mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Report & control music playback from server
This commit is contained in:
parent
6c26891bff
commit
9259edcf1c
2 changed files with 151 additions and 56 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue