diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt index f7da6233..fa8ba35b 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt @@ -242,4 +242,12 @@ class DbManager { fun getLocalPlaybackSession(playbackSessionId:String):PlaybackSession? { return Paper.book("localPlaybackSession").read(playbackSessionId) } + + + fun saveMediaItemHistory(mediaItemHistory:MediaItemHistory) { + Paper.book("mediaItemHistory").write(mediaItemHistory.id,mediaItemHistory) + } + fun getMediaItemHistory(id:String):MediaItemHistory? { + return Paper.book("mediaItemHistory").read(id) + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemEvent.kt b/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemEvent.kt new file mode 100644 index 00000000..a5be2f95 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemEvent.kt @@ -0,0 +1,15 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class MediaItemEvent( + var name:String, // e.g. Play/Pause/Stop/Seek/Save + var type:String, // Playback/Info + var description:String?, + var currentTime:Number?, // Seconds + var serverSyncAttempted:Boolean?, + var serverSyncSuccess:Boolean?, + var serverSyncMessage:String?, + var timestamp: Long +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemHistory.kt b/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemHistory.kt new file mode 100644 index 00000000..5968cb91 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemHistory.kt @@ -0,0 +1,17 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class MediaItemHistory( + var id: String, // media id + var mediaDisplayTitle: String, + var libraryItemId: String, + var episodeId: String?, + var isLocal:Boolean, + var serverConnectionConfigId:String?, + var serverAddress:String?, + var serverUserId:String?, + var createdAt: Long, + var events:MutableList, +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt index 22e54c04..e559ea58 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt @@ -65,11 +65,13 @@ class PlaybackSession( @get:JsonIgnore val localLibraryItemId get() = localLibraryItem?.id ?: "" @get:JsonIgnore - val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId" + val localMediaProgressId get() = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId" @get:JsonIgnore val progress get() = currentTime / getTotalDuration() @get:JsonIgnore val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null + @get:JsonIgnore + val mediaItemId get() = if (isLocalLibraryItemOnly) localMediaProgressId else if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId" @JsonIgnore fun getCurrentTrackIndex():Int { diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaEventManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaEventManager.kt new file mode 100644 index 00000000..72055cc5 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaEventManager.kt @@ -0,0 +1,87 @@ +package com.audiobookshelf.app.media + +import android.util.Log +import com.audiobookshelf.app.data.MediaItemEvent +import com.audiobookshelf.app.data.MediaItemHistory +import com.audiobookshelf.app.data.PlaybackSession +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.player.PlayerNotificationService +import com.audiobookshelf.app.player.SyncResult + +object MediaEventManager { + const val tag = "MediaEventManager" + + var clientEventEmitter: PlayerNotificationService.ClientEventEmitter? = null + + fun playEvent(playbackSession: PlaybackSession) { + Log.i(tag, "Play Event for media \"${playbackSession.displayTitle}\"") + addPlaybackEvent("Play", playbackSession, null) + } + + fun pauseEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) { + Log.i(tag, "Pause Event for media \"${playbackSession.displayTitle}\"") + addPlaybackEvent("Pause", playbackSession, syncResult) + } + + fun stopEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) { + Log.i(tag, "Stop Event for media \"${playbackSession.displayTitle}\"") + addPlaybackEvent("Stop", playbackSession, syncResult) + } + + fun saveEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) { + Log.i(tag, "Save Event for media \"${playbackSession.displayTitle}\"") + addPlaybackEvent("Save", playbackSession, syncResult) + } + + fun finishedEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) { + Log.i(tag, "Finished Event for media \"${playbackSession.displayTitle}\"") + addPlaybackEvent("Finished", playbackSession, syncResult) + } + + fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) { + Log.i(tag, "Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}") + addPlaybackEvent("Seek", playbackSession, syncResult) + } + + private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult:SyncResult?) { + val mediaItemHistory = getMediaItemHistoryForSession(playbackSession) ?: createMediaItemHistoryForSession(playbackSession) + + val mediaItemEvent = MediaItemEvent( + name = eventName, + type = "Playback", + description = "", + currentTime = playbackSession.currentTime, + serverSyncAttempted = syncResult?.serverSyncAttempted ?: false, + serverSyncSuccess = syncResult?.serverSyncSuccess, + serverSyncMessage = syncResult?.serverSyncMessage, + timestamp = System.currentTimeMillis() + ) + mediaItemHistory.events.add(mediaItemEvent) + DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory) + + clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory) + } + + private fun getMediaItemHistoryForSession(playbackSession: PlaybackSession) : MediaItemHistory? { + return DeviceManager.dbManager.getMediaItemHistory(playbackSession.mediaItemId) + } + + private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession):MediaItemHistory { + Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"") + val isLocalOnly = playbackSession.isLocalLibraryItemOnly + val libraryItemId = if (isLocalOnly) playbackSession.localLibraryItemId else playbackSession.libraryItemId ?: "" + val episodeId:String? = if (isLocalOnly && playbackSession.localEpisodeId != null) playbackSession.localEpisodeId else playbackSession.episodeId + return MediaItemHistory( + id = playbackSession.mediaItemId, + mediaDisplayTitle = playbackSession.displayTitle ?: "Unset", + libraryItemId, + episodeId, + isLocalOnly, + playbackSession. + serverConnectionConfigId, + playbackSession.serverAddress, + playbackSession.userId, + createdAt = System.currentTimeMillis(), + events = mutableListOf()) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaProgressSyncer.kt similarity index 72% rename from android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt rename to android/app/src/main/java/com/audiobookshelf/app/media/MediaProgressSyncer.kt index e766f303..3f7de691 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaProgressSyncer.kt @@ -7,6 +7,7 @@ import com.audiobookshelf.app.data.LocalMediaProgress import com.audiobookshelf.app.data.MediaProgress import com.audiobookshelf.app.data.PlaybackSession import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.media.MediaEventManager import com.audiobookshelf.app.server.ApiHandler import java.util.* import kotlin.concurrent.schedule @@ -17,6 +18,12 @@ data class MediaProgressSyncData( var currentTime:Double // seconds ) +data class SyncResult( + var serverSyncAttempted:Boolean, + var serverSyncSuccess:Boolean?, + var serverSyncMessage:String? +) + class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) { private val tag = "MediaProgressSync" private val METERED_CONNECTION_SYNC_INTERVAL = 60000 @@ -30,15 +37,15 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing var currentLocalMediaProgress: LocalMediaProgress? = null - val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset" - val currentIsLocal get() = currentPlaybackSession?.isLocal == true + private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset" + private val currentIsLocal get() = currentPlaybackSession?.isLocal == true val currentSessionId get() = currentPlaybackSession?.id ?: "" - val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0 + private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0 - fun start() { + fun start(playbackSession:PlaybackSession) { if (listeningTimerRunning) { Log.d(tag, "start: Timer already running for $currentDisplayTitle") - if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) { + if (playbackSession.id != currentSessionId) { Log.d(tag, "Playback session changed, reset timer") currentLocalMediaProgress = null listeningTimerTask?.cancel() @@ -48,25 +55,29 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic } else { return } - } else if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) { + } else if (playbackSession.id != currentSessionId) { currentLocalMediaProgress = null } listeningTimerRunning = true lastSyncTime = System.currentTimeMillis() Log.d(tag, "start: init last sync time $lastSyncTime") - currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy() + currentPlaybackSession = playbackSession.clone() - listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) { + listeningTimerTask = Timer("ListeningTimer", false).schedule(15000L, 15000L) { Handler(Looper.getMainLooper()).post() { if (playerNotificationService.currentPlayer.isPlaying) { - // Only sync with server on unmetered connection every 5s OR sync with server if last sync time is >= 60s + // Only sync with server on unmetered connection every 15s OR sync with server if last sync time is >= 60s val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL val currentTime = playerNotificationService.getCurrentTimeSeconds() if (currentTime > 0) { - sync(shouldSyncServer, currentTime) { + sync(shouldSyncServer, currentTime) { syncResult -> Log.d(tag, "Sync complete") + + currentPlaybackSession?.let { playbackSession -> + MediaEventManager.saveEvent(playbackSession, syncResult) + } } } } @@ -74,8 +85,16 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic } } + fun play(playbackSession:PlaybackSession) { + Log.d(tag, "play ${playbackSession.displayTitle}") + MediaEventManager.playEvent(playbackSession) + + start(playbackSession) + } + fun stop(shouldSync:Boolean? = true, cb: () -> Unit) { if (!listeningTimerRunning) return + listeningTimerTask?.cancel() listeningTimerTask = null listeningTimerRunning = false @@ -83,11 +102,19 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0 if (currentTime > 0) { // Current time should always be > 0 on stop - sync(true, currentTime) { + sync(true, currentTime) { syncResult -> + currentPlaybackSession?.let { playbackSession -> + MediaEventManager.stopEvent(playbackSession, syncResult) + } + reset() cb() } } else { + currentPlaybackSession?.let { playbackSession -> + MediaEventManager.stopEvent(playbackSession, null) + } + reset() cb() } @@ -95,6 +122,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic fun pause(cb: () -> Unit) { if (!listeningTimerRunning) return + listeningTimerTask?.cancel() listeningTimerTask = null listeningTimerRunning = false @@ -103,33 +131,61 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic val currentTime = playerNotificationService.getCurrentTimeSeconds() if (currentTime > 0) { // Current time should always be > 0 on pause - sync(true, currentTime) { + sync(true, currentTime) { syncResult -> lastSyncTime = 0L Log.d(tag, "pause: Set last sync time 0 $lastSyncTime") failedSyncs = 0 + + currentPlaybackSession?.let { playbackSession -> + MediaEventManager.pauseEvent(playbackSession, syncResult) + } + cb() } } else { lastSyncTime = 0L Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)") failedSyncs = 0 + + currentPlaybackSession?.let { playbackSession -> + MediaEventManager.pauseEvent(playbackSession, null) + } + cb() } } fun finished(cb: () -> Unit) { if (!listeningTimerRunning) return + listeningTimerTask?.cancel() listeningTimerTask = null listeningTimerRunning = false - Log.d(tag, "stop: Stopping listening for $currentDisplayTitle") + Log.d(tag, "finished: Stopping listening for $currentDisplayTitle") - sync(true, currentPlaybackSession?.duration ?: 0.0) { + sync(true, currentPlaybackSession?.duration ?: 0.0) { syncResult -> reset() + + currentPlaybackSession?.let { playbackSession -> + MediaEventManager.finishedEvent(playbackSession, syncResult) + } + cb() } } + fun seek() { + currentPlaybackSession?.currentTime = playerNotificationService.getCurrentTimeSeconds() + Log.d(tag, "seek: $currentDisplayTitle, currentTime=${currentPlaybackSession?.currentTime}") + + if (currentPlaybackSession == null) { + Log.e(tag, "seek: Playback session not set") + return + } + + MediaEventManager.seekEvent(currentPlaybackSession!!, null) + } + fun syncFromServerProgress(mediaProgress: MediaProgress) { currentPlaybackSession?.let { it.updatedAt = mediaProgress.lastUpdate @@ -139,15 +195,15 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic } } - fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: () -> Unit) { + fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: (SyncResult?) -> Unit) { if (lastSyncTime <= 0) { Log.e(tag, "Last sync time is not set $lastSyncTime") - return + return cb(null) } val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime if (diffSinceLastSync < 1000L) { - return cb() + return cb(null) } val listeningTimeToAdd = diffSinceLastSync / 1000L @@ -157,7 +213,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic if (currentPlaybackSession?.progress?.isNaN() == true) { Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}") - return cb() + return cb(null) } if (currentIsLocal) { @@ -170,11 +226,12 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic // Local library item is linked to a server library item // Send sync to server also if connected to this server and local item belongs to this server if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) { - apiHandler.sendLocalProgressSync(it) { syncSuccess -> + apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg -> Log.d( tag, "Local progress sync data sent to server $currentDisplayTitle for time $currentTime" ) + if (syncSuccess) { failedSyncs = 0 playerNotificationService.alertSyncSuccess() @@ -187,15 +244,15 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime") } - cb() + cb(SyncResult(true, syncSuccess, errorMsg)) } } else { - cb() + cb(SyncResult(false, null, null)) } } } else if (shouldSyncServer) { - apiHandler.sendProgressSync(currentSessionId, syncData) { - if (it) { + apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg -> + if (syncSuccess) { Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime") failedSyncs = 0 playerNotificationService.alertSyncSuccess() @@ -208,10 +265,10 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic } Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime") } - cb() + cb(SyncResult(true, syncSuccess, errorMsg)) } } else { - cb() + cb(SyncResult(false, null, null)) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt index fdb55812..a44a7ffb 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt @@ -1,8 +1,10 @@ package com.audiobookshelf.app.player import android.util.Log +import com.audiobookshelf.app.data.PlaybackSession import com.audiobookshelf.app.data.PlayerState import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.media.MediaEventManager import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.Player @@ -16,6 +18,7 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) : } private var onSeekBack: Boolean = false + private var isSeeking: Boolean = false override fun onPlayerError(error: PlaybackException) { val errorMessage = error.message ?: "Unknown Error" @@ -23,6 +26,92 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) : playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode } + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + // If playing set seeking flag + isSeeking = playerNotificationService.currentPlayer.isPlaying + + Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying} reason=SEEK") + playerNotificationService.mediaProgressSyncer.seek() + } else { + Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying}, reason=$reason") + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + Log.d(tag, "onIsPlayingChanged to $isPlaying | ${playerNotificationService.getMediaPlayer()}") + + if (isSeeking) { + if (isPlaying) { + isSeeking = false + Log.d(tag, "onIsPlayingChanged isSeeking seek complete") + } else { + Log.d(tag, "onIsPlayingChanged isSeeking skipping pause") + } + return + } + + val player = playerNotificationService.currentPlayer + + if (player.isPlaying) { + Log.d(tag, "SeekBackTime: Player is playing") + if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) { + var seekBackTime = 0L + if (onSeekBack) onSeekBack = false + else { + Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime") + seekBackTime = calcPauseSeekBackTime() + if (seekBackTime > 0) { + // Current chapter is used so that seek back does not go back to the previous chapter + val currentChapter = playerNotificationService.getCurrentBookChapter() + val minSeekBackTime = currentChapter?.startMs ?: 0 + + val currentTime = playerNotificationService.getCurrentTime() + val newTime = currentTime - seekBackTime + if (newTime < minSeekBackTime) { + seekBackTime = currentTime - minSeekBackTime + } + Log.d(tag, "SeekBackTime $seekBackTime") + onSeekBack = true + } + } + + // Check if playback session still exists or sync media progress if updated + val pauseLength: Long = System.currentTimeMillis() - lastPauseTime + if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) { + val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime) + if (!shouldCarryOn) return + } + + if (seekBackTime > 0L) { + playerNotificationService.seekBackward(seekBackTime) + } + } + } else { + Log.d(tag, "SeekBackTime: Player not playing set last pause time") + lastPauseTime = System.currentTimeMillis() + } + + // Start/stop progress sync interval + if (player.isPlaying) { + player.volume = 1F // Volume on sleep timer might have decreased this + val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession + playbackSession?.let { playerNotificationService.mediaProgressSyncer.play(it) } + } else { + playerNotificationService.mediaProgressSyncer.pause { + Log.d(tag, "Media Progress Syncer paused and synced") + } + } + + playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying) + + DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService) + } + override fun onEvents(player: Player, events: Player.Events) { Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}") @@ -67,62 +156,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) : if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) { Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}") } - if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) { - Log.d(tag, "EVENT IS PLAYING CHANGED ${playerNotificationService.getMediaPlayer()}") - - if (player.isPlaying) { - Log.d(tag, "SeekBackTime: Player is playing") - if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) { - var seekBackTime = 0L - if (onSeekBack) onSeekBack = false - else { - Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime") - seekBackTime = calcPauseSeekBackTime() - if (seekBackTime > 0) { - // Current chapter is used so that seek back does not go back to the previous chapter - val currentChapter = playerNotificationService.getCurrentBookChapter() - val minSeekBackTime = currentChapter?.startMs ?: 0 - - val currentTime = playerNotificationService.getCurrentTime() - val newTime = currentTime - seekBackTime - if (newTime < minSeekBackTime) { - seekBackTime = currentTime - minSeekBackTime - } - Log.d(tag, "SeekBackTime $seekBackTime") - onSeekBack = true - } - } - - // Check if playback session still exists or sync media progress if updated - val pauseLength: Long = System.currentTimeMillis() - lastPauseTime - if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) { - val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime) - if (!shouldCarryOn) return - } - - if (seekBackTime > 0L) { - playerNotificationService.seekBackward(seekBackTime) - } - } - } else { - Log.d(tag, "SeekBackTime: Player not playing set last pause time") - lastPauseTime = System.currentTimeMillis() - } - - // Start/stop progress sync interval - if (player.isPlaying) { - player.volume = 1F // Volume on sleep timer might have decreased this - playerNotificationService.mediaProgressSyncer.start() - } else { - playerNotificationService.mediaProgressSyncer.pause { - Log.d(tag, "Media Progress Syncer paused and synced") - } - } - - playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying) - - DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService) - } } private fun calcPauseSeekBackTime() : Long { diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 4872e982..e4e3ba13 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -74,6 +74,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { fun onProgressSyncFailing() fun onProgressSyncSuccess() fun onNetworkMeteredChanged(isUnmetered:Boolean) + fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) } private val tag = "PlayerService" @@ -101,7 +102,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var channelId = "audiobookshelf_channel" private var channelName = "Audiobookshelf Channel" - private var currentPlaybackSession:PlaybackSession? = null + var currentPlaybackSession:PlaybackSession? = null private var initialPlaybackRate:Float? = null private var isAndroidAuto = false @@ -682,7 +683,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { seekPlayer(playbackSession.currentTimeMs) // Should already be playing currentPlayer.volume = 1F // Volume on sleep timer might have decreased this - mediaProgressSyncer.start() + currentPlaybackSession?.let { mediaProgressSyncer.play(it) } clientEventEmitter?.onPlayingUpdate(true) } } else { @@ -690,9 +691,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { if (seekBackTime > 0L) { seekBackward(seekBackTime) } + // Should already be playing currentPlayer.volume = 1F // Volume on sleep timer might have decreased this - mediaProgressSyncer.start() + currentPlaybackSession?.let { mediaProgressSyncer.play(it) } clientEventEmitter?.onPlayingUpdate(true) } } @@ -722,7 +724,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } currentPlayer.volume = 1F // Volume on sleep timer might have decreased this - mediaProgressSyncer.start() + mediaProgressSyncer.play(it) clientEventEmitter?.onPlayingUpdate(true) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt index 8d9a62c0..c6e18e45 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt @@ -6,7 +6,9 @@ import android.util.Log import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.media.MediaEventManager import com.audiobookshelf.app.player.CastManager +import com.audiobookshelf.app.player.MediaProgressSyncer import com.audiobookshelf.app.player.PlayerNotificationService import com.audiobookshelf.app.server.ApiHandler import com.fasterxml.jackson.core.json.JsonReadFeature @@ -88,7 +90,13 @@ class AbsAudioPlayer : Plugin() { override fun onNetworkMeteredChanged(isUnmetered:Boolean) { emit("onNetworkMeteredChanged", isUnmetered) } + + override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) { + notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory))) + } }) + + MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter } mainActivity.pluginCallback = foregroundServiceReady } @@ -267,6 +275,7 @@ class AbsAudioPlayer : Plugin() { @PluginMethod fun seek(call: PluginCall) { val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds + Log.d(tag, "seek action to $time") Handler(Looper.getMainLooper()).post { playerNotificationService.seekPlayer(time * 1000L) // convert to ms call.resolve() diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt index e5585594..824362b7 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -406,4 +406,19 @@ class AbsDatabase : Plugin() { call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData))) } } + + @PluginMethod + fun getMediaItemHistory(call:PluginCall) { // Returns device data + Log.d(tag, "getMediaItemHistory ${call.data}") + val mediaId = call.getString("mediaId") ?: "" + + GlobalScope.launch(Dispatchers.IO) { + val mediaItemHistory = DeviceManager.dbManager.getMediaItemHistory(mediaId) + if (mediaItemHistory == null) { + call.resolve() + } else { + call.resolve(JSObject(jacksonMapper.writeValueAsString(mediaItemHistory))) + } + } + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 6e76587e..49025a94 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -226,26 +226,26 @@ class ApiHandler(var ctx:Context) { } } - fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> Unit) { + fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean, String?) -> Unit) { val payload = JSObject(jacksonMapper.writeValueAsString(syncData)) postRequest("/api/session/$sessionId/sync", payload, null) { if (!it.getString("error").isNullOrEmpty()) { - cb(false) + cb(false, it.getString("error")) } else { - cb(true) + cb(true, null) } } } - fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean) -> Unit) { + fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) { val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession)) postRequest("/api/session/local", payload, null) { if (!it.getString("error").isNullOrEmpty()) { - cb(false) + cb(false, it.getString("error")) } else { - cb(true) + cb(true, null) } } } diff --git a/assets/fonts.css b/assets/fonts.css index 2831805c..8241ff6b 100644 --- a/assets/fonts.css +++ b/assets/fonts.css @@ -2,14 +2,14 @@ font-family: 'Material Icons'; font-style: normal; font-weight: 400; - src: url(/fonts/MaterialIcons.woff2) format('woff2'); + src: url(/fonts/MaterialIcons-Regular.ttf) format('truetype'); } @font-face { font-family: 'Material Icons Outlined'; font-style: normal; font-weight: 400; - src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2'); + src: url(/fonts/MaterialIconsOutlined-Regular.otf) format('opentype'); } .material-icons { diff --git a/pages/item/_id.vue b/pages/item/_id/index.vue similarity index 98% rename from pages/item/_id.vue rename to pages/item/_id/index.vue index 6713e984..a5973a1f 100644 --- a/pages/item/_id.vue +++ b/pages/item/_id/index.vue @@ -367,6 +367,14 @@ export default { const items = [] if (!this.isPodcast) { + // TODO: Implement on iOS + if (!this.isIos) { + items.push({ + text: 'History', + value: 'history' + }) + } + items.push({ text: this.userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished', value: 'markFinished' @@ -401,6 +409,10 @@ export default { if (width * this.bookCoverAspectRatio > 325) width = 325 / this.bookCoverAspectRatio return width + }, + mediaId() { + if (this.isPodcast) return null + return this.serverLibraryItemId || this.localLibraryItemId } }, methods: { @@ -430,6 +442,8 @@ export default { } else if (action === 'markFinished') { if (this.isProcessingReadUpdate) return this.toggleFinished() + } else if (action === 'history') { + this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`) } }, moreButtonPress() { diff --git a/pages/media/_id/history.vue b/pages/media/_id/history.vue new file mode 100644 index 00000000..67b0d70e --- /dev/null +++ b/pages/media/_id/history.vue @@ -0,0 +1,153 @@ + + + \ No newline at end of file diff --git a/plugins/capacitor/AbsDatabase.js b/plugins/capacitor/AbsDatabase.js index 9cc52644..28683126 100644 --- a/plugins/capacitor/AbsDatabase.js +++ b/plugins/capacitor/AbsDatabase.js @@ -217,6 +217,132 @@ class AbsDatabaseWeb extends WebPlugin { localStorage.setItem('device', JSON.stringify(deviceData)) return deviceData } + + async getMediaItemHistory({ mediaId }) { + console.log('Get media item history', mediaId) + return { + id: mediaId, + mediaDisplayTitle: 'Test Book', + libraryItemId: mediaId, + episodeId: null, + isLocal: false, + serverConnectionConfigId: null, + serverAddress: null, + createdAt: Date.now(), + events: [ + { + name: 'Pause', + type: 'Playback', + description: null, + currentTime: 81, + serverSyncAttempted: true, + serverSyncSuccess: true, + serverSyncMessage: null, + timestamp: Date.now() - (1000 * 60 * 22) + 13000 // 22 mins ago + 13s + }, + { + name: 'Play', + type: 'Playback', + description: null, + currentTime: 68, + serverSyncAttempted: false, + serverSyncSuccess: null, + serverSyncMessage: null, + timestamp: Date.now() - (1000 * 60 * 22) // 22 mins ago + }, + { + name: 'Pause', + type: 'Playback', + description: null, + currentTime: 68, + serverSyncAttempted: true, + serverSyncSuccess: false, + serverSyncMessage: null, + timestamp: Date.now() - (1000 * 60 * 60) + (58000) // 1 hour ago + 58s + }, + { + name: 'Save', + type: 'Playback', + description: null, + currentTime: 55, + serverSyncAttempted: true, + serverSyncSuccess: true, + serverSyncMessage: null, + timestamp: Date.now() - (1000 * 60 * 60) + (45000) // 1 hour ago + 45s + }, + { + name: 'Save', + type: 'Playback', + description: null, + currentTime: 40, + serverSyncAttempted: true, + serverSyncSuccess: true, + serverSyncMessage: null, + timestamp: Date.now() - (1000 * 60 * 60) + (30000) // 1 hour ago + 30s + }, + { + name: 'Save', + type: 'Playback', + description: null, + currentTime: 25, + serverSyncAttempted: true, + serverSyncSuccess: true, + serverSyncMessage: null, + timestamp: Date.now() - (1000 * 60 * 60) + (15000) // 1 hour ago + 15s + }, + { + name: 'Play', + type: 'Playback', + description: null, + currentTime: 10, + serverSyncAttempted: false, + serverSyncSuccess: null, + serverSyncMessage: null, + timestamp: Date.now() - (1000 * 60 * 60) // 1 hour ago + }, + { + name: 'Stop', + type: 'Playback', + description: null, + currentTime: 10, + serverSyncAttempted: true, + serverSyncSuccess: true, + serverSyncMessage: null, + timestamp: Date.now() - (1000 * 60 * 60 * 25) + 10000 // 25 hours ago + 10s + }, + { + name: 'Seek', + type: 'Playback', + description: null, + currentTime: 6, + serverSyncAttempted: true, + serverSyncSuccess: true, + serverSyncMessage: null, + timestamp: Date.now() - (1000 * 60 * 60 * 25) + 2000 // 25 hours ago + 2s + }, + { + name: 'Play', + type: 'Playback', + description: null, + currentTime: 0, + serverSyncAttempted: false, + serverSyncSuccess: null, + serverSyncMessage: null, + timestamp: Date.now() - (1000 * 60 * 60 * 25) // 25 hours ago + }, + { + name: 'Play', + type: 'Playback', + description: null, + currentTime: 0, + serverSyncAttempted: false, + serverSyncSuccess: null, + serverSyncMessage: null, + timestamp: Date.now() - (1000 * 60 * 60 * 50) // 50 hours ago + } + ] + } + } } const AbsDatabase = registerPlugin('AbsDatabase', { diff --git a/plugins/db.js b/plugins/db.js index 1b31332e..3197d29e 100644 --- a/plugins/db.js +++ b/plugins/db.js @@ -86,6 +86,10 @@ class DbService { updateDeviceSettings(payload) { return AbsDatabase.updateDeviceSettings(payload) } + + getMediaItemHistory(mediaId) { + return AbsDatabase.getMediaItemHistory({ mediaId }) + } } export default ({ app, store }, inject) => { diff --git a/plugins/init.client.js b/plugins/init.client.js index 5fd70ea0..d2e39118 100644 --- a/plugins/init.client.js +++ b/plugins/init.client.js @@ -91,10 +91,10 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => { } Vue.prototype.$secondsToTimestamp = (seconds) => { - var _seconds = seconds - var _minutes = Math.floor(seconds / 60) + let _seconds = seconds + let _minutes = Math.floor(seconds / 60) _seconds -= _minutes * 60 - var _hours = Math.floor(_minutes / 60) + let _hours = Math.floor(_minutes / 60) _minutes -= _hours * 60 _seconds = Math.floor(_seconds) if (!_hours) { @@ -103,6 +103,16 @@ Vue.prototype.$secondsToTimestamp = (seconds) => { return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}` } +Vue.prototype.$secondsToTimestampFull = (seconds) => { + let _seconds = seconds + let _minutes = Math.floor(seconds / 60) + _seconds -= _minutes * 60 + let _hours = Math.floor(_minutes / 60) + _minutes -= _hours * 60 + _seconds = Math.floor(_seconds) + return `${_hours.toString().padStart(2, '0')}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}` +} + Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => { if (typeof input !== 'string') { return false diff --git a/static/fonts/MaterialIcons-Regular.ttf b/static/fonts/MaterialIcons-Regular.ttf new file mode 100644 index 00000000..9d09b0fe Binary files /dev/null and b/static/fonts/MaterialIcons-Regular.ttf differ diff --git a/static/fonts/MaterialIcons.woff2 b/static/fonts/MaterialIcons.woff2 deleted file mode 100644 index 28fc9e48..00000000 Binary files a/static/fonts/MaterialIcons.woff2 and /dev/null differ diff --git a/static/fonts/MaterialIconsOutlined-Regular.otf b/static/fonts/MaterialIconsOutlined-Regular.otf new file mode 100644 index 00000000..9dad12bc Binary files /dev/null and b/static/fonts/MaterialIconsOutlined-Regular.otf differ diff --git a/static/fonts/MaterialIconsOutlined.woff2 b/static/fonts/MaterialIconsOutlined.woff2 deleted file mode 100644 index aa2ef9c1..00000000 Binary files a/static/fonts/MaterialIconsOutlined.woff2 and /dev/null differ diff --git a/tailwind.config.js b/tailwind.config.js index 3c161585..2e7e9a6d 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -5,7 +5,8 @@ module.exports = { options: { safelist: [ 'bg-success', - 'bg-info' + 'bg-info', + 'text-info' ] } },