mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
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:
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
135
app/src/main/java/hu/bbara/purefin/player/manager/TrackMapper.kt
Normal file
135
app/src/main/java/hu/bbara/purefin/player/manager/TrackMapper.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user