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.
This commit is contained in:
2026-01-28 20:48:51 +01:00
parent eae843312c
commit 88e9ca229e
4 changed files with 611 additions and 360 deletions

View File

@@ -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<String>, count: Int = 2): List<MediaItem> {
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()
}
}

View File

@@ -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<PlaybackStateSnapshot> = _playbackState.asStateFlow()
private val _progress = MutableStateFlow(PlaybackProgressSnapshot())
val progress: StateFlow<PlaybackProgressSnapshot> = _progress.asStateFlow()
private val _metadata = MutableStateFlow(MetadataState())
val metadata: StateFlow<MetadataState> = _metadata.asStateFlow()
private val _tracks = MutableStateFlow(TrackSelectionState())
val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow()
private val _queue = MutableStateFlow<List<QueueItemUi>>(emptyList())
val queue: StateFlow<List<QueueItemUi>> = _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<QueueItemUi>()
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
)

View File

@@ -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<TrackOption> = emptyList(),
val textTracks: List<TrackOption> = emptyList(),
val videoTracks: List<TrackOption> = 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<TrackOption>()
val text = mutableListOf<TrackOption>()
val video = mutableListOf<TrackOption>()
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
)
}
}

View File

@@ -1,47 +1,34 @@
package hu.bbara.purefin.player.viewmodel 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.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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 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.PlayerUiState
import hu.bbara.purefin.player.model.QueueItemUi
import hu.bbara.purefin.player.model.TrackOption 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.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID import java.util.UUID
import org.jellyfin.sdk.model.api.MediaSourceInfo
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class PlayerViewModel @Inject constructor( class PlayerViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
val player: Player, private val playerManager: PlayerManager,
val jellyfinApiClient: JellyfinApiClient private val mediaRepository: MediaRepository
) : ViewModel() { ) : ViewModel() {
val mediaId: String? = savedStateHandle["MEDIA_ID"] val player get() = playerManager.player
private val videoUris = savedStateHandle.getStateFlow("videoUris", emptyList<Uri>())
private val mediaId: String? = savedStateHandle["MEDIA_ID"]
private val _uiState = MutableStateFlow(PlayerUiState()) private val _uiState = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow() val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
@@ -49,152 +36,138 @@ class PlayerViewModel @Inject constructor(
val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow() val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow()
private var autoHideJob: Job? = null private var autoHideJob: Job? = null
private var lastNextUpMediaId: String? = null
private val playerListener = object : Player.Listener { private var dataErrorMessage: String? = null
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)
}
}
}
init { init {
observePlayer() observePlayerState()
loadMedia() loadInitialMedia()
startProgressUpdates()
} }
private fun observePlayer() { private fun observePlayerState() {
player.addListener(playerListener)
}
fun loadMedia() {
viewModelScope.launch { viewModelScope.launch {
val mediaSources: List<MediaSourceInfo> = playerManager.playbackState.collect { state ->
jellyfinApiClient.getMediaSources(UUID.fromString(mediaId!!)) _uiState.update {
val selectedMediaSource = mediaSources.first() it.copy(
val contentUriString = isPlaying = state.isPlaying,
jellyfinApiClient.getMediaPlaybackInfo( isBuffering = state.isBuffering,
mediaId = UUID.fromString(mediaId), isEnded = state.isEnded,
mediaSourceId = selectedMediaSource.id error = state.error ?: dataErrorMessage
) )
val mediaMetadata = MediaMetadata.Builder() }
.setTitle(selectedMediaSource.name) if (state.isPlaying) {
.build() scheduleAutoHide()
contentUriString?.toUri()?.let { } else {
playVideo( showControls()
uri = it, }
metadata = mediaMetadata 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 { viewModelScope.launch {
val episodes = jellyfinApiClient.getNextEpisodes( val mediaItem = mediaRepository.getMediaItem(uuid)
episodeId = UUID.fromString(mediaId), if (mediaItem != null) {
count = 2 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) { items.forEach { playerManager.addToQueue(it) }
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()
} }
} }
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() { fun togglePlayPause() {
if (player.isPlaying) player.pause() else player.play() playerManager.togglePlayPause()
} }
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
player.seekTo(positionMs) playerManager.seekTo(positionMs)
scheduleAutoHide() scheduleAutoHide()
} }
fun seekBy(deltaMs: Long) { fun seekBy(deltaMs: Long) {
val target = (player.currentPosition + deltaMs).coerceAtLeast(0L) playerManager.seekBy(deltaMs)
seekTo(target) scheduleAutoHide()
} }
fun seekToLiveEdge() { fun seekToLiveEdge() {
if (player.isCurrentMediaItemLive) { playerManager.seekToLiveEdge()
player.seekToDefaultPosition()
player.play()
}
} }
fun showControls() { fun showControls() {
@@ -217,245 +190,44 @@ class PlayerViewModel @Inject constructor(
} }
fun next() { fun next() {
if (player.hasNextMediaItem()) { playerManager.next()
player.seekToNextMediaItem() showControls()
showControls()
}
} }
fun previous() { fun previous() {
if (player.hasPreviousMediaItem()) { playerManager.previous()
player.seekToPreviousMediaItem() showControls()
showControls()
}
} }
fun selectTrack(option: TrackOption) { fun selectTrack(option: TrackOption) {
val builder = player.trackSelectionParameters.buildUpon() playerManager.selectTrack(option)
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()
} }
fun setPlaybackSpeed(speed: Float) { fun setPlaybackSpeed(speed: Float) {
player.setPlaybackSpeed(speed) playerManager.setPlaybackSpeed(speed)
_uiState.update { it.copy(playbackSpeed = speed) } _uiState.update { it.copy(playbackSpeed = speed) }
} }
fun retry() { fun retry() {
player.prepare() playerManager.retry()
player.playWhenReady = true
} }
fun playQueueItem(id: String) { fun playQueueItem(id: String) {
val items = _uiState.value.queue playerManager.playQueueItem(id)
val targetIndex = items.indexOfFirst { it.id == id } showControls()
if (targetIndex >= 0) {
player.seekToDefaultPosition(targetIndex)
player.playWhenReady = true
showControls()
}
} }
fun clearError() { fun clearError() {
dataErrorMessage = null
playerManager.clearError()
_uiState.update { it.copy(error = null) } _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<TrackOption>()
val text = mutableListOf<TrackOption>()
val video = mutableListOf<TrackOption>()
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<QueueItemUi>()
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() { override fun onCleared() {
super.onCleared() super.onCleared()
autoHideJob?.cancel() autoHideJob?.cancel()
player.removeListener(playerListener) playerManager.release()
player.release()
} }
private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull()
} }