MediaItemHistory and history page

This commit is contained in:
advplyr 2023-01-14 18:01:12 -06:00
parent b1805875b9
commit 297eca6a86
22 changed files with 651 additions and 98 deletions

View file

@ -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)
}
}

View file

@ -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
)

View file

@ -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<MediaItemEvent>,
)

View file

@ -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 {

View file

@ -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())
}
}

View file

@ -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))
}
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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)))
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -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 {

View file

@ -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() {

153
pages/media/_id/history.vue Normal file
View file

@ -0,0 +1,153 @@
<template>
<div class="w-full h-full px-3 py-4 overflow-y-auto relative bg-bg">
<p class="mb-4 text-lg font-semibold">History for {{ displayTitle }}</p>
<div v-if="!mediaEvents.length" class="text-center py-8">
<p class="text-gray-200">No History</p>
</div>
<div v-for="(events, name) in groupedMediaEvents" :key="name" class="py-2">
<p class="my-2 text-gray-400 font-semibold">{{ name }}</p>
<div v-for="(evt, index) in events" :key="index" class="py-3 flex items-center">
<p class="text-sm text-gray-400 w-12">{{ $formatDate(evt.timestamp, 'HH:mm') }}</p>
<span class="material-icons px-2" :class="`text-${getEventColor(evt.name)}`">{{ getEventIcon(evt.name) }}</span>
<p class="text-sm text-white">{{ evt.name }}</p>
<span v-if="evt.serverSyncAttempted && evt.serverSyncSuccess" class="material-icons-outlined px-2 text-base text-success">cloud_done</span>
<span v-if="evt.serverSyncAttempted && !evt.serverSyncSuccess" class="material-icons px-2 text-base text-error">error_outline</span>
<p v-if="evt.num" class="text-sm text-gray-400 italic">+{{ evt.num }}</p>
<div class="flex-grow" />
<p class="text-base text-white">{{ $secondsToTimestampFull(evt.currentTime) }}</p>
</div>
</div>
</div>
</template>
<script>
import { AbsAudioPlayer } from '@/plugins/capacitor'
export default {
async asyncData({ params, store, redirect, app, query }) {
const mediaItemHistory = await app.$db.getMediaItemHistory(params.id)
if (!mediaItemHistory) {
return redirect('/?error=Media Item Not Found')
}
return {
title: query.title || 'Unknown',
mediaItemHistory
}
},
data() {
return {
onMediaItemHistoryUpdatedListener: null
}
},
computed: {
displayTitle() {
if (!this.mediaItemHistory) return this.title
return this.mediaItemHistory.mediaDisplayTitle
},
mediaEvents() {
if (!this.mediaItemHistory) return []
return (this.mediaItemHistory.events || []).sort((a, b) => b.timestamp - a.timestamp)
},
groupedMediaEvents() {
const groups = {}
const today = this.$formatDate(new Date(), 'MMM dd, yyyy')
const yesterday = this.$formatDate(Date.now() - 1000 * 60 * 60 * 24, 'MMM dd, yyyy')
let lastKey = null
let numSaves = 0
let index = 0
this.mediaEvents.forEach((evt) => {
const date = this.$formatDate(evt.timestamp, 'MMM dd, yyyy')
let include = true
let key = date
if (date === today) key = 'Today'
else if (date === yesterday) key = 'Yesterday'
if (!groups[key]) groups[key] = []
if (!lastKey) lastKey = key
// Collapse saves
if (evt.name === 'Save') {
if (numSaves > 0 && lastKey === key) {
include = false
groups[key][index - 1].num = numSaves
numSaves++
} else {
lastKey = key
numSaves = 1
}
} else {
numSaves = 0
}
if (include) {
groups[key].push(evt)
index++
}
})
return groups
}
},
methods: {
getEventIcon(name) {
switch (name) {
case 'Play':
return 'play_circle_filled'
case 'Pause':
return 'pause_circle_filled'
case 'Stop':
return 'stop_circle'
case 'Save':
return 'sync'
case 'Seek':
return 'commit'
default:
return 'info'
}
},
getEventColor(name) {
switch (name) {
case 'Play':
return 'success'
case 'Pause':
return 'gray-300'
case 'Stop':
return 'error'
case 'Save':
return 'info'
case 'Seek':
return 'gray-200'
default:
return 'info'
}
},
onMediaItemHistoryUpdated(mediaItemHistory) {
if (!mediaItemHistory || !mediaItemHistory.id) {
console.error('Invalid media item history', mediaItemHistory)
return
}
console.log('Media Item History updated')
this.mediaItemHistory = mediaItemHistory
}
},
async mounted() {
this.onMediaItemHistoryUpdatedListener = await AbsAudioPlayer.addListener('onMediaItemHistoryUpdated', this.onMediaItemHistoryUpdated)
},
beforeDestroy() {
if (this.onMediaItemHistoryUpdatedListener) this.onMediaItemHistoryUpdatedListener.remove()
}
}
</script>

View file

@ -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', {

View file

@ -86,6 +86,10 @@ class DbService {
updateDeviceSettings(payload) {
return AbsDatabase.updateDeviceSettings(payload)
}
getMediaItemHistory(mediaId) {
return AbsDatabase.getMediaItemHistory({ mediaId })
}
}
export default ({ app, store }, inject) => {

View file

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -5,7 +5,8 @@ module.exports = {
options: {
safelist: [
'bg-success',
'bg-info'
'bg-info',
'text-info'
]
}
},