Rebuilding audio player and handling playback sessions in android. Moving all logic natively

This commit is contained in:
advplyr 2022-04-02 12:12:00 -05:00
parent 314dc960f2
commit f70f707100
19 changed files with 180 additions and 596 deletions

View file

@ -5,6 +5,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import com.capacitorjs.plugins.app.AppPlugin import com.capacitorjs.plugins.app.AppPlugin
@ -30,7 +31,15 @@ class MyNativeAudio : Plugin() {
playerNotificationService.setBridge(bridge) playerNotificationService.setBridge(bridge)
playerNotificationService.setCustomObjectListener(object : PlayerNotificationService.MyCustomObjectListener { playerNotificationService.clientEventEmitter = (object : PlayerNotificationService.ClientEventEmitter {
override fun onPlaybackSession(playbackSession: PlaybackSession) {
notifyListeners("onPlaybackSession", JSObject(jacksonObjectMapper().writeValueAsString(playbackSession)))
}
override fun onPlaybackClosed() {
emit("onPlaybackClosed", true)
}
override fun onPlayingUpdate(isPlaying: Boolean) { override fun onPlayingUpdate(isPlaying: Boolean) {
emit("onPlayingUpdate", isPlaying) emit("onPlayingUpdate", isPlaying)
} }
@ -67,10 +76,9 @@ class MyNativeAudio : Plugin() {
@PluginMethod @PluginMethod
fun prepareLibraryItem(call: PluginCall) { fun prepareLibraryItem(call: PluginCall) {
var libraryItemId = call.getString("libraryItemId", "").toString() var libraryItemId = call.getString("libraryItemId", "").toString()
var mediaEntityId = call.getString("mediaEntityId", "").toString()
var playWhenReady = call.getBoolean("playWhenReady") == true var playWhenReady = call.getBoolean("playWhenReady") == true
apiHandler.playLibraryItem(libraryItemId) { apiHandler.playLibraryItem(libraryItemId, false) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
Log.d(tag, "Preparing Player TEST ${jacksonObjectMapper().writeValueAsString(it)}") Log.d(tag, "Preparing Player TEST ${jacksonObjectMapper().writeValueAsString(it)}")

View file

@ -54,7 +54,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
var isStarted = false var isStarted = false
} }
interface MyCustomObjectListener { interface ClientEventEmitter {
fun onPlaybackSession(playbackSession:PlaybackSession)
fun onPlaybackClosed()
fun onPlayingUpdate(isPlaying: Boolean) fun onPlayingUpdate(isPlaying: Boolean)
fun onMetadata(metadata: JSObject) fun onMetadata(metadata: JSObject)
fun onPrepare(audiobookId: String, playWhenReady: Boolean) fun onPrepare(audiobookId: String, playWhenReady: Boolean)
@ -65,7 +67,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private val tag = "PlayerService" private val tag = "PlayerService"
private val binder = LocalBinder() private val binder = LocalBinder()
var listener:MyCustomObjectListener? = null var clientEventEmitter:ClientEventEmitter? = null
private lateinit var ctx:Context private lateinit var ctx:Context
private lateinit var mediaSessionConnector: MediaSessionConnector private lateinit var mediaSessionConnector: MediaSessionConnector
@ -106,9 +108,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private var mShakeDetector: ShakeDetector? = null private var mShakeDetector: ShakeDetector? = null
private var shakeSensorUnregisterTask:TimerTask? = null private var shakeSensorUnregisterTask:TimerTask? = null
fun setCustomObjectListener(mylistener: MyCustomObjectListener) {
listener = mylistener
}
fun setBridge(bridge: Bridge) { fun setBridge(bridge: Bridge) {
webviewBridge = bridge webviewBridge = bridge
} }
@ -380,8 +379,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
var builder = MediaDescriptionCompat.Builder() var builder = MediaDescriptionCompat.Builder()
.setMediaId(currentPlaybackSession!!.id) .setMediaId(currentPlaybackSession!!.id)
.setTitle(currentPlaybackSession!!.getTitle()) .setTitle(currentPlaybackSession!!.displayTitle)
.setSubtitle(currentPlaybackSession!!.getAuthor()) .setSubtitle(currentPlaybackSession!!.displayAuthor)
.setMediaUri(currentPlaybackSession!!.getContentUri()) .setMediaUri(currentPlaybackSession!!.getContentUri())
.setIconUri(currentPlaybackSession!!.getCoverUri()) .setIconUri(currentPlaybackSession!!.getCoverUri())
return builder.build() return builder.build()
@ -656,7 +655,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
audiobookProgressSyncer.stop() audiobookProgressSyncer.stop()
} }
listener?.onPlayingUpdate(player.isPlaying) clientEventEmitter?.onPlayingUpdate(player.isPlaying)
} }
} }
} }
@ -668,6 +667,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
*/ */
fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean) { fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean) {
currentPlaybackSession = playbackSession currentPlaybackSession = playbackSession
clientEventEmitter?.onPlaybackSession(playbackSession)
var metadata = playbackSession.getMediaMetadataCompat() var metadata = playbackSession.getMediaMetadataCompat()
mediaSession.setMetadata(metadata) mediaSession.setMetadata(metadata)
var mediaMetadata = playbackSession.getExoMediaMetadata() var mediaMetadata = playbackSession.getExoMediaMetadata()
@ -679,7 +681,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (mPlayer == currentPlayer) { if (mPlayer == currentPlayer) {
var mediaSource:MediaSource var mediaSource:MediaSource
if (currentPlaybackSession?.isLocal == true) { if (!playbackSession.isHLS) {
Log.d(tag, "Playing Local File") Log.d(tag, "Playing Local File")
var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId) var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
@ -881,11 +883,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun terminateStream() { fun terminateStream() {
if (currentPlayer.playbackState == Player.STATE_READY) { // if (currentPlayer.playbackState == Player.STATE_READY) {
currentPlayer.clearMediaItems() // currentPlayer.clearMediaItems()
} // }
currentAudiobookStreamData?.id = "" currentPlayer.clearMediaItems()
currentPlaybackSession = null
lastPauseTime = 0 lastPauseTime = 0
clientEventEmitter?.onPlaybackClosed()
} }
fun sendClientMetadata(stateName: String) { fun sendClientMetadata(stateName: String) {
@ -895,7 +899,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
metadata.put("duration", duration) metadata.put("duration", duration)
metadata.put("currentTime", mPlayer.currentPosition) metadata.put("currentTime", mPlayer.currentPosition)
metadata.put("stateName", stateName) metadata.put("stateName", stateName)
listener?.onMetadata(metadata) clientEventEmitter?.onMetadata(metadata)
} }

View file

@ -87,7 +87,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
} }
} }
playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
sleepTimerRunning = true sleepTimerRunning = true
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) { sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
@ -99,14 +99,14 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s") Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
if (sleepTimeSecondsRemaining > 0) { if (sleepTimeSecondsRemaining > 0) {
playerNotificationService.listener?.onSleepTimerSet(sleepTimeSecondsRemaining) playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining)
} }
if (sleepTimeSecondsRemaining <= 0) { if (sleepTimeSecondsRemaining <= 0) {
Log.d(tag, "Sleep Timer Pausing Player on Chapter") Log.d(tag, "Sleep Timer Pausing Player on Chapter")
pause() pause()
playerNotificationService.listener?.onSleepTimerEnded(getCurrentTime()) playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
clearSleepTimer() clearSleepTimer()
sleepTimerFinishedAt = System.currentTimeMillis() sleepTimerFinishedAt = System.currentTimeMillis()
} else if (sleepTimeSecondsRemaining <= 30) { } else if (sleepTimeSecondsRemaining <= 30) {
@ -136,7 +136,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
fun cancelSleepTimer() { fun cancelSleepTimer() {
Log.d(tag, "Canceling Sleep Timer") Log.d(tag, "Canceling Sleep Timer")
clearSleepTimer() clearSleepTimer()
playerNotificationService.listener?.onSleepTimerSet(0) playerNotificationService.clientEventEmitter?.onSleepTimerSet(0)
} }
private fun extendSleepTime() { private fun extendSleepTime() {
@ -150,7 +150,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration() if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration()
} }
playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
} }
fun checkShouldExtendSleepTimer() { fun checkShouldExtendSleepTimer() {
@ -197,7 +197,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
} }
setVolume(1F) setVolume(1F)
playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
} }
fun decreaseSleepTime(time: Long) { fun decreaseSleepTime(time: Long) {
@ -219,6 +219,6 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
} }
setVolume(1F) setVolume(1F)
playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
} }
} }

View file

@ -46,7 +46,8 @@ data class Book(
var metadata:BookMetadata, var metadata:BookMetadata,
var coverPath:String?, var coverPath:String?,
var tags:MutableList<String>, var tags:MutableList<String>,
var audioFiles:MutableList<AudioFile> var audioFiles:MutableList<AudioFile>,
var chapters:MutableList<BookChapter>
) : MediaType() ) : MediaType()
// This auto-detects whether it is a Book or Podcast // This auto-detects whether it is a Book or Podcast
@ -154,3 +155,11 @@ data class AudioTrack(
var localFileId:String?, var localFileId:String?,
var audioProbeResult:AudioProbeResult? var audioProbeResult:AudioProbeResult?
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class BookChapter(
var id:Int,
var start:Double,
var end:Double,
var title:String?
)

View file

@ -52,11 +52,9 @@ class DbManager : Plugin() {
} }
fun saveLocalMediaItems(localMediaItems:List<LocalMediaItem>) { fun saveLocalMediaItems(localMediaItems:List<LocalMediaItem>) {
GlobalScope.launch(Dispatchers.IO) {
localMediaItems.map { localMediaItems.map {
Paper.book("localMediaItems").write(it.id, it) Paper.book("localMediaItems").write(it.id, it)
} }
}
} }
fun saveLocalFolder(localFolder:LocalFolder) { fun saveLocalFolder(localFolder:LocalFolder) {

View file

@ -29,7 +29,7 @@ data class LocalMediaItem(
var absolutePath:String, var absolutePath:String,
var audioTracks:MutableList<AudioTrack>, var audioTracks:MutableList<AudioTrack>,
var localFiles:MutableList<LocalFile>, var localFiles:MutableList<LocalFile>,
var coverPath:String? var coverContentUrl:String?
) { ) {
@JsonIgnore @JsonIgnore
@ -53,7 +53,7 @@ data class LocalMediaItem(
var sessionId = "play-${UUID.randomUUID()}" var sessionId = "play-${UUID.randomUUID()}"
var mediaMetadata = getMediaMetadata() var mediaMetadata = getMediaMetadata()
return PlaybackSession(sessionId,null,null,null,null,mediaType,mediaMetadata,null,getDuration(),PLAYMETHOD_LOCAL,audioTracks,0.0,null,this,null,null) return PlaybackSession(sessionId,null,null,null, mediaType, mediaMetadata, mutableListOf(), name, "author name here",null,getDuration(),PLAYMETHOD_LOCAL,audioTracks,0.0,null,this,null,null)
} }
} }

View file

@ -19,9 +19,11 @@ class PlaybackSession(
var userId:String?, var userId:String?,
var libraryItemId:String?, var libraryItemId:String?,
var episodeId:String?, var episodeId:String?,
var mediaEntityId:String?,
var mediaType:String, var mediaType:String,
var mediaMetadata:MediaTypeMetadata, var mediaMetadata:MediaTypeMetadata,
var chapters:MutableList<BookChapter>,
var displayTitle: String?,
var displayAuthor: String?,
var coverPath:String?, var coverPath:String?,
var duration:Double, var duration:Double,
var playMethod:Int, var playMethod:Int,
@ -37,23 +39,9 @@ class PlaybackSession(
val isLocal get() = playMethod == PLAYMETHOD_LOCAL val isLocal get() = playMethod == PLAYMETHOD_LOCAL
val currentTimeMs get() = (currentTime * 1000L).toLong() val currentTimeMs get() = (currentTime * 1000L).toLong()
@JsonIgnore
fun getTitle():String {
if (mediaMetadata == null) return "Unset"
var metadata = mediaMetadata as BookMetadata
return metadata.title
}
@JsonIgnore
fun getAuthor():String {
if (mediaMetadata == null) return "Unset"
var metadata = mediaMetadata as BookMetadata
return metadata.authorName ?: "Unset"
}
@JsonIgnore @JsonIgnore
fun getCoverUri(): Uri { fun getCoverUri(): Uri {
if (localMediaItem?.coverPath != null) return Uri.parse(localMediaItem?.coverPath) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) if (localMediaItem?.coverContentUrl != null) return Uri.parse(localMediaItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
return Uri.parse("$serverUrl/api/items/$libraryItemId/cover?token=$token") return Uri.parse("$serverUrl/api/items/$libraryItemId/cover?token=$token")
@ -75,11 +63,11 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getMediaMetadataCompat(): MediaMetadataCompat { fun getMediaMetadataCompat(): MediaMetadataCompat {
var metadataBuilder = MediaMetadataCompat.Builder() var metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, this.getTitle()) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, getTitle()) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, this.getAuthor()) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, this.getAuthor()) .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, this.getAuthor()) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series") .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series")
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
return metadataBuilder.build() return metadataBuilder.build()
@ -87,13 +75,12 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getExoMediaMetadata(): MediaMetadata { fun getExoMediaMetadata(): MediaMetadata {
var authorName = this.getAuthor()
var metadataBuilder = MediaMetadata.Builder() var metadataBuilder = MediaMetadata.Builder()
.setTitle(this.getTitle()) .setTitle(displayTitle)
.setDisplayTitle(this.getTitle()) .setDisplayTitle(displayTitle)
.setArtist(authorName) .setArtist(displayAuthor)
.setAlbumArtist(authorName) .setAlbumArtist(displayAuthor)
.setSubtitle(authorName) .setSubtitle(displayAuthor)
var contentUri = this.getContentUri() var contentUri = this.getContentUri()
metadataBuilder.setMediaUri(contentUri) metadataBuilder.setMediaUri(contentUri)

View file

@ -11,6 +11,9 @@ import com.arthenica.ffmpegkit.Level
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class FolderScanner(var ctx: Context) { class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner" private val tag = "FolderScanner"
@ -69,7 +72,7 @@ class FolderScanner(var ctx: Context) {
var localFiles = mutableListOf<LocalFile>() var localFiles = mutableListOf<LocalFile>()
var index = 1 var index = 1
var startOffset = 0.0 var startOffset = 0.0
var coverPath:String? = null var coverContentUrl:String? = null
var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
@ -146,14 +149,14 @@ class FolderScanner(var ctx: Context) {
if (existingLocalFile == null) { if (existingLocalFile == null) {
isNewOrUpdated = true isNewOrUpdated = true
} }
if (existingMediaItem != null && existingMediaItem.coverPath == null) { if (existingMediaItem != null && existingMediaItem.coverContentUrl == null) {
// Existing media item did not have a cover - cover found on scan // Existing media item did not have a cover - cover found on scan
isNewOrUpdated = true isNewOrUpdated = true
} }
// First image file use as cover path // First image file use as cover path
if (coverPath == null) { if (coverContentUrl == null) {
coverPath = localFile.contentUrl coverContentUrl = localFile.contentUrl
} }
} }
} }
@ -170,7 +173,7 @@ class FolderScanner(var ctx: Context) {
else mediaItemsAdded++ else mediaItemsAdded++
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files") Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, it.uri.toString(), it.getSimplePath(ctx), it.getAbsolutePath(ctx),audioTracks,localFiles,coverPath) var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, it.uri.toString(), it.getSimplePath(ctx), it.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl)
mediaItems.add(localMediaItem) mediaItems.add(localMediaItem)
} }
} }

View file

@ -109,11 +109,14 @@ class ApiHandler {
} }
} }
fun playLibraryItem(libraryItemId:String, cb: (PlaybackSession) -> Unit) { fun playLibraryItem(libraryItemId:String, forceTranscode:Boolean, cb: (PlaybackSession) -> Unit) {
val mapper = jacksonObjectMapper() val mapper = jacksonObjectMapper()
var payload = JSObject() var payload = JSObject()
payload.put("mediaPlayer", "exo-player") payload.put("mediaPlayer", "exo-player")
payload.put("forceDirectPlay", true)
// Only if direct play fails do we force transcode
if (!forceTranscode) payload.put("forceDirectPlay", true)
else payload.put("forceTranscode", true)
postRequest("/api/items/$libraryItemId/play", payload) { postRequest("/api/items/$libraryItemId/play", payload) {
it.put("serverUrl", serverUrl) it.put("serverUrl", serverUrl)

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="showFullscreen ? 'fullscreen' : ''"> <div v-if="playbackSession" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="showFullscreen ? 'fullscreen' : ''">
<div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto"> <div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto">
<div class="top-2 left-4 absolute cursor-pointer"> <div class="top-2 left-4 absolute cursor-pointer">
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span> <span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
@ -31,7 +31,7 @@
<div class="cover-wrapper absolute z-30 pointer-events-auto" :class="bookCoverAspectRatio === 1 ? 'square-cover' : ''" @click="clickContainer"> <div class="cover-wrapper absolute z-30 pointer-events-auto" :class="bookCoverAspectRatio === 1 ? 'square-cover' : ''" @click="clickContainer">
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full"> <div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full">
<covers-book-cover :library-item="libraryItem" :download-cover="downloadedCover" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover v-if="libraryItem || localMediaItemCoverSrc" :library-item="libraryItem" :download-cover="localMediaItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
</div> </div>
@ -52,7 +52,7 @@
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p> <p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
</div> </div>
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="$emit('selectChapter')">format_list_bulleted</span> <span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="showChapterModal = true">format_list_bulleted</span>
</div> </div>
</div> </div>
@ -84,27 +84,18 @@
</div> </div>
</div> </div>
</div> </div>
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
</div> </div>
</template> </template>
<script> <script>
import { Capacitor } from '@capacitor/core'
import MyNativeAudio from '@/plugins/my-native-audio' import MyNativeAudio from '@/plugins/my-native-audio'
export default { export default {
props: { props: {
playing: Boolean, playing: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
mediaEntity: {
type: Object,
default: () => {}
},
download: {
type: Object,
default: () => {}
},
bookmarks: { bookmarks: {
type: Array, type: Array,
default: () => [] default: () => []
@ -114,6 +105,10 @@ export default {
}, },
data() { data() {
return { return {
// Main
playbackSession: null,
// Others
showChapterModal: false,
showCastBtn: false, showCastBtn: false,
showFullscreen: false, showFullscreen: false,
totalDuration: 0, totalDuration: 0,
@ -121,9 +116,6 @@ export default {
currentTime: 0, currentTime: 0,
bufferedTime: 0, bufferedTime: 0,
isResetting: false, isResetting: false,
initObject: null,
streamId: null,
audiobookId: null,
stateName: 'idle', stateName: 'idle',
playInterval: null, playInterval: null,
trackWidth: 0, trackWidth: 0,
@ -134,15 +126,13 @@ export default {
playedTrackWidth: 0, playedTrackWidth: 0,
seekedTime: 0, seekedTime: 0,
seekLoading: false, seekLoading: false,
onPlaybackSessionListener: null,
onPlaybackClosedListener: null,
onPlayingUpdateListener: null, onPlayingUpdateListener: null,
onMetadataListener: null, onMetadataListener: null,
// noSyncUpdateTime: false,
touchStartY: 0, touchStartY: 0,
touchStartTime: 0, touchStartTime: 0,
touchEndY: 0, touchEndY: 0,
listenTimeInterval: null,
listeningTimeSinceLastUpdate: 0,
totalListeningTimeInSession: 0,
useChapterTrack: false, useChapterTrack: false,
isLoading: true isLoading: true
} }
@ -179,28 +169,42 @@ export default {
} }
return this.showFullscreen ? 200 : 60 return this.showFullscreen ? 200 : 60
}, },
media() {
return this.libraryItem.media || {}
},
mediaMetadata() { mediaMetadata() {
return this.media.metadata || {} return this.playbackSession ? this.playbackSession.mediaMetadata : null
},
libraryItem() {
return this.playbackSession ? this.playbackSession.libraryItem || null : null
},
localMediaItem() {
return this.playbackSession ? this.playbackSession.localMediaItem || null : null
},
localMediaItemCoverSrc() {
var localMediaItemCover = this.localMediaItem ? this.localMediaItem.coverContentUrl : null
if (localMediaItemCover) return Capacitor.convertFileSrc(localMediaItemCover)
return null
},
playMethod() {
return this.playbackSession ? this.playbackSession.playMethod : null
},
isLocalPlayMethod() {
return this.playMethod == this.$constants.PlayMethod.LOCAL
}, },
title() { title() {
return this.mediaMetadata.title if (this.playbackSession) return this.playbackSession.displayTitle
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
}, },
authorName() { authorName() {
return this.mediaMetadata.authorName if (this.playbackSession) return this.playbackSession.displayAuthor
return this.mediaMetadata ? this.mediaMetadata.authorName : 'Author'
}, },
chapters() { chapters() {
return (this.mediaEntity ? this.mediaEntity.chapters || [] : []).map((chapter) => { if (this.playbackSession && this.playbackSession.chapters) {
var chap = { ...chapter } return this.playbackSession.chapters
chap.start = Number(chap.start) }
chap.end = Number(chap.end) return []
return chap
})
}, },
currentChapter() { currentChapter() {
if (!this.mediaEntity || !this.chapters.length) return null if (!this.chapters.length) return null
return this.chapters.find((ch) => Number(Number(ch.start).toFixed(2)) <= this.currentTime && Number(Number(ch.end).toFixed(2)) > this.currentTime) return this.chapters.find((ch) => Number(Number(ch.start).toFixed(2)) <= this.currentTime && Number(Number(ch.end).toFixed(2)) > this.currentTime)
}, },
nextChapter() { nextChapter() {
@ -213,9 +217,6 @@ export default {
currentChapterDuration() { currentChapterDuration() {
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
}, },
downloadedCover() {
return this.download ? this.download.cover : null
},
totalDurationPretty() { totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration) return this.$secondsToTimestamp(this.totalDuration)
}, },
@ -248,10 +249,6 @@ export default {
if (!this.currentChapter) return 0 if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentTime return this.currentChapter.end - this.currentTime
}, },
// sleepTimeRemaining() {
// if (!this.sleepTimerEndTime) return 0
// return Math.max(0, this.sleepTimerEndTime / 1000 - this.currentTime)
// },
sleepTimeRemainingPretty() { sleepTimeRemainingPretty() {
if (!this.sleepTimeRemaining) return '0s' if (!this.sleepTimeRemaining) return '0s'
var secondsRemaining = Math.round(this.sleepTimeRemaining) var secondsRemaining = Math.round(this.sleepTimeRemaining)
@ -263,66 +260,14 @@ export default {
} }
}, },
methods: { methods: {
selectChapter(chapter) {
this.seek(chapter.start)
this.showChapterModal = false
},
castClick() { castClick() {
console.log('Cast Btn Click') console.log('Cast Btn Click')
MyNativeAudio.requestSession() MyNativeAudio.requestSession()
}, },
sendStreamSync(timeListened = 0) {
var syncData = {
timeListened,
currentTime: this.currentTime,
streamId: this.streamId,
audiobookId: this.audiobookId,
totalDuration: this.totalDuration
}
this.$emit('sync', syncData)
},
sendAddListeningTime() {
var listeningTimeToAdd = Math.floor(this.listeningTimeSinceLastUpdate)
this.listeningTimeSinceLastUpdate = Math.max(0, this.listeningTimeSinceLastUpdate - listeningTimeToAdd)
this.sendStreamSync(listeningTimeToAdd)
},
cancelListenTimeInterval() {
this.sendAddListeningTime()
clearInterval(this.listenTimeInterval)
this.listenTimeInterval = null
},
startListenTimeInterval() {
clearInterval(this.listenTimeInterval)
var lastTime = this.currentTime
var lastTick = Date.now()
var noProgressCount = 0
this.listenTimeInterval = setInterval(() => {
var timeSinceLastTick = Date.now() - lastTick
lastTick = Date.now()
var expectedAudioTime = lastTime + timeSinceLastTick / 1000
var currentTime = this.currentTime
var differenceFromExpected = expectedAudioTime - currentTime
if (currentTime === lastTime) {
noProgressCount++
if (noProgressCount > 3) {
console.error('Audio current time has not increased - cancel interval and pause player')
this.pause()
}
} else if (Math.abs(differenceFromExpected) > 0.1) {
noProgressCount = 0
console.warn('Invalid time between interval - resync last', differenceFromExpected)
lastTime = currentTime
} else {
noProgressCount = 0
var exactPlayTimeDifference = currentTime - lastTime
// console.log('Difference from expected', differenceFromExpected, 'Exact play time diff', exactPlayTimeDifference)
lastTime = currentTime
this.listeningTimeSinceLastUpdate += exactPlayTimeDifference
this.totalListeningTimeInSession += exactPlayTimeDifference
// console.log('Time since last update:', this.listeningTimeSinceLastUpdate, 'Session listening time:', this.totalListeningTimeInSession)
if (this.listeningTimeSinceLastUpdate > 5) {
this.sendAddListeningTime()
}
}
}, 1000)
},
clickContainer() { clickContainer() {
this.showFullscreen = true this.showFullscreen = true
@ -520,94 +465,6 @@ export default {
this.pause() this.pause()
} }
}, },
calcSeekBackTime(lastUpdate) {
var time = Date.now() - lastUpdate
var seekback = 0
if (time < 60000) seekback = 0
else if (time < 120000) seekback = 10000
else if (time < 300000) seekback = 15000
else if (time < 1800000) seekback = 20000
else if (time < 3600000) seekback = 25000
else seekback = 29500
return seekback
},
async set(audiobookStreamData, stream, fromAppDestroy) {
this.isResetting = false
this.bufferedTime = 0
this.streamId = stream ? stream.id : null
this.audiobookId = audiobookStreamData.audiobookId
this.initObject = { ...audiobookStreamData }
console.log('[AudioPlayer] Set Audio Player', !!stream)
var init = true
if (!!stream) {
//console.log(JSON.stringify(stream))
var data = await MyNativeAudio.getStreamSyncData()
console.log('getStreamSyncData', JSON.stringify(data))
console.log('lastUpdate', stream.lastUpdate || 0)
//Same audiobook
if (data.id == stream.id && (data.isPlaying || data.lastPauseTime >= (stream.lastUpdate || 0))) {
console.log('Same audiobook')
this.isPaused = !data.isPlaying
this.currentTime = Number((data.currentTime / 1000).toFixed(2))
this.totalDuration = Number((data.duration / 1000).toFixed(2))
this.$emit('setTotalDuration', this.totalDuration)
this.timeupdate()
if (data.isPlaying) {
console.log('playing - continue')
if (fromAppDestroy) this.startPlayInterval()
} else console.log('paused and newer')
if (!fromAppDestroy) return
init = false
this.initObject.startTime = String(Math.floor(this.currentTime * 1000))
}
//new audiobook stream or sync from other client
else if (stream.clientCurrentTime > 0) {
console.log('new audiobook stream or sync from other client')
if (!!stream.lastUpdate) {
var backTime = this.calcSeekBackTime(stream.lastUpdate)
var currentTime = Math.floor(stream.clientCurrentTime * 1000)
if (backTime >= currentTime) backTime = currentTime - 500
console.log('SeekBackTime', backTime)
this.initObject.startTime = String(Math.floor(currentTime - backTime))
}
}
}
this.currentPlaybackRate = this.initObject.playbackSpeed
console.log(`[AudioPlayer] Set Stream Playback Rate: ${this.currentPlaybackRate}`)
if (init)
MyNativeAudio.initPlayer(this.initObject).then((res) => {
if (res && res.success) {
console.log('Success init audio player')
} else {
console.error('Failed to init audio player')
}
})
if (audiobookStreamData.isLocal) {
this.setStreamReady()
}
},
setFromObj() {
if (!this.initObject) {
console.error('Cannot set from obj')
return
}
this.isResetting = false
MyNativeAudio.initPlayer(this.initObject).then((res) => {
if (res && res.success) {
console.log('Success init audio player')
} else {
console.error('Failed to init audio player')
}
})
if (audiobookStreamData.isLocal) {
this.setStreamReady()
}
},
play() { play() {
MyNativeAudio.playPlayer() MyNativeAudio.playPlayer()
this.startPlayInterval() this.startPlayInterval()
@ -619,8 +476,6 @@ export default {
this.isPlaying = false this.isPlaying = false
}, },
startPlayInterval() { startPlayInterval() {
this.startListenTimeInterval()
clearInterval(this.playInterval) clearInterval(this.playInterval)
this.playInterval = setInterval(async () => { this.playInterval = setInterval(async () => {
var data = await MyNativeAudio.getCurrentTime() var data = await MyNativeAudio.getCurrentTime()
@ -631,20 +486,14 @@ export default {
}, 1000) }, 1000)
}, },
stopPlayInterval() { stopPlayInterval() {
this.cancelListenTimeInterval()
clearInterval(this.playInterval) clearInterval(this.playInterval)
}, },
resetStream(startTime) { resetStream(startTime) {
var _time = String(Math.floor(startTime * 1000))
if (!this.initObject) {
console.error('Terminate stream when no init object is set...')
return
}
this.isResetting = true this.isResetting = true
this.initObject.currentTime = _time
this.terminateStream() this.terminateStream()
}, },
terminateStream() { terminateStream() {
if (!this.playbackSession) return
MyNativeAudio.terminateStream() MyNativeAudio.terminateStream()
}, },
onPlayingUpdate(data) { onPlayingUpdate(data) {
@ -671,17 +520,32 @@ export default {
this.timeupdate() this.timeupdate()
}, },
// When a playback session is started the native android/ios will send the session
onPlaybackSession(playbackSession) {
console.log('onPlaybackSession received', JSON.stringify(playbackSession))
this.playbackSession = playbackSession
// Set track width
this.$nextTick(() => {
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
})
},
onPlaybackClosed() {
console.log('Received onPlaybackClosed evt')
this.showFullscreen = false
this.playbackSession = null
},
async init() { async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack() this.useChapterTrack = await this.$localStore.getUseChapterTrack()
this.onPlaybackSessionListener = MyNativeAudio.addListener('onPlaybackSession', this.onPlaybackSession)
this.onPlaybackClosedListener = MyNativeAudio.addListener('onPlaybackClosed', this.onPlaybackClosed)
this.onPlayingUpdateListener = MyNativeAudio.addListener('onPlayingUpdate', this.onPlayingUpdate) this.onPlayingUpdateListener = MyNativeAudio.addListener('onPlayingUpdate', this.onPlayingUpdate)
this.onMetadataListener = MyNativeAudio.addListener('onMetadata', this.onMetadata) this.onMetadataListener = MyNativeAudio.addListener('onMetadata', this.onMetadata)
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
}, },
handleGesture() { handleGesture() {
var touchDistance = this.touchEndY - this.touchStartY var touchDistance = this.touchEndY - this.touchStartY
@ -721,7 +585,7 @@ export default {
}) })
this.$localStore.setUseChapterTrack(this.useChapterTrack) this.$localStore.setUseChapterTrack(this.useChapterTrack)
} else if (action === 'close') { } else if (action === 'close') {
this.$emit('close') this.terminateStream()
} }
}, },
forceCloseDropdownMenu() { forceCloseDropdownMenu() {
@ -743,6 +607,8 @@ export default {
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove() if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
if (this.onMetadataListener) this.onMetadataListener.remove() if (this.onMetadataListener) this.onMetadataListener.remove()
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove()
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
clearInterval(this.playInterval) clearInterval(this.playInterval)
} }
} }

View file

@ -1,20 +1,14 @@
<template> <template>
<div> <div>
<div v-if="libraryItemPlaying" id="streamContainer"> <div id="streamContainer">
<app-audio-player <app-audio-player
ref="audioPlayer" ref="audioPlayer"
:playing.sync="isPlaying" :playing.sync="isPlaying"
:library-item="libraryItemPlaying"
:media-entity="mediaEntityPlaying"
:download="download"
:bookmarks="bookmarks" :bookmarks="bookmarks"
:sleep-timer-running="isSleepTimerRunning" :sleep-timer-running="isSleepTimerRunning"
:sleep-time-remaining="sleepTimeRemaining" :sleep-time-remaining="sleepTimeRemaining"
@close="cancelStream"
@sync="sync"
@setTotalDuration="setTotalDuration" @setTotalDuration="setTotalDuration"
@selectPlaybackSpeed="showPlaybackSpeedModal = true" @selectPlaybackSpeed="showPlaybackSpeedModal = true"
@selectChapter="clickChapterBtn"
@updateTime="(t) => (currentTime = t)" @updateTime="(t) => (currentTime = t)"
@showSleepTimer="showSleepTimer" @showSleepTimer="showSleepTimer"
@showBookmarks="showBookmarks" @showBookmarks="showBookmarks"
@ -23,14 +17,12 @@
</div> </div>
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" /> <modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" /> <modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
<modals-bookmarks-modal v-model="showBookmarksModal" :audiobook-id="audiobookId" :bookmarks="bookmarks" :current-time="currentTime" @select="selectBookmark" /> <modals-bookmarks-modal v-model="showBookmarksModal" :audiobook-id="audiobookId" :bookmarks="bookmarks" :current-time="currentTime" @select="selectBookmark" />
</div> </div>
</template> </template>
<script> <script>
import { Dialog } from '@capacitor/dialog'
import MyNativeAudio from '@/plugins/my-native-audio' import MyNativeAudio from '@/plugins/my-native-audio'
export default { export default {
@ -40,12 +32,10 @@ export default {
audioPlayerReady: false, audioPlayerReady: false,
stream: null, stream: null,
download: null, download: null,
lastProgressTimeUpdate: 0,
showPlaybackSpeedModal: false, showPlaybackSpeedModal: false,
showBookmarksModal: false, showBookmarksModal: false,
showSleepTimerModal: false, showSleepTimerModal: false,
playbackSpeed: 1, playbackSpeed: 1,
showChapterModal: false,
currentTime: 0, currentTime: 0,
isSleepTimerRunning: false, isSleepTimerRunning: false,
sleepTimerEndTime: 0, sleepTimerEndTime: 0,
@ -66,96 +56,13 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemPlaying() {
return this.$store.state.globals.libraryItemPlaying
},
mediaEntityPlaying() {
return this.$store.state.globals.mediaEntityPlaying
},
userAudiobook() {
if (!this.audiobookId) return
return this.$store.getters['user/getUserAudiobookData'](this.audiobookId)
},
bookmarks() { bookmarks() {
if (!this.userAudiobook) return [] // return this.$store.getters['user/getUserBookmarksForItem'](this.)
return this.userAudiobook.bookmarks || [] return []
},
currentChapter() {
if (!this.audiobook || !this.chapters.length) return null
return this.chapters.find((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) > this.currentTime)
}, },
socketConnected() { socketConnected() {
return this.$store.state.socketConnected return this.$store.state.socketConnected
},
playingDownload() {
return this.$store.state.playingDownload
},
audiobook() {
if (this.playingDownload) return this.playingDownload.audiobook
return this.streamAudiobook
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
title() {
return this.book ? this.book.title : ''
},
author() {
return this.book ? this.book.author : ''
},
cover() {
return this.book ? this.book.cover : ''
},
series() {
return this.book ? this.book.series : ''
},
chapters() {
return this.audiobook ? this.audiobook.chapters || [] : []
},
volumeNumber() {
return this.book ? this.book.volumeNumber : ''
},
seriesTxt() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
duration() {
return this.audiobook ? this.audiobook.duration || 0 : 0
},
coverForNative() {
if (!this.cover) {
return `${this.$store.state.serverUrl}/Logo.png`
}
if (this.cover.startsWith('http')) return this.cover
var coverSrc = this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook)
return coverSrc
},
tracksForCast() {
if (!this.audiobook || !this.audiobook.tracks) {
return []
}
var abpath = this.audiobook.path
var tracks = this.audiobook.tracks.map((t) => {
var trelpath = t.path.replace(abpath, '')
if (trelpath.startsWith('/')) trelpath = trelpath.substr(1)
return `${this.$store.state.serverUrl}/s/book/${this.audiobook.id}/${trelpath}?token=${this.userToken}`
})
return tracks
} }
// sleepTimeRemaining() {
// if (!this.sleepTimerEndTime) return 0
// return Math.max(0, this.sleepTimerEndTime / 1000 - this.currentTime)
// }
}, },
methods: { methods: {
showBookmarks() { showBookmarks() {
@ -174,7 +81,7 @@ export default {
if (currentPosition) { if (currentPosition) {
console.log('Sleep Timer Ended Current Position: ' + currentPosition) console.log('Sleep Timer Ended Current Position: ' + currentPosition)
var currentTime = Math.floor(currentPosition / 1000) var currentTime = Math.floor(currentPosition / 1000)
this.updateTime(currentTime) // TODO: Was syncing to the server here before
} }
}, },
onSleepTimerSet({ value: sleepTimeRemaining }) { onSleepTimerSet({ value: sleepTimeRemaining }) {
@ -189,8 +96,8 @@ export default {
this.sleepTimeRemaining = sleepTimeRemaining this.sleepTimeRemaining = sleepTimeRemaining
}, },
showSleepTimer() { showSleepTimer() {
if (this.currentChapter) { if (this.$refs.audioPlayer && this.$refs.audioPlayer.currentChapter) {
this.currentEndOfChapterTime = Math.floor(this.currentChapter.end) this.currentEndOfChapterTime = Math.floor(this.$refs.audioPlayer.currentChapter.end)
} else { } else {
this.currentEndOfChapterTime = 0 this.currentEndOfChapterTime = 0
} }
@ -214,85 +121,11 @@ export default {
console.log('Canceling sleep timer') console.log('Canceling sleep timer')
await MyNativeAudio.cancelSleepTimer() await MyNativeAudio.cancelSleepTimer()
}, },
clickChapterBtn() {
if (!this.chapters.length) return
this.showChapterModal = true
},
selectChapter(chapter) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.seek(chapter.start)
}
this.showChapterModal = false
},
async cancelStream() {
this.currentTime = 0
if (this.download) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
this.download = null
this.$store.commit('setPlayingDownload', null)
this.$localStore.setCurrent(null)
} else {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Cancel this stream?'
})
if (value) {
this.$server.socket.emit('close_stream')
this.$store.commit('setStreamAudiobook', null)
this.$server.stream = null
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
}
}
},
sync(syncData) {
var diff = syncData.currentTime - this.lastServerUpdateSentSeconds
if (Math.abs(diff) < 1 && !syncData.timeListened) {
// No need to sync
return
}
if (this.stream) {
this.$server.socket.emit('stream_sync', syncData)
} else {
var progressUpdate = {
audiobookId: syncData.audiobookId,
currentTime: syncData.currentTime,
totalDuration: syncData.totalDuration,
progress: syncData.totalDuration ? Number((syncData.currentTime / syncData.totalDuration).toFixed(3)) : 0,
lastUpdate: Date.now(),
isRead: false
}
if (this.$server.connected) {
this.$server.socket.emit('progress_update', progressUpdate)
} else {
this.$store.dispatch('user/updateUserAudiobookData', progressUpdate)
}
}
},
updateTime(currentTime) {
this.sync({
currentTime,
audiobookId: this.audiobookId,
streamId: this.stream ? this.stream.id : null,
timeListened: 0,
totalDuration: this.totalDuration || 0
})
},
setTotalDuration(duration) { setTotalDuration(duration) {
this.totalDuration = duration this.totalDuration = duration
}, },
streamClosed(audiobookId) { streamClosed() {
console.log('Stream Closed') console.log('Stream Closed')
if (this.stream.audiobook.id === audiobookId || audiobookId === 'n/a') {
this.$store.commit('setStreamAudiobook', null)
}
}, },
streamProgress(data) { streamProgress(data) {
if (!data.numSegments) return if (!data.numSegments) return
@ -308,131 +141,13 @@ export default {
} }
}, },
streamReset({ streamId, startTime }) { streamReset({ streamId, startTime }) {
console.log('received stream reset', streamId, startTime)
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
if (this.stream && this.stream.id === streamId) { if (this.stream && this.stream.id === streamId) {
this.$refs.audioPlayer.resetStream(startTime) this.$refs.audioPlayer.resetStream(startTime)
} }
} }
}, },
async getDownloadStartTime() {
var userAudiobook = this.$store.getters['user/getUserAudiobookData'](this.audiobookId)
if (!userAudiobook) {
console.log('[StreamContainer] getDownloadStartTime no user audiobook record found')
return 0
}
return userAudiobook.currentTime
},
async playDownload() {
if (this.stream) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
this.stream = null
}
this.lastProgressTimeUpdate = 0
console.log('[StreamContainer] Playing local', this.playingDownload)
if (!this.$refs.audioPlayer) {
console.error('No Audio Player Mini')
return
}
var playOnLoad = this.$store.state.playOnLoad
if (playOnLoad) this.$store.commit('setPlayOnLoad', false)
var currentTime = await this.getDownloadStartTime()
if (isNaN(currentTime) || currentTime === null) currentTime = 0
this.currentTime = currentTime
// Update local current time
this.$localStore.setCurrent({
audiobookId: this.download.id,
lastUpdate: Date.now()
})
var audiobookStreamData = {
id: 'download',
title: this.title,
author: this.author,
playWhenReady: !!playOnLoad,
startTime: String(Math.floor(currentTime * 1000)),
playbackSpeed: this.playbackSpeed || 1,
cover: this.download.coverUrl || null,
duration: String(Math.floor(this.duration * 1000)),
series: this.seriesTxt,
token: this.userToken,
contentUrl: this.playingDownload.contentUrl,
isLocal: true,
audiobookId: this.download.id
}
this.$refs.audioPlayer.set(audiobookStreamData, null, false)
},
streamOpen(stream) {
if (this.download) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
this.download = null
}
this.lastProgressTimeUpdate = 0
console.log('[StreamContainer] Stream Open: ' + this.title)
if (!this.$refs.audioPlayer) {
console.error('[StreamContainer] No Audio Player Mini')
return
}
// Update local remove current
this.$localStore.setCurrent(null)
var playlistUrl = stream.clientPlaylistUri
var currentTime = stream.clientCurrentTime || 0
this.currentTime = currentTime
var playOnLoad = this.$store.state.playOnLoad
if (playOnLoad) this.$store.commit('setPlayOnLoad', false)
var audiobookStreamData = {
id: stream.id,
title: this.title,
author: this.author,
playWhenReady: !!playOnLoad,
startTime: String(Math.floor(currentTime * 1000)),
playbackSpeed: this.playbackSpeed || 1,
cover: this.coverForNative,
duration: String(Math.floor(this.duration * 1000)),
series: this.seriesTxt,
playlistUrl: this.$server.url + playlistUrl,
token: this.userToken,
audiobookId: this.audiobookId,
tracks: this.tracksForCast
}
console.log('[StreamContainer] Set Audio Player', JSON.stringify(audiobookStreamData))
if (!this.$refs.audioPlayer) {
console.error('[StreamContainer] Invalid no audio player')
} else {
console.log('[StreamContainer] Has Audio Player Ref')
}
this.$refs.audioPlayer.set(audiobookStreamData, stream, !this.stream)
this.stream = stream
},
audioPlayerMounted() {
console.log('Audio Player Mounted', this.$server.stream)
this.audioPlayerReady = true
if (this.playingDownload) {
console.log('[StreamContainer] Play download on audio mount')
if (!this.download) {
this.download = { ...this.playingDownload }
}
this.playDownload()
} else if (this.$server.stream) {
console.log('[StreamContainer] Open stream on audio mount')
this.streamOpen(this.$server.stream)
}
},
updatePlaybackSpeed(speed) { updatePlaybackSpeed(speed) {
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
console.log(`[AudioPlayerContainer] Update Playback Speed: ${speed}`) console.log(`[AudioPlayerContainer] Update Playback Speed: ${speed}`)
@ -463,39 +178,24 @@ export default {
this.$server.socket.on('stream_reset', this.streamReset) this.$server.socket.on('stream_reset', this.streamReset)
}, },
closeStreamOnly() { closeStreamOnly() {
// If user logs out or disconnects from server, close audio if streaming // If user logs out or disconnects from server and not playing local
if (!this.download) { if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isLocalPlayMethod) {
this.$store.commit('setStreamAudiobook', null) this.$refs.audioPlayer.terminateStream()
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
} }
}, },
async playLibraryItem(libraryItemId) { async playLibraryItem(libraryItemId) {
var libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed to fetch full item', error)
return null
})
if (!libraryItem) return
this.$store.commit('globals/setLibraryItemPlaying', libraryItem)
MyNativeAudio.prepareLibraryItem({ libraryItemId, playWhenReady: true }) MyNativeAudio.prepareLibraryItem({ libraryItemId, playWhenReady: true })
.then((data) => { .then((data) => {
console.log('TEST library item play response', JSON.stringify(data)) console.log('TEST library item play response', JSON.stringify(data))
var mediaEntity = data.mediaEntity
this.$store.commit('globals/setMediaEntityPlaying', mediaEntity)
}) })
.catch((error) => { .catch((error) => {
console.error('TEST failed', error) console.error('TEST failed', error)
}) })
}, },
async playLocalItem(localMediaItemId) { async playLocalItem(localMediaItemId) {
console.log('Called play local media item for lmi', localMediaItemId)
MyNativeAudio.playLocalLibraryItem({ localMediaItemId, playWhenReady: true }) MyNativeAudio.playLocalLibraryItem({ localMediaItemId, playWhenReady: true })
.then((data) => { .then((data) => {
console.log('TEST library item play response', JSON.stringify(data)) console.log('TEST library item play response', JSON.stringify(data))
var mediaEntity = data.mediaEntity
this.$store.commit('globals/setMediaEntityPlaying', mediaEntity)
}) })
.catch((error) => { .catch((error) => {
console.error('TEST failed', error) console.error('TEST failed', error)
@ -512,7 +212,7 @@ export default {
this.setListeners() this.setListeners()
this.$eventBus.$on('play-item', this.playLibraryItem) this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('play-local-item', this.playLocalItem) this.$eventBus.$on('play-local-item', this.playLocalItem)
this.$eventBus.$on('close_stream', this.closeStreamOnly) this.$eventBus.$on('close-stream', this.closeStreamOnly)
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated }) this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
}, },
beforeDestroy() { beforeDestroy() {
@ -527,8 +227,8 @@ export default {
this.$server.socket.off('stream_reset', this.streamReset) this.$server.socket.off('stream_reset', this.streamReset)
} }
this.$eventBus.$off('play-item', this.playLibraryItem) this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('play-local-item', this.playLocalItem) this.$eventBus.$off('play-local-item', this.playLocalItem)
this.$eventBus.$off('close_stream', this.closeStreamOnly) this.$eventBus.$off('close-stream', this.closeStreamOnly)
this.$store.commit('user/removeSettingsListener', 'streamContainer') this.$store.commit('user/removeSettingsListener', 'streamContainer')
} }
} }

View file

@ -5,7 +5,7 @@
<div class="absolute cover-bg" ref="coverBg" /> <div class="absolute cover-bg" ref="coverBg" />
</div> </div>
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" /> <img v-if="fullCoverUrl" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> <div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p> <p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">

View file

@ -2,7 +2,7 @@
<div class="relative w-full" v-click-outside="clickOutsideObj"> <div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p> <p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center"> <span class="flex items-center" :class="!selectedText ? 'text-gray-300' : 'text-white'">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span> <span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span>
</span> </span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">

View file

@ -351,7 +351,7 @@ export default {
// }, // },
userLoggedOut() { userLoggedOut() {
// Only cancels stream if streamining not playing downloaded // Only cancels stream if streamining not playing downloaded
this.$eventBus.$emit('close_stream') this.$eventBus.$emit('close-stream')
}, },
initSocketListeners() { initSocketListeners() {
if (this.$server.socket) { if (this.$server.socket) {

View file

@ -102,8 +102,8 @@ export default {
// When all items are up-to-date then local media items are not returned // When all items are up-to-date then local media items are not returned
if (response.localMediaItems.length) { if (response.localMediaItems.length) {
this.localMediaItems = response.localMediaItems.map((mi) => { this.localMediaItems = response.localMediaItems.map((mi) => {
if (mi.coverPath) { if (mi.coverContentUrl) {
mi.coverPathSrc = Capacitor.convertFileSrc(mi.coverPath) mi.coverPathSrc = Capacitor.convertFileSrc(mi.coverContentUrl)
} }
return mi return mi
}) })
@ -123,7 +123,7 @@ export default {
this.localMediaItems = items.map((lmi) => { this.localMediaItems = items.map((lmi) => {
return { return {
...lmi, ...lmi,
coverPathSrc: lmi.coverPath ? Capacitor.convertFileSrc(lmi.coverPath) : null coverPathSrc: lmi.coverContentUrl ? Capacitor.convertFileSrc(lmi.coverContentUrl) : null
} }
}) })
if (this.shouldScan) { if (this.shouldScan) {

View file

@ -15,7 +15,7 @@
<div v-if="!localFolders.length" class="flex justify-center"> <div v-if="!localFolders.length" class="flex justify-center">
<p class="text-center">No Media Folders</p> <p class="text-center">No Media Folders</p>
</div> </div>
<div class="flex p-2 border-t border-primary mt-2"> <div class="flex border-t border-primary my-4">
<div class="flex-grow pr-1"> <div class="flex-grow pr-1">
<ui-dropdown v-model="newFolderMediaType" placeholder="Select media type" :items="mediaTypeItems" /> <ui-dropdown v-model="newFolderMediaType" placeholder="Select media type" :items="mediaTypeItems" />
</div> </div>
@ -56,7 +56,7 @@ export default {
methods: { methods: {
async selectFolder() { async selectFolder() {
if (!this.newFolderMediaType) { if (!this.newFolderMediaType) {
return this.$toast.warn('Must select a media type') return this.$toast.error('Must select a media type')
} }
var folderObj = await StorageManager.selectFolder({ mediaType: this.newFolderMediaType }) var folderObj = await StorageManager.selectFolder({ mediaType: this.newFolderMediaType })
if (folderObj.error) { if (folderObj.error) {

View file

@ -15,10 +15,18 @@ const BookCoverAspectRatio = {
SQUARE: 1 SQUARE: 1
} }
const PlayMethod = {
DIRECTPLAY: 0,
DIRECTSTREAM: 1,
TRANSCODE: 2,
LOCAL: 3
}
const Constants = { const Constants = {
DownloadStatus, DownloadStatus,
CoverDestination, CoverDestination,
BookCoverAspectRatio BookCoverAspectRatio,
PlayMethod
} }
export default ({ app }, inject) => { export default ({ app }, inject) => {

View file

@ -1,6 +1,5 @@
export const state = () => ({ export const state = () => ({
libraryItemPlaying: null,
mediaEntityPlaying: null
}) })
export const getters = { export const getters = {
@ -29,10 +28,5 @@ export const actions = {
} }
export const mutations = { export const mutations = {
setLibraryItemPlaying(state, libraryItem) {
state.libraryItemPlaying = libraryItem
},
setMediaEntityPlaying(state, mediaEntity) {
state.mediaEntityPlaying = mediaEntity
}
} }

View file

@ -24,6 +24,10 @@ export const getters = {
if (!state.user.libraryItemProgress) return null if (!state.user.libraryItemProgress) return null
return state.user.libraryItemProgress.find(li => li.id == libraryItemId) return state.user.libraryItemProgress.find(li => li.id == libraryItemId)
}, },
getUserBookmarksForItem: (state) => (libraryItemId) => {
if (!state.user.bookmarks) return []
return state.user.bookmarks.filter(bm => bm.libraryItemId === libraryItemId)
},
getUserAudiobookData: (state, getters) => (audiobookId) => { getUserAudiobookData: (state, getters) => (audiobookId) => {
return getters.getUserAudiobook(audiobookId) return getters.getUserAudiobook(audiobookId)
}, },