From 88e9ca229e66d4fcccfdfc78677080beec36b461 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Wed, 28 Jan 2026 20:48:51 +0100 Subject: [PATCH] refactor: abstract player logic into `PlayerManager` and `MediaRepository` - Introduce `PlayerManager` to encapsulate `Media3` player interactions, state management, and UI updates. - Manages playback state (playing, buffering, ended, error), progress, metadata, track selection, and the media queue. - Exposes state via `StateFlow` for reactive UI updates. - Handles player lifecycle and event listeners. - Create `MediaRepository` to fetch media items and upcoming episodes from the Jellyfin API. - Abstracts away the logic for retrieving media sources, playback URLs, and constructing `MediaItem` objects. - Includes a method to get the next episodes for auto-play, avoiding duplicates already in the queue. - Implement `TrackMapper` to convert Media3 `Tracks` into a `TrackSelectionState` model for the UI. - Refactor `PlayerViewModel` to delegate all player and data-fetching logic to `PlayerManager` and `MediaRepository`. - The ViewModel now observes state flows from the manager and orchestrates UI actions (e.g., auto-hiding controls). - Simplifies the ViewModel by removing direct player listener implementation, progress loops, and track parsing. - Improves error handling for invalid media IDs and data loading issues. --- .../purefin/player/data/MediaRepository.kt | 72 +++ .../purefin/player/manager/PlayerManager.kt | 272 ++++++++++ .../purefin/player/manager/TrackMapper.kt | 135 +++++ .../player/viewmodel/PlayerViewModel.kt | 492 +++++------------- 4 files changed, 611 insertions(+), 360 deletions(-) create mode 100644 app/src/main/java/hu/bbara/purefin/player/data/MediaRepository.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/manager/TrackMapper.kt diff --git a/app/src/main/java/hu/bbara/purefin/player/data/MediaRepository.kt b/app/src/main/java/hu/bbara/purefin/player/data/MediaRepository.kt new file mode 100644 index 0000000..0276e7e --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/data/MediaRepository.kt @@ -0,0 +1,72 @@ +package hu.bbara.purefin.player.data + +import android.net.Uri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import dagger.hilt.android.scopes.ViewModelScoped +import hu.bbara.purefin.app.content.ContentMockData.episode +import hu.bbara.purefin.client.JellyfinApiClient +import javax.inject.Inject +import java.util.UUID + +@ViewModelScoped +class MediaRepository @Inject constructor( + private val jellyfinApiClient: JellyfinApiClient +) { + + suspend fun getMediaItem(mediaId: UUID): MediaItem? { + val mediaSources = jellyfinApiClient.getMediaSources(mediaId) + val selectedMediaSource = mediaSources.firstOrNull() ?: return null + val playbackUrl = jellyfinApiClient.getMediaPlaybackInfo( + mediaId = mediaId, + mediaSourceId = selectedMediaSource.id + ) ?: return null + val baseItem = jellyfinApiClient.getItemInfo(mediaId) + return createMediaItem( + mediaId = mediaId.toString(), + playbackUrl = playbackUrl, + title = baseItem?.name ?: selectedMediaSource.name, + subtitle = "S${baseItem!!.parentIndexNumber}:E${baseItem.indexNumber}" + ) + } + + suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set, count: Int = 2): List { + val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count) + return episodes.mapNotNull { episode -> + val id = episode.id ?: return@mapNotNull null + val stringId = id.toString() + if (existingIds.contains(stringId)) { + return@mapNotNull null + } + val mediaSources = jellyfinApiClient.getMediaSources(id) + val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null + val playbackUrl = jellyfinApiClient.getMediaPlaybackInfo( + mediaId = id, + mediaSourceId = selectedMediaSource.id + ) ?: return@mapNotNull null + createMediaItem( + mediaId = stringId, + playbackUrl = playbackUrl, + title = episode.name ?: selectedMediaSource.name, + subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}" + ) + } + } + + private fun createMediaItem( + mediaId: String, + playbackUrl: String, + title: String?, + subtitle: String? + ): MediaItem { + val metadata = MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .build() + return MediaItem.Builder() + .setUri(Uri.parse(playbackUrl)) + .setMediaId(mediaId) + .setMediaMetadata(metadata) + .build() + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt b/app/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt new file mode 100644 index 0000000..0b47019 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt @@ -0,0 +1,272 @@ +package hu.bbara.purefin.player.manager + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.Tracks +import androidx.media3.common.util.UnstableApi +import dagger.hilt.android.scopes.ViewModelScoped +import hu.bbara.purefin.player.model.QueueItemUi +import hu.bbara.purefin.player.model.TrackOption +import hu.bbara.purefin.player.model.TrackType +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Encapsulates the Media3 [Player] wiring and exposes reactive updates for the UI layer. + */ +@ViewModelScoped +@OptIn(UnstableApi::class) +class PlayerManager @Inject constructor( + val player: Player, + private val trackMapper: TrackMapper +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + private val _playbackState = MutableStateFlow(PlaybackStateSnapshot()) + val playbackState: StateFlow = _playbackState.asStateFlow() + + private val _progress = MutableStateFlow(PlaybackProgressSnapshot()) + val progress: StateFlow = _progress.asStateFlow() + + private val _metadata = MutableStateFlow(MetadataState()) + val metadata: StateFlow = _metadata.asStateFlow() + + private val _tracks = MutableStateFlow(TrackSelectionState()) + val tracks: StateFlow = _tracks.asStateFlow() + + private val _queue = MutableStateFlow>(emptyList()) + val queue: StateFlow> = _queue.asStateFlow() + + private val listener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + _playbackState.update { it.copy(isPlaying = isPlaying, isBuffering = false, isEnded = false) } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + val buffering = playbackState == Player.STATE_BUFFERING + val ended = playbackState == Player.STATE_ENDED + _playbackState.update { state -> + state.copy( + isBuffering = buffering, + isEnded = ended, + error = if (playbackState == Player.STATE_IDLE) state.error else null + ) + } + if (ended) player.pause() + } + + override fun onPlayerError(error: PlaybackException) { + _playbackState.update { it.copy(error = error.errorCodeName ?: error.localizedMessage ?: "Playback error") } + } + + override fun onTracksChanged(tracks: Tracks) { + refreshTracks(tracks) + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + refreshMetadata(mediaItem) + refreshQueue() + } + } + + init { + player.addListener(listener) + refreshMetadata(player.currentMediaItem) + refreshTracks(player.currentTracks) + refreshQueue() + startProgressLoop() + } + + fun play(mediaItem: MediaItem) { + player.setMediaItem(mediaItem) + player.prepare() + player.playWhenReady = true + refreshMetadata(mediaItem) + refreshQueue() + _playbackState.update { it.copy(isEnded = false, error = null) } + } + + fun addToQueue(mediaItem: MediaItem) { + player.addMediaItem(mediaItem) + refreshQueue() + } + + fun togglePlayPause() { + if (player.isPlaying) player.pause() else player.play() + } + + fun seekTo(positionMs: Long) { + player.seekTo(positionMs) + } + + fun seekBy(deltaMs: Long) { + val target = (player.currentPosition + deltaMs).coerceAtLeast(0L) + seekTo(target) + } + + fun seekToLiveEdge() { + if (player.isCurrentMediaItemLive) { + player.seekToDefaultPosition() + player.play() + } + } + + fun next() { + if (player.hasNextMediaItem()) { + player.seekToNextMediaItem() + } + } + + fun previous() { + if (player.hasPreviousMediaItem()) { + player.seekToPreviousMediaItem() + } + } + + fun selectTrack(option: TrackOption) { + val builder = player.trackSelectionParameters.buildUpon() + when (option.type) { + TrackType.TEXT -> { + if (option.isOff) { + builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) + builder.clearOverridesOfType(C.TRACK_TYPE_TEXT) + } else { + builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + builder.clearOverridesOfType(C.TRACK_TYPE_TEXT) + val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return + builder.addOverride( + TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex)) + ) + } + } + + TrackType.AUDIO -> { + builder.clearOverridesOfType(C.TRACK_TYPE_AUDIO) + val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return + builder.addOverride( + TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex)) + ) + } + + TrackType.VIDEO -> { + builder.clearOverridesOfType(C.TRACK_TYPE_VIDEO) + val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return + builder.addOverride( + TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex)) + ) + } + } + player.trackSelectionParameters = builder.build() + refreshTracks(player.currentTracks) + } + + fun setPlaybackSpeed(speed: Float) { + player.setPlaybackSpeed(speed) + } + + fun retry() { + player.prepare() + player.playWhenReady = true + } + + fun playQueueItem(id: String) { + val items = _queue.value + val targetIndex = items.indexOfFirst { it.id == id } + if (targetIndex >= 0) { + player.seekToDefaultPosition(targetIndex) + player.playWhenReady = true + refreshQueue() + } + } + + fun clearError() { + _playbackState.update { it.copy(error = null) } + } + + fun release() { + scope.cancel() + player.removeListener(listener) + player.release() + } + + private fun startProgressLoop() { + scope.launch { + while (isActive) { + val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs + val position = player.currentPosition + val buffered = player.bufferedPosition + _progress.value = PlaybackProgressSnapshot( + durationMs = duration, + positionMs = position, + bufferedMs = buffered, + isLive = player.isCurrentMediaItemLive + ) + delay(500) + } + } + } + + private fun refreshQueue() { + val items = mutableListOf() + for (i in 0 until player.mediaItemCount) { + val mediaItem = player.getMediaItemAt(i) + items.add( + QueueItemUi( + id = mediaItem.mediaId.ifEmpty { i.toString() }, + title = mediaItem.mediaMetadata.title?.toString() ?: "Item ${i + 1}", + subtitle = mediaItem.mediaMetadata.subtitle?.toString(), + artworkUrl = mediaItem.mediaMetadata.artworkUri?.toString(), + isCurrent = i == player.currentMediaItemIndex + ) + ) + } + _queue.value = items + } + + private fun refreshMetadata(mediaItem: MediaItem?) { + _metadata.value = MetadataState( + mediaId = mediaItem?.mediaId, + title = mediaItem?.mediaMetadata?.title?.toString(), + subtitle = mediaItem?.mediaMetadata?.subtitle?.toString() + ) + } + + private fun refreshTracks(tracks: Tracks) { + _tracks.value = trackMapper.map(tracks) + } +} + +data class PlaybackStateSnapshot( + val isPlaying: Boolean = false, + val isBuffering: Boolean = false, + val isEnded: Boolean = false, + val error: String? = null +) + +data class PlaybackProgressSnapshot( + val durationMs: Long = 0L, + val positionMs: Long = 0L, + val bufferedMs: Long = 0L, + val isLive: Boolean = false +) + +data class MetadataState( + val mediaId: String? = null, + val title: String? = null, + val subtitle: String? = null +) diff --git a/app/src/main/java/hu/bbara/purefin/player/manager/TrackMapper.kt b/app/src/main/java/hu/bbara/purefin/player/manager/TrackMapper.kt new file mode 100644 index 0000000..10ac64b --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/manager/TrackMapper.kt @@ -0,0 +1,135 @@ +package hu.bbara.purefin.player.manager + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.Tracks +import androidx.media3.common.util.UnstableApi +import hu.bbara.purefin.player.model.TrackOption +import hu.bbara.purefin.player.model.TrackType +import javax.inject.Inject + +data class TrackSelectionState( + val audioTracks: List = emptyList(), + val textTracks: List = emptyList(), + val videoTracks: List = emptyList(), + val selectedAudioTrackId: String? = null, + val selectedTextTrackId: String? = null, + val selectedVideoTrackId: String? = null +) + +class TrackMapper @Inject constructor() { + + @OptIn(UnstableApi::class) + fun map(tracks: Tracks): TrackSelectionState { + val audio = mutableListOf() + val text = mutableListOf() + val video = mutableListOf() + var selectedAudio: String? = null + var selectedText: String? = null + var selectedVideo: String? = null + + tracks.groups.forEachIndexed { groupIndex, group -> + when (group.type) { + C.TRACK_TYPE_AUDIO -> { + repeat(group.length) { trackIndex -> + val format = group.getTrackFormat(trackIndex) + val id = "a_${groupIndex}_${trackIndex}" + val label = format.label + ?: format.language + ?: "${format.channelCount}ch" + ?: "Audio ${trackIndex}" + val option = TrackOption( + id = id, + label = label, + language = format.language, + bitrate = format.bitrate, + channelCount = format.channelCount, + height = null, + groupIndex = groupIndex, + trackIndex = trackIndex, + type = TrackType.AUDIO, + isOff = false + ) + audio.add(option) + if (group.isTrackSelected(trackIndex)) selectedAudio = id + } + } + + C.TRACK_TYPE_TEXT -> { + repeat(group.length) { trackIndex -> + val format = group.getTrackFormat(trackIndex) + val id = "t_${groupIndex}_${trackIndex}" + val label = format.label + ?: format.language + ?: "Subtitle ${trackIndex}" + val option = TrackOption( + id = id, + label = label, + language = format.language, + bitrate = null, + channelCount = null, + height = null, + groupIndex = groupIndex, + trackIndex = trackIndex, + type = TrackType.TEXT, + isOff = false + ) + text.add(option) + if (group.isTrackSelected(trackIndex)) selectedText = id + } + } + + C.TRACK_TYPE_VIDEO -> { + repeat(group.length) { trackIndex -> + val format = group.getTrackFormat(trackIndex) + val id = "v_${groupIndex}_${trackIndex}" + val res = if (format.height != Format.NO_VALUE) "${format.height}p" else null + val label = res ?: format.label ?: "Video ${trackIndex}" + val option = TrackOption( + id = id, + label = label, + language = null, + bitrate = format.bitrate, + channelCount = null, + height = format.height.takeIf { it > 0 }, + groupIndex = groupIndex, + trackIndex = trackIndex, + type = TrackType.VIDEO, + isOff = false + ) + video.add(option) + if (group.isTrackSelected(trackIndex)) selectedVideo = id + } + } + } + } + + if (text.isNotEmpty()) { + text.add( + 0, + TrackOption( + id = "text_off", + label = "Off", + language = null, + bitrate = null, + channelCount = null, + height = null, + groupIndex = -1, + trackIndex = -1, + type = TrackType.TEXT, + isOff = true + ) + ) + } + + return TrackSelectionState( + audioTracks = audio, + textTracks = text, + videoTracks = video, + selectedAudioTrackId = selectedAudio, + selectedTextTrackId = selectedText ?: text.firstOrNull { option -> option.isOff }?.id, + selectedVideoTrackId = selectedVideo + ) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt b/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt index 936d48e..509f3e0 100644 --- a/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt @@ -1,47 +1,34 @@ package hu.bbara.purefin.player.viewmodel -import android.net.Uri -import androidx.annotation.OptIn -import androidx.core.net.toUri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.media3.common.C -import androidx.media3.common.Format -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.common.TrackSelectionOverride -import androidx.media3.common.Tracks -import androidx.media3.common.util.UnstableApi import dagger.hilt.android.lifecycle.HiltViewModel -import hu.bbara.purefin.client.JellyfinApiClient +import hu.bbara.purefin.player.data.MediaRepository +import hu.bbara.purefin.player.manager.PlayerManager import hu.bbara.purefin.player.model.PlayerUiState -import hu.bbara.purefin.player.model.QueueItemUi import hu.bbara.purefin.player.model.TrackOption -import hu.bbara.purefin.player.model.TrackType +import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.MediaSourceInfo -import javax.inject.Inject +import java.util.UUID @HiltViewModel class PlayerViewModel @Inject constructor( - private val savedStateHandle: SavedStateHandle, - val player: Player, - val jellyfinApiClient: JellyfinApiClient + savedStateHandle: SavedStateHandle, + private val playerManager: PlayerManager, + private val mediaRepository: MediaRepository ) : ViewModel() { - val mediaId: String? = savedStateHandle["MEDIA_ID"] - private val videoUris = savedStateHandle.getStateFlow("videoUris", emptyList()) + val player get() = playerManager.player + + private val mediaId: String? = savedStateHandle["MEDIA_ID"] + private val _uiState = MutableStateFlow(PlayerUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -49,152 +36,138 @@ class PlayerViewModel @Inject constructor( val controlsVisible: StateFlow = _controlsVisible.asStateFlow() private var autoHideJob: Job? = null - - private val playerListener = object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - _uiState.update { it.copy(isPlaying = isPlaying, isBuffering = false, isEnded = false) } - if (isPlaying) { - scheduleAutoHide() - } else { - showControls() - } - } - - override fun onPlaybackStateChanged(playbackState: Int) { - val buffering = playbackState == Player.STATE_BUFFERING - val ended = playbackState == Player.STATE_ENDED - _uiState.update { state -> - state.copy( - isBuffering = buffering, - isEnded = ended, - error = if (playbackState == Player.STATE_IDLE) state.error else null - ) - } - if (buffering || ended) showControls() - if (ended) player.pause() - } - - override fun onPlayerError(error: PlaybackException) { - _uiState.update { it.copy(error = error.errorCodeName ?: error.localizedMessage ?: "Playback error") } - showControls() - } - - override fun onTracksChanged(tracks: Tracks) { - updateTracks(tracks) - } - - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - val mediaId = mediaItem?.mediaId - if (!mediaId.isNullOrEmpty()) { - updateMetadata(mediaItem) - loadNextUpMedias(mediaId) - } - } - } + private var lastNextUpMediaId: String? = null + private var dataErrorMessage: String? = null init { - observePlayer() - loadMedia() - startProgressUpdates() + observePlayerState() + loadInitialMedia() } - private fun observePlayer() { - player.addListener(playerListener) - } - - fun loadMedia() { + private fun observePlayerState() { viewModelScope.launch { - val mediaSources: List = - jellyfinApiClient.getMediaSources(UUID.fromString(mediaId!!)) - val selectedMediaSource = mediaSources.first() - val contentUriString = - jellyfinApiClient.getMediaPlaybackInfo( - mediaId = UUID.fromString(mediaId), - mediaSourceId = selectedMediaSource.id - ) - val mediaMetadata = MediaMetadata.Builder() - .setTitle(selectedMediaSource.name) - .build() - contentUriString?.toUri()?.let { - playVideo( - uri = it, - metadata = mediaMetadata - ) + playerManager.playbackState.collect { state -> + _uiState.update { + it.copy( + isPlaying = state.isPlaying, + isBuffering = state.isBuffering, + isEnded = state.isEnded, + error = state.error ?: dataErrorMessage + ) + } + if (state.isPlaying) { + scheduleAutoHide() + } else { + showControls() + } + if (state.isEnded || state.isBuffering) { + showControls() + } + } + } + + viewModelScope.launch { + playerManager.progress.collect { progress -> + _uiState.update { + it.copy( + durationMs = progress.durationMs, + positionMs = progress.positionMs, + bufferedMs = progress.bufferedMs, + isLive = progress.isLive + ) + } + } + } + + viewModelScope.launch { + playerManager.metadata.collect { metadata -> + _uiState.update { + it.copy( + title = metadata.title, + subtitle = metadata.subtitle + ) + } + val currentMediaId = metadata.mediaId + if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) { + lastNextUpMediaId = currentMediaId + loadNextUp(currentMediaId) + } + } + } + + viewModelScope.launch { + playerManager.tracks.collect { tracks -> + _uiState.update { + it.copy( + audioTracks = tracks.audioTracks, + textTracks = tracks.textTracks, + qualityTracks = tracks.videoTracks, + selectedAudioTrackId = tracks.selectedAudioTrackId, + selectedTextTrackId = tracks.selectedTextTrackId, + selectedQualityTrackId = tracks.selectedVideoTrackId + ) + } + } + } + + viewModelScope.launch { + playerManager.queue.collect { queue -> + _uiState.update { it.copy(queue = queue) } } } } - private fun loadNextUpMedias(mediaId: String) { + private fun loadInitialMedia() { + val id = mediaId ?: return + val uuid = id.toUuidOrNull() + if (uuid == null) { + dataErrorMessage = "Invalid media id" + _uiState.update { it.copy(error = dataErrorMessage) } + return + } viewModelScope.launch { - val episodes = jellyfinApiClient.getNextEpisodes( - episodeId = UUID.fromString(mediaId), - count = 2 + val mediaItem = mediaRepository.getMediaItem(uuid) + if (mediaItem != null) { + playerManager.play(mediaItem) + if (dataErrorMessage != null) { + dataErrorMessage = null + _uiState.update { it.copy(error = null) } + } + } else { + dataErrorMessage = "Unable to load media" + _uiState.update { it.copy(error = dataErrorMessage) } + } + } + } + + private fun loadNextUp(currentMediaId: String) { + val uuid = currentMediaId.toUuidOrNull() ?: return + viewModelScope.launch { + val queuedIds = uiState.value.queue.map { it.id }.toSet() + val items = mediaRepository.getNextUpMediaItems( + episodeId = uuid, + existingIds = queuedIds ) - for (episode in episodes) { - if (_uiState.value.queue.any { it.id == episode.id.toString() }) { - continue - } - val mediaSources = jellyfinApiClient.getMediaSources(episode.id) - val selectedMediaSource = mediaSources.first() - val contentUriString = jellyfinApiClient.getMediaPlaybackInfo( - mediaId = episode.id, - mediaSourceId = selectedMediaSource.id - ) - val mediaMetadata = MediaMetadata.Builder() - .setTitle(selectedMediaSource.name) - .build() - contentUriString?.toUri()?.let { - addVideoUri(it, mediaMetadata, episode.id.toString()) - } - } - updateQueue() + items.forEach { playerManager.addToQueue(it) } } } - fun addVideoUri(contentUri: Uri, metadata: MediaMetadata, mediaId: String? = null) { - savedStateHandle["videoUris"] = videoUris.value + contentUri - val mediaItem = MediaItem.Builder() - .setUri(contentUri) - .setMediaMetadata(metadata) - .setMediaId(mediaId ?: contentUri.toString()) - .build() - player.addMediaItem(mediaItem) - } - - fun playVideo(uri: Uri, metadata: MediaMetadata) { - val mediaItem = MediaItem.Builder() - .setUri(uri) - .setMediaMetadata(metadata) - .setMediaId(mediaId ?: uri.toString()) - .build() - player.setMediaItem(mediaItem) - player.prepare() - player.playWhenReady = true - updateQueue() - updateMetadata(mediaItem) - updateTracks() - _uiState.update { it.copy(isEnded = false, error = null) } - } - fun togglePlayPause() { - if (player.isPlaying) player.pause() else player.play() + playerManager.togglePlayPause() } fun seekTo(positionMs: Long) { - player.seekTo(positionMs) + playerManager.seekTo(positionMs) scheduleAutoHide() } fun seekBy(deltaMs: Long) { - val target = (player.currentPosition + deltaMs).coerceAtLeast(0L) - seekTo(target) + playerManager.seekBy(deltaMs) + scheduleAutoHide() } fun seekToLiveEdge() { - if (player.isCurrentMediaItemLive) { - player.seekToDefaultPosition() - player.play() - } + playerManager.seekToLiveEdge() } fun showControls() { @@ -217,245 +190,44 @@ class PlayerViewModel @Inject constructor( } fun next() { - if (player.hasNextMediaItem()) { - player.seekToNextMediaItem() - showControls() - } + playerManager.next() + showControls() } fun previous() { - if (player.hasPreviousMediaItem()) { - player.seekToPreviousMediaItem() - showControls() - } + playerManager.previous() + showControls() } fun selectTrack(option: TrackOption) { - val builder = player.trackSelectionParameters.buildUpon() - when (option.type) { - TrackType.TEXT -> { - if (option.isOff) { - builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) - builder.clearOverridesOfType(C.TRACK_TYPE_TEXT) - } else { - builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) - builder.clearOverridesOfType(C.TRACK_TYPE_TEXT) - val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return - builder.addOverride( - TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex)) - ) - } - } - - TrackType.AUDIO -> { - builder.clearOverridesOfType(C.TRACK_TYPE_AUDIO) - val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return - builder.addOverride( - TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex)) - ) - } - - TrackType.VIDEO -> { - builder.clearOverridesOfType(C.TRACK_TYPE_VIDEO) - val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return - builder.addOverride( - TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex)) - ) - } - } - player.trackSelectionParameters = builder.build() - updateTracks() + playerManager.selectTrack(option) } fun setPlaybackSpeed(speed: Float) { - player.setPlaybackSpeed(speed) + playerManager.setPlaybackSpeed(speed) _uiState.update { it.copy(playbackSpeed = speed) } } fun retry() { - player.prepare() - player.playWhenReady = true + playerManager.retry() } fun playQueueItem(id: String) { - val items = _uiState.value.queue - val targetIndex = items.indexOfFirst { it.id == id } - if (targetIndex >= 0) { - player.seekToDefaultPosition(targetIndex) - player.playWhenReady = true - showControls() - } + playerManager.playQueueItem(id) + showControls() } fun clearError() { + dataErrorMessage = null + playerManager.clearError() _uiState.update { it.copy(error = null) } } - private fun startProgressUpdates() { - viewModelScope.launch { - while (isActive) { - val duration = player.duration.takeIf { it > 0 } ?: _uiState.value.durationMs - val position = player.currentPosition - val buffered = player.bufferedPosition - _uiState.update { - it.copy( - durationMs = duration, - positionMs = position, - bufferedMs = buffered, - isLive = player.isCurrentMediaItemLive - ) - } - delay(500) - } - } - } - - @OptIn(UnstableApi::class) - private fun updateTracks(tracks: Tracks = player.currentTracks) { - val audio = mutableListOf() - val text = mutableListOf() - val video = mutableListOf() - var selectedAudio: String? = null - var selectedText: String? = null - var selectedVideo: String? = null - - tracks.groups.forEachIndexed { groupIndex, group -> - when (group.type) { - C.TRACK_TYPE_AUDIO -> { - repeat(group.length) { trackIndex -> - val format = group.getTrackFormat(trackIndex) - val id = "a_${groupIndex}_$trackIndex" - val label = format.label - ?: format.language - ?: "${format.channelCount}ch" - ?: "Audio $trackIndex" - val option = TrackOption( - id = id, - label = label, - language = format.language, - bitrate = format.bitrate, - channelCount = format.channelCount, - height = null, - groupIndex = groupIndex, - trackIndex = trackIndex, - type = TrackType.AUDIO, - isOff = false - ) - audio.add(option) - if (group.isTrackSelected(trackIndex)) selectedAudio = id - } - } - - C.TRACK_TYPE_TEXT -> { - repeat(group.length) { trackIndex -> - val format = group.getTrackFormat(trackIndex) - val id = "t_${groupIndex}_$trackIndex" - val label = format.label - ?: format.language - ?: "Subtitle $trackIndex" - val option = TrackOption( - id = id, - label = label, - language = format.language, - bitrate = null, - channelCount = null, - height = null, - groupIndex = groupIndex, - trackIndex = trackIndex, - type = TrackType.TEXT, - isOff = false - ) - text.add(option) - if (group.isTrackSelected(trackIndex)) selectedText = id - } - } - - C.TRACK_TYPE_VIDEO -> { - repeat(group.length) { trackIndex -> - val format = group.getTrackFormat(trackIndex) - val id = "v_${groupIndex}_$trackIndex" - val res = if (format.height != Format.NO_VALUE) "${format.height}p" else null - val label = res ?: format.label ?: "Video $trackIndex" - val option = TrackOption( - id = id, - label = label, - language = null, - bitrate = format.bitrate, - channelCount = null, - height = format.height.takeIf { it > 0 }, - groupIndex = groupIndex, - trackIndex = trackIndex, - type = TrackType.VIDEO, - isOff = false - ) - video.add(option) - if (group.isTrackSelected(trackIndex)) selectedVideo = id - } - } - } - } - - if (text.isNotEmpty()) { - text.add( - 0, - TrackOption( - id = "text_off", - label = "Off", - language = null, - bitrate = null, - channelCount = null, - height = null, - groupIndex = -1, - trackIndex = -1, - type = TrackType.TEXT, - isOff = true - ) - ) - } - - _uiState.update { - it.copy( - audioTracks = audio, - textTracks = text, - qualityTracks = video, - selectedAudioTrackId = selectedAudio, - selectedTextTrackId = selectedText ?: text.firstOrNull { option -> option.isOff }?.id, - selectedQualityTrackId = selectedVideo - ) - } - } - - private fun updateQueue() { - val items = mutableListOf() - for (i in 0 until player.mediaItemCount) { - val mediaItem = player.getMediaItemAt(i) - items.add( - QueueItemUi( - id = mediaItem.mediaId.ifEmpty { i.toString() }, - title = mediaItem.mediaMetadata.title?.toString() ?: "Item ${i + 1}", - subtitle = mediaItem.mediaMetadata.subtitle?.toString(), - artworkUrl = mediaItem.mediaMetadata.artworkUri?.toString(), - isCurrent = i == player.currentMediaItemIndex - ) - ) - } - _uiState.update { it.copy(queue = items) } - } - - private fun updateMetadata(mediaItem: MediaItem?) { - mediaItem ?: return - _uiState.update { - it.copy( - title = mediaItem.mediaMetadata.title?.toString(), - subtitle = mediaItem.mediaMetadata.subtitle?.toString() - ) - } - } - override fun onCleared() { super.onCleared() autoHideJob?.cancel() - player.removeListener(playerListener) - player.release() + playerManager.release() } + + private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull() }