Report & control music playback from server

This commit is contained in:
Damontecres 2026-03-16 14:17:11 -04:00
parent 6c26891bff
commit 9259edcf1c
No known key found for this signature in database
2 changed files with 151 additions and 56 deletions

View file

@ -19,17 +19,27 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PlaybackItemState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.profile.Codec
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@ -38,14 +48,20 @@ import okhttp3.OkHttpClient
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.instantMixApi import org.jellyfin.sdk.api.client.extensions.instantMixApi
import org.jellyfin.sdk.api.client.extensions.universalAudioApi import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Singleton @Singleton
@ -54,6 +70,7 @@ class MusicService
constructor( constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
@param:DefaultCoroutineScope private val defaultScope: CoroutineScope,
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService, private val imageUrlService: ImageUrlService,
@ -84,6 +101,8 @@ class MusicService
private val mutex = Mutex() private val mutex = Mutex()
var mediaSession: MediaSession? = null var mediaSession: MediaSession? = null
private set private set
private var activityTracker: TrackActivityPlaybackListener? = null
private var websocketJob: Job? = null
suspend fun start() { suspend fun start() {
if (mediaSession == null) { if (mediaSession == null) {
@ -91,6 +110,17 @@ class MusicService
if (mediaSession == null) { if (mediaSession == null) {
Timber.i("Starting music MediaSession") Timber.i("Starting music MediaSession")
mediaSession = MediaSession.Builder(context, player).build() mediaSession = MediaSession.Builder(context, player).build()
activityTracker =
TrackActivityPlaybackListener(api, player) {
state.value.currentItemId?.let { itemId ->
PlaybackItemState(
itemId = itemId,
playMethod = PlayMethod.DIRECT_PLAY,
// playSessionId = mediaSession?.id,
)
}
}.also { player.addListener(it) }
websocketJob = subscribe()
} }
} }
} }
@ -109,6 +139,13 @@ class MusicService
mediaSession?.release() mediaSession?.release()
mediaSession = null mediaSession = null
onMain { onMain {
websocketJob?.cancel()
websocketJob = null
activityTracker?.let {
it.release()
player.removeListener(it)
}
activityTracker = null
player.stop() player.stop()
player.setMediaItems(emptyList()) player.setMediaItems(emptyList())
} }
@ -315,6 +352,59 @@ class MusicService
_state.update { it.copy(loadingState = LoadingState.Success) } _state.update { it.copy(loadingState = LoadingState.Success) }
return result return result
} }
fun subscribe(): Job =
api.webSocket
.subscribe<PlaystateMessage>()
.onEach { message ->
message.data?.let {
withContext(Dispatchers.Main) {
when (it.command) {
PlaystateCommand.STOP -> {
stop()
}
PlaystateCommand.PAUSE -> {
player.pause()
}
PlaystateCommand.UNPAUSE -> {
player.play()
}
PlaystateCommand.NEXT_TRACK -> {
player.seekToNext()
}
PlaystateCommand.PREVIOUS_TRACK -> {
player.seekToPrevious()
}
PlaystateCommand.SEEK -> {
it.seekPositionTicks?.ticks?.let {
player.seekTo(
it.inWholeMilliseconds,
)
}
}
PlaystateCommand.REWIND -> {
player.seekBack(10.seconds)
}
PlaystateCommand.FAST_FORWARD -> {
player.seekForward(30.seconds)
}
PlaystateCommand.PLAY_PAUSE -> {
if (player.isPlaying) player.pause() else player.play()
}
}
}
}
}.catch { ex ->
Timber.e(ex, "Error in websocket subscription")
}.launchIn(defaultScope)
} }
@Stable @Stable

View file

@ -32,7 +32,7 @@ import kotlin.time.Duration.Companion.seconds
class TrackActivityPlaybackListener( class TrackActivityPlaybackListener(
private val api: ApiClient, private val api: ApiClient,
private val player: Player, private val player: Player,
private val getState: () -> PlaybackItemState, private val getState: () -> PlaybackItemState?,
) : Player.Listener { ) : Player.Listener {
private val coroutineScope = CoroutineScope(Dispatchers.Main) private val coroutineScope = CoroutineScope(Dispatchers.Main)
private val task: TimerTask = private val task: TimerTask =
@ -51,46 +51,50 @@ class TrackActivityPlaybackListener(
fun init() { fun init() {
launch("reportPlaybackStart") { launch("reportPlaybackStart") {
val state = getState.invoke() getState.invoke()?.let { state ->
Timber.v("reportPlaybackStart for ${state.itemId}") Timber.v("reportPlaybackStart for ${state.itemId}")
api.playStateApi.reportPlaybackStart( api.playStateApi.reportPlaybackStart(
PlaybackStartInfo( PlaybackStartInfo(
canSeek = true, canSeek = true,
itemId = state.itemId, itemId = state.itemId,
isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
playMethod = state.playMethod, playMethod = state.playMethod,
repeatMode = RepeatMode.REPEAT_NONE, repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT, playbackOrder = PlaybackOrder.DEFAULT,
isMuted = false, isMuted = false,
audioStreamIndex = state.audioStreamIndex, audioStreamIndex = state.audioStreamIndex,
subtitleStreamIndex = state.subtitleStreamIndex, subtitleStreamIndex = state.subtitleStreamIndex,
playSessionId = state.playSessionId, playSessionId = state.playSessionId,
liveStreamId = state.liveStreamId, liveStreamId = state.liveStreamId,
), ),
) )
val delay = 5.seconds.inWholeMilliseconds
// Every x seconds, check if the video is playing val delay = 5.seconds.inWholeMilliseconds
TIMER.schedule(task, delay, delay) // Every x seconds, check if the video is playing
initialized = true TIMER.schedule(task, delay, delay)
initialized = true
}
} }
} }
fun release() { fun release() {
// player.removeListener(this)
task.cancel() task.cancel()
TIMER.purge() TIMER.purge()
val position = player.currentPosition.milliseconds val position = player.currentPosition.milliseconds
launch("reportPlaybackStopped") { launch("reportPlaybackStopped") {
val state = getState.invoke() getState.invoke()?.let { state ->
Timber.v("reportPlaybackStopped for ${state.itemId} at $position") Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
api.playStateApi.reportPlaybackStopped( api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo( PlaybackStopInfo(
itemId = state.itemId, itemId = state.itemId,
positionTicks = position.inWholeTicks, positionTicks = position.inWholeTicks,
failed = false, failed = false,
playSessionId = state.playSessionId, playSessionId = state.playSessionId,
liveStreamId = state.liveStreamId, liveStreamId = state.liveStreamId,
), ),
) )
}
} }
} }
@ -111,30 +115,31 @@ class TrackActivityPlaybackListener(
private fun saveActivity(position: Long) { private fun saveActivity(position: Long) {
launch("saveActivity") { launch("saveActivity") {
val state = getState.invoke() getState.invoke()?.let { state ->
val calcPosition = val calcPosition =
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
(if (position >= 0) position else player.currentPosition) (if (position >= 0) position else player.currentPosition)
}
if (calcPosition > 0) {
val isPaused = withContext(Dispatchers.Main) { !player.isPlaying }
Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition")
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = state.itemId,
positionTicks = calcPosition.milliseconds.inWholeTicks,
canSeek = true,
isPaused = isPaused,
isMuted = false,
playMethod = state.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
audioStreamIndex = state.audioStreamIndex,
subtitleStreamIndex = state.subtitleStreamIndex,
playSessionId = state.playSessionId,
liveStreamId = state.liveStreamId,
),
)
} }
if (calcPosition > 0) {
val isPaused = withContext(Dispatchers.Main) { !player.isPlaying }
Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition")
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = state.itemId,
positionTicks = calcPosition.milliseconds.inWholeTicks,
canSeek = true,
isPaused = isPaused,
isMuted = false,
playMethod = state.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
audioStreamIndex = state.audioStreamIndex,
subtitleStreamIndex = state.subtitleStreamIndex,
playSessionId = state.playSessionId,
liveStreamId = state.liveStreamId,
),
)
} }
} }
} }