mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2025-08-30 22:59:35 +02:00
Android add media progress sync for server sessions
This commit is contained in:
parent
9ad351f0d7
commit
526fca98b9
10 changed files with 290 additions and 317 deletions
|
@ -19,130 +19,6 @@ data class DeviceData(
|
||||||
var localLibraryItemIdPlaying:String?
|
var localLibraryItemIdPlaying:String?
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LocalLibraryItem(
|
|
||||||
var id:String,
|
|
||||||
var serverAddress:String?,
|
|
||||||
var libraryItemId:String?,
|
|
||||||
var folderId:String,
|
|
||||||
var basePath:String,
|
|
||||||
var absolutePath:String,
|
|
||||||
var contentUrl:String,
|
|
||||||
var isInvalid:Boolean,
|
|
||||||
var mediaType:String,
|
|
||||||
var media:MediaType,
|
|
||||||
var localFiles:MutableList<LocalFile>,
|
|
||||||
var coverContentUrl:String?,
|
|
||||||
var coverAbsolutePath:String?,
|
|
||||||
var isLocal:Boolean
|
|
||||||
) {
|
|
||||||
@JsonIgnore
|
|
||||||
fun getDuration():Double {
|
|
||||||
var total = 0.0
|
|
||||||
var audioTracks = media.getAudioTracks()
|
|
||||||
audioTracks.forEach{ total += it.duration }
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun updateFromScan(audioTracks:MutableList<AudioTrack>, _localFiles:MutableList<LocalFile>) {
|
|
||||||
media.setAudioTracks(audioTracks)
|
|
||||||
localFiles = _localFiles
|
|
||||||
|
|
||||||
if (coverContentUrl != null) {
|
|
||||||
if (localFiles.find { it.contentUrl == coverContentUrl } == null) {
|
|
||||||
// Cover was removed
|
|
||||||
coverContentUrl = null
|
|
||||||
coverAbsolutePath = null
|
|
||||||
media.coverPath = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getPlaybackSession():PlaybackSession {
|
|
||||||
var sessionId = "play-${UUID.randomUUID()}"
|
|
||||||
|
|
||||||
var mediaMetadata = media.metadata
|
|
||||||
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
|
||||||
var authorName = "Unknown"
|
|
||||||
if (mediaType == "book") {
|
|
||||||
var bookMetadata = mediaMetadata as BookMetadata
|
|
||||||
authorName = bookMetadata?.authorName ?: "Unknown"
|
|
||||||
}
|
|
||||||
return PlaybackSession(sessionId,null,null,null, mediaType, mediaMetadata, chapters, mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL, media.getAudioTracks() as MutableList<AudioTrack>,0.0,null,this,null,null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun removeLocalFile(localFileId:String) {
|
|
||||||
localFiles.removeIf { it.id == localFileId }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LocalMediaItem(
|
|
||||||
var id:String,
|
|
||||||
var serverAddress:String?,
|
|
||||||
var name: String,
|
|
||||||
var mediaType:String,
|
|
||||||
var folderId:String,
|
|
||||||
var contentUrl:String,
|
|
||||||
var simplePath: String,
|
|
||||||
var basePath:String,
|
|
||||||
var absolutePath:String,
|
|
||||||
var audioTracks:MutableList<AudioTrack>,
|
|
||||||
var localFiles:MutableList<LocalFile>,
|
|
||||||
var coverContentUrl:String?,
|
|
||||||
var coverAbsolutePath:String?
|
|
||||||
) {
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getDuration():Double {
|
|
||||||
var total = 0.0
|
|
||||||
audioTracks.forEach{ total += it.duration }
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getTotalSize():Long {
|
|
||||||
var total = 0L
|
|
||||||
localFiles.forEach { total += it.size }
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getMediaMetadata():MediaTypeMetadata {
|
|
||||||
return if (mediaType == "book") {
|
|
||||||
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
|
|
||||||
} else {
|
|
||||||
PodcastMetadata(name,null,null, mutableListOf())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getAudiobookChapters():List<BookChapter> {
|
|
||||||
if (mediaType != "book" || audioTracks.isEmpty()) return mutableListOf()
|
|
||||||
if (audioTracks.size == 1) { // Single track audiobook look for chapters from ffprobe
|
|
||||||
return audioTracks[0].audioProbeResult?.getBookChapters() ?: mutableListOf()
|
|
||||||
}
|
|
||||||
// Multi-track make chapters from tracks
|
|
||||||
return audioTracks.map { it.getBookChapter() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getLocalLibraryItem():LocalLibraryItem {
|
|
||||||
var mediaMetadata = getMediaMetadata()
|
|
||||||
if (mediaType == "book") {
|
|
||||||
var chapters = getAudiobookChapters()
|
|
||||||
var book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration())
|
|
||||||
return LocalLibraryItem(id,serverAddress, null, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true)
|
|
||||||
} else {
|
|
||||||
var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false)
|
|
||||||
return LocalLibraryItem(id,serverAddress, null, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalFile(
|
data class LocalFile(
|
||||||
var id:String,
|
var id:String,
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LocalLibraryItem(
|
||||||
|
var id:String,
|
||||||
|
var serverAddress:String?,
|
||||||
|
var libraryItemId:String?,
|
||||||
|
var folderId:String,
|
||||||
|
var basePath:String,
|
||||||
|
var absolutePath:String,
|
||||||
|
var contentUrl:String,
|
||||||
|
var isInvalid:Boolean,
|
||||||
|
var mediaType:String,
|
||||||
|
var media:MediaType,
|
||||||
|
var localFiles:MutableList<LocalFile>,
|
||||||
|
var coverContentUrl:String?,
|
||||||
|
var coverAbsolutePath:String?,
|
||||||
|
var isLocal:Boolean
|
||||||
|
) {
|
||||||
|
@JsonIgnore
|
||||||
|
fun getDuration():Double {
|
||||||
|
var total = 0.0
|
||||||
|
var audioTracks = media.getAudioTracks()
|
||||||
|
audioTracks.forEach{ total += it.duration }
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun updateFromScan(audioTracks:MutableList<AudioTrack>, _localFiles:MutableList<LocalFile>) {
|
||||||
|
media.setAudioTracks(audioTracks)
|
||||||
|
localFiles = _localFiles
|
||||||
|
|
||||||
|
if (coverContentUrl != null) {
|
||||||
|
if (localFiles.find { it.contentUrl == coverContentUrl } == null) {
|
||||||
|
// Cover was removed
|
||||||
|
coverContentUrl = null
|
||||||
|
coverAbsolutePath = null
|
||||||
|
media.coverPath = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getPlaybackSession():PlaybackSession {
|
||||||
|
var sessionId = "play-${UUID.randomUUID()}"
|
||||||
|
|
||||||
|
var mediaMetadata = media.metadata
|
||||||
|
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
||||||
|
var authorName = "Unknown"
|
||||||
|
if (mediaType == "book") {
|
||||||
|
var bookMetadata = mediaMetadata as BookMetadata
|
||||||
|
authorName = bookMetadata?.authorName ?: "Unknown"
|
||||||
|
}
|
||||||
|
return PlaybackSession(sessionId,null,null,null, mediaType, mediaMetadata, chapters, mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL, media.getAudioTracks() as MutableList<AudioTrack>,0.0,null,this,null,null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun removeLocalFile(localFileId:String) {
|
||||||
|
localFiles.removeIf { it.id == localFileId }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
/*
|
||||||
|
Used as a helper class to generate LocalLibraryItem from scan results
|
||||||
|
*/
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LocalMediaItem(
|
||||||
|
var id:String,
|
||||||
|
var serverAddress:String?,
|
||||||
|
var name: String,
|
||||||
|
var mediaType:String,
|
||||||
|
var folderId:String,
|
||||||
|
var contentUrl:String,
|
||||||
|
var simplePath: String,
|
||||||
|
var basePath:String,
|
||||||
|
var absolutePath:String,
|
||||||
|
var audioTracks:MutableList<AudioTrack>,
|
||||||
|
var localFiles:MutableList<LocalFile>,
|
||||||
|
var coverContentUrl:String?,
|
||||||
|
var coverAbsolutePath:String?
|
||||||
|
) {
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getDuration():Double {
|
||||||
|
var total = 0.0
|
||||||
|
audioTracks.forEach{ total += it.duration }
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getTotalSize():Long {
|
||||||
|
var total = 0L
|
||||||
|
localFiles.forEach { total += it.size }
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaMetadata():MediaTypeMetadata {
|
||||||
|
return if (mediaType == "book") {
|
||||||
|
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
|
||||||
|
} else {
|
||||||
|
PodcastMetadata(name,null,null, mutableListOf())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getAudiobookChapters():List<BookChapter> {
|
||||||
|
if (mediaType != "book" || audioTracks.isEmpty()) return mutableListOf()
|
||||||
|
if (audioTracks.size == 1) { // Single track audiobook look for chapters from ffprobe
|
||||||
|
return audioTracks[0].audioProbeResult?.getBookChapters() ?: mutableListOf()
|
||||||
|
}
|
||||||
|
// Multi-track make chapters from tracks
|
||||||
|
return audioTracks.map { it.getBookChapter() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getLocalLibraryItem():LocalLibraryItem {
|
||||||
|
var mediaMetadata = getMediaMetadata()
|
||||||
|
if (mediaType == "book") {
|
||||||
|
var chapters = getAudiobookChapters()
|
||||||
|
var book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration())
|
||||||
|
return LocalLibraryItem(id,serverAddress, null, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true)
|
||||||
|
} else {
|
||||||
|
var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false)
|
||||||
|
return LocalLibraryItem(id,serverAddress, null, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class MediaProgress(
|
||||||
|
val id:String,
|
||||||
|
val libraryItemId:String,
|
||||||
|
val episodeId:String,
|
||||||
|
val duration:Double,
|
||||||
|
val progress:Double, // 0 to 1
|
||||||
|
val currentTime:Int,
|
||||||
|
val isFinished:Boolean,
|
||||||
|
val lastUpdate:Long,
|
||||||
|
val startedAt:Long,
|
||||||
|
val finishedAt:Long,
|
||||||
|
val isLocal:Boolean?
|
||||||
|
)
|
|
@ -127,4 +127,9 @@ class PlaybackSession(
|
||||||
}
|
}
|
||||||
return mediaItems
|
return mediaItems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun clone():PlaybackSession {
|
||||||
|
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,audioTracks,currentTime,libraryItem,localLibraryItem,serverUrl,token)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,184 +0,0 @@
|
||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.getcapacitor.JSObject
|
|
||||||
import okhttp3.*
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.concurrent.schedule
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Normal progress sync is handled in webview, but when using android auto webview may not be open.
|
|
||||||
* If webview is not open sync progress every 5s. Webview can be closed at any time so interval is always set.
|
|
||||||
*/
|
|
||||||
class AudiobookProgressSyncer constructor(playerNotificationService:PlayerNotificationService, client: OkHttpClient) {
|
|
||||||
private val tag = "AudiobookProgressSync"
|
|
||||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
|
||||||
private val client:OkHttpClient = client
|
|
||||||
|
|
||||||
private var listeningTimerTask: TimerTask? = null
|
|
||||||
var listeningTimerRunning:Boolean = false
|
|
||||||
|
|
||||||
private var webviewOpenOnStart:Boolean = false
|
|
||||||
private var webviewClosedMidSession:Boolean = false
|
|
||||||
private var listeningBookTitle:String? = ""
|
|
||||||
private var listeningBookIsLocal:Boolean = false
|
|
||||||
private var listeningBookId:String? = ""
|
|
||||||
private var listeningStreamId:String? = ""
|
|
||||||
|
|
||||||
private var lastPlaybackTime:Long = 0
|
|
||||||
private var lastUpdateTime:Long = 0
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
if (listeningTimerRunning) {
|
|
||||||
Log.d(tag, "start: Timer already running for $listeningBookTitle")
|
|
||||||
if (playerNotificationService.getCurrentBookTitle() != listeningBookTitle) {
|
|
||||||
Log.d(tag, "start: Changed audiobook stream - resetting timer")
|
|
||||||
listeningTimerTask?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
listeningTimerRunning = true
|
|
||||||
|
|
||||||
webviewOpenOnStart = playerNotificationService.getIsWebviewOpen()
|
|
||||||
listeningBookTitle = playerNotificationService.getCurrentBookTitle()
|
|
||||||
listeningBookIsLocal = false
|
|
||||||
listeningBookId = "empty"
|
|
||||||
listeningStreamId = "empty"
|
|
||||||
|
|
||||||
lastPlaybackTime = playerNotificationService.getCurrentTime()
|
|
||||||
lastUpdateTime = System.currentTimeMillis() / 1000L
|
|
||||||
|
|
||||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
// Webview was closed while android auto is open - switch to native sync
|
|
||||||
var isWebviewOpen = playerNotificationService.getIsWebviewOpen()
|
|
||||||
if (!isWebviewOpen && webviewOpenOnStart) {
|
|
||||||
Log.d(tag, "Listening Timer: webview closed Switching to native sync tracking")
|
|
||||||
webviewOpenOnStart = false
|
|
||||||
webviewClosedMidSession = true
|
|
||||||
lastUpdateTime = System.currentTimeMillis() / 1000L
|
|
||||||
} else if (isWebviewOpen && webviewClosedMidSession) {
|
|
||||||
Log.d(tag, "Listening Timer: webview re-opened Switching back to webview sync tracking")
|
|
||||||
webviewClosedMidSession = false
|
|
||||||
webviewOpenOnStart = true
|
|
||||||
lastUpdateTime = System.currentTimeMillis() / 1000L
|
|
||||||
}
|
|
||||||
if (!webviewOpenOnStart && playerNotificationService.currentPlayer.isPlaying) {
|
|
||||||
sync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
if (!listeningTimerRunning) return
|
|
||||||
Log.d(tag, "stop: Stopping listening for $listeningBookTitle")
|
|
||||||
|
|
||||||
if (!webviewOpenOnStart) {
|
|
||||||
sync()
|
|
||||||
}
|
|
||||||
reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
listeningTimerTask?.cancel()
|
|
||||||
listeningTimerTask = null
|
|
||||||
listeningTimerRunning = false
|
|
||||||
listeningBookTitle = ""
|
|
||||||
listeningBookId = ""
|
|
||||||
listeningBookIsLocal = false
|
|
||||||
listeningStreamId = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sync() {
|
|
||||||
var currTime = System.currentTimeMillis() / 1000L
|
|
||||||
var elapsed = currTime - lastUpdateTime
|
|
||||||
lastUpdateTime = currTime
|
|
||||||
|
|
||||||
if (!listeningBookIsLocal) {
|
|
||||||
Log.d(tag, "ListeningTimer: Sending sync data to server: elapsed $elapsed | $listeningStreamId | $listeningBookId")
|
|
||||||
|
|
||||||
// Send sync data only for streaming books
|
|
||||||
var syncData: JSObject = JSObject()
|
|
||||||
syncData.put("timeListened", elapsed)
|
|
||||||
syncData.put("currentTime", playerNotificationService.getCurrentTime() / 1000)
|
|
||||||
syncData.put("streamId", listeningStreamId)
|
|
||||||
syncData.put("audiobookId", listeningBookId)
|
|
||||||
sendStreamSyncData(syncData) {
|
|
||||||
Log.d(tag, "Stream sync done")
|
|
||||||
}
|
|
||||||
} else if (listeningStreamId == "download") {
|
|
||||||
// TODO: Save downloaded audiobook progress & send to server if connected
|
|
||||||
Log.d(tag, "ListeningTimer: Is listening download")
|
|
||||||
|
|
||||||
// Send sync data only for local books
|
|
||||||
var syncData: JSObject = JSObject()
|
|
||||||
// var duration = playerNotificationService.getAudiobookDuration() / 1000
|
|
||||||
var duration = 1000
|
|
||||||
var currentTime = playerNotificationService.getCurrentTime() / 1000
|
|
||||||
syncData.put("totalDuration", duration)
|
|
||||||
syncData.put("currentTime", currentTime)
|
|
||||||
syncData.put("progress", if (duration > 0) (currentTime / duration) else 0)
|
|
||||||
syncData.put("isRead", false)
|
|
||||||
syncData.put("lastUpdate", System.currentTimeMillis())
|
|
||||||
syncData.put("audiobookId", listeningBookId)
|
|
||||||
sendLocalSyncData(syncData) {
|
|
||||||
Log.d(tag, "Local sync done")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendLocalSyncData(payload:JSObject, cb: (() -> Unit)) {
|
|
||||||
var serverUrl = DeviceManager.serverAddress
|
|
||||||
var token = DeviceManager.token
|
|
||||||
|
|
||||||
if (serverUrl == "" || token == "") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(tag, "Sync Local $serverUrl | $token")
|
|
||||||
var url = "$serverUrl/api/syncLocal"
|
|
||||||
sendServerRequest(url, token, payload, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendStreamSyncData(payload:JSObject, cb: (() -> Unit)) {
|
|
||||||
var serverUrl = DeviceManager.serverAddress
|
|
||||||
var token = DeviceManager.token
|
|
||||||
|
|
||||||
if (serverUrl == "" || token == "") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(tag, "Sync Stream $serverUrl | $token")
|
|
||||||
var url = "$serverUrl/api/syncStream"
|
|
||||||
sendServerRequest(url, token, payload, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendServerRequest(url:String, token:String, payload:JSObject, cb: () -> Unit) {
|
|
||||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
|
||||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
|
||||||
val request = Request.Builder().post(requestBody)
|
|
||||||
.url(url).addHeader("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
|
|
||||||
client.newCall(request).enqueue(object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
Log.d(tag, "FAILURE TO CONNECT")
|
|
||||||
e.printStackTrace()
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
response.use {
|
|
||||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.data.MediaProgress
|
||||||
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.concurrent.schedule
|
||||||
|
|
||||||
|
data class MediaProgressSyncData(
|
||||||
|
var timeListened:Long, // seconds
|
||||||
|
var duration:Double, // seconds
|
||||||
|
var currentTime:Double // seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, apiHandler: ApiHandler) {
|
||||||
|
private val tag = "MediaProgressSync"
|
||||||
|
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||||
|
private val apiHandler = apiHandler
|
||||||
|
|
||||||
|
private var listeningTimerTask: TimerTask? = null
|
||||||
|
var listeningTimerRunning:Boolean = false
|
||||||
|
|
||||||
|
private var lastSyncTime:Long = 0
|
||||||
|
|
||||||
|
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
||||||
|
// var currentMediaProgress: MediaProgress? = null
|
||||||
|
|
||||||
|
val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||||
|
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||||
|
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||||
|
val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (listeningTimerRunning) {
|
||||||
|
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
|
||||||
|
if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
|
||||||
|
Log.d(tag, "Playback session changed, reset timer")
|
||||||
|
listeningTimerTask?.cancel()
|
||||||
|
lastSyncTime = 0L
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listeningTimerRunning = true
|
||||||
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
|
||||||
|
|
||||||
|
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||||
|
var currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||||
|
sync(currentTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (!listeningTimerRunning) return
|
||||||
|
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
||||||
|
|
||||||
|
var currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||||
|
sync(currentTime)
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sync(currentTime:Double) {
|
||||||
|
var diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
|
||||||
|
if (diffSinceLastSync < 1000L) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||||
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
var syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||||
|
|
||||||
|
if (currentIsLocal) {
|
||||||
|
// TODO: Save local progress sync
|
||||||
|
} else {
|
||||||
|
apiHandler.sendProgressSync(currentSessionId,syncData) {
|
||||||
|
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
listeningTimerTask?.cancel()
|
||||||
|
listeningTimerTask = null
|
||||||
|
listeningTimerRunning = false
|
||||||
|
currentPlaybackSession = null
|
||||||
|
lastSyncTime = 0L
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,6 +24,7 @@ import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.Audiobook
|
import com.audiobookshelf.app.Audiobook
|
||||||
import com.audiobookshelf.app.AudiobookManager
|
import com.audiobookshelf.app.AudiobookManager
|
||||||
import com.audiobookshelf.app.data.PlaybackSession
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import com.getcapacitor.Bridge
|
import com.getcapacitor.Bridge
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
import com.google.android.exoplayer2.*
|
import com.google.android.exoplayer2.*
|
||||||
|
@ -69,6 +70,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||||
private lateinit var mediaSession: MediaSessionCompat
|
private lateinit var mediaSession: MediaSessionCompat
|
||||||
private lateinit var transportControls:MediaControllerCompat.TransportControls
|
private lateinit var transportControls:MediaControllerCompat.TransportControls
|
||||||
private lateinit var audiobookManager: AudiobookManager
|
private lateinit var audiobookManager: AudiobookManager
|
||||||
|
lateinit var apiHandler: ApiHandler
|
||||||
|
|
||||||
lateinit var mPlayer: SimpleExoPlayer
|
lateinit var mPlayer: SimpleExoPlayer
|
||||||
lateinit var currentPlayer:Player
|
lateinit var currentPlayer:Player
|
||||||
|
@ -76,7 +78,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||||
|
|
||||||
lateinit var sleepTimerManager:SleepTimerManager
|
lateinit var sleepTimerManager:SleepTimerManager
|
||||||
lateinit var castManager:CastManager
|
lateinit var castManager:CastManager
|
||||||
lateinit var audiobookProgressSyncer:AudiobookProgressSyncer
|
lateinit var mediaProgressSyncer:MediaProgressSyncer
|
||||||
|
|
||||||
private var notificationId = 10;
|
private var notificationId = 10;
|
||||||
private var channelId = "audiobookshelf_channel"
|
private var channelId = "audiobookshelf_channel"
|
||||||
|
@ -195,7 +197,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||||
playerNotificationManager.setPlayer(null)
|
playerNotificationManager.setPlayer(null)
|
||||||
mPlayer.release()
|
mPlayer.release()
|
||||||
mediaSession.release()
|
mediaSession.release()
|
||||||
audiobookProgressSyncer.reset()
|
mediaProgressSyncer.reset()
|
||||||
Log.d(tag, "onDestroy")
|
Log.d(tag, "onDestroy")
|
||||||
isStarted = false
|
isStarted = false
|
||||||
|
|
||||||
|
@ -236,14 +238,17 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||||
|
|
||||||
var client: OkHttpClient = OkHttpClient()
|
var client: OkHttpClient = OkHttpClient()
|
||||||
|
|
||||||
|
// Initialize API
|
||||||
|
apiHandler = ApiHandler(ctx)
|
||||||
|
|
||||||
// Initialize sleep timer
|
// Initialize sleep timer
|
||||||
sleepTimerManager = SleepTimerManager(this)
|
sleepTimerManager = SleepTimerManager(this)
|
||||||
|
|
||||||
// Initialize Cast Manager
|
// Initialize Cast Manager
|
||||||
castManager = CastManager(this)
|
castManager = CastManager(this)
|
||||||
|
|
||||||
// Initialize Audiobook Progress Syncer (Only used for android auto when webview is not open)
|
// Initialize Media Progress Syncer
|
||||||
audiobookProgressSyncer = AudiobookProgressSyncer(this, client)
|
mediaProgressSyncer = MediaProgressSyncer(this, apiHandler)
|
||||||
|
|
||||||
// Initialize shake sensor
|
// Initialize shake sensor
|
||||||
Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}")
|
Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}")
|
||||||
|
@ -601,13 +606,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||||
}
|
}
|
||||||
} else lastPauseTime = System.currentTimeMillis()
|
} else lastPauseTime = System.currentTimeMillis()
|
||||||
|
|
||||||
// If app is only running in android auto then webview will not be open
|
// Start/stop progress sync interval
|
||||||
// so progress needs to be synced natively
|
|
||||||
Log.d(tag, "Playing ${getCurrentBookTitle()} | ${currentPlayer.mediaMetadata.title} | ${currentPlayer.mediaMetadata.displayTitle}")
|
Log.d(tag, "Playing ${getCurrentBookTitle()} | ${currentPlayer.mediaMetadata.title} | ${currentPlayer.mediaMetadata.displayTitle}")
|
||||||
if (player.isPlaying) {
|
if (player.isPlaying) {
|
||||||
audiobookProgressSyncer.start()
|
mediaProgressSyncer.start()
|
||||||
} else {
|
} else {
|
||||||
audiobookProgressSyncer.stop()
|
mediaProgressSyncer.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
clientEventEmitter?.onPlayingUpdate(player.isPlaying)
|
clientEventEmitter?.onPlayingUpdate(player.isPlaying)
|
||||||
|
@ -763,6 +767,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getCurrentTimeSeconds() : Double {
|
||||||
|
return getCurrentTime() / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
fun getBufferedTime() : Long {
|
fun getBufferedTime() : Long {
|
||||||
if (currentPlayer.mediaItemCount > 1) {
|
if (currentPlayer.mediaItemCount > 1) {
|
||||||
var windowIndex = currentPlayer.currentWindowIndex
|
var windowIndex = currentPlayer.currentWindowIndex
|
||||||
|
@ -785,6 +793,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||||
return currentPlaybackSession?.displayTitle
|
return currentPlaybackSession?.displayTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
|
||||||
|
return currentPlaybackSession?.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentPlaybackSessionId() :String? {
|
||||||
|
return currentPlaybackSession?.id
|
||||||
|
}
|
||||||
|
|
||||||
fun calcPauseSeekBackTime() : Long {
|
fun calcPauseSeekBackTime() : Long {
|
||||||
if (lastPauseTime <= 0) return 0
|
if (lastPauseTime <= 0) return 0
|
||||||
var time: Long = System.currentTimeMillis() - lastPauseTime
|
var time: Long = System.currentTimeMillis() - lastPauseTime
|
||||||
|
|
|
@ -7,6 +7,7 @@ import com.audiobookshelf.app.data.Library
|
||||||
import com.audiobookshelf.app.data.LibraryItem
|
import com.audiobookshelf.app.data.LibraryItem
|
||||||
import com.audiobookshelf.app.data.PlaybackSession
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||||
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 com.getcapacitor.JSArray
|
import com.getcapacitor.JSArray
|
||||||
|
@ -119,4 +120,12 @@ class ApiHandler {
|
||||||
cb(playbackSession)
|
cb(playbackSession)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: () -> Unit) {
|
||||||
|
var payload = JSObject(jacksonObjectMapper().writeValueAsString(syncData))
|
||||||
|
|
||||||
|
postRequest("/api/session/$sessionId/sync", payload) {
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@ export const getters = {
|
||||||
if (!state.user || !state.user.mediaProgress) return null
|
if (!state.user || !state.user.mediaProgress) return null
|
||||||
return state.user.mediaProgress.find(li => {
|
return state.user.mediaProgress.find(li => {
|
||||||
if (episodeId && li.episodeId !== episodeId) return false
|
if (episodeId && li.episodeId !== episodeId) return false
|
||||||
return li.id == libraryItemId
|
return li.libraryItemId == libraryItemId
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue