refactor: modularize app into multi-module architecture

This commit is contained in:
2026-02-21 00:15:51 +01:00
parent 8601ef0236
commit 7333781f83
123 changed files with 668 additions and 404 deletions

View File

@@ -0,0 +1,42 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "hu.bbara.purefin.core.player"
compileSdk = 36
defaultConfig {
minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.medi3.exoplayer)
implementation(libs.media3.datasource.okhttp)
implementation(libs.datastore)
implementation(libs.kotlinx.serialization.json)
implementation(libs.jellyfin.core)
implementation(libs.okhttp)
}

View File

@@ -0,0 +1,189 @@
package hu.bbara.purefin.core.player.data
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import java.util.UUID
import javax.inject.Inject
@ViewModelScoped
class PlayerMediaRepository @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository
) {
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
val mediaSources = jellyfinApiClient.getMediaSources(mediaId)
val selectedMediaSource = mediaSources.firstOrNull() ?: return@withContext null
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
mediaId = mediaId,
mediaSource = selectedMediaSource
) ?: return@withContext null
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
// Calculate resume position
val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource)
val serverUrl = userSessionRepository.serverUrl.first()
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, mediaId, selectedMediaSource)
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackUrl = playbackUrl,
title = baseItem?.name ?: selectedMediaSource.name!!,
subtitle = "S${baseItem!!.parentIndexNumber}:E${baseItem.indexNumber}",
artworkUrl = artworkUrl,
subtitleConfigurations = subtitleConfigs
)
Pair(mediaItem, resumePositionMs)
}
private fun calculateResumePosition(
baseItem: BaseItemDto?,
mediaSource: MediaSourceInfo
): Long? {
val userData = baseItem?.userData ?: return null
// Get runtime in ticks
val runtimeTicks = mediaSource.runTimeTicks ?: baseItem.runTimeTicks ?: 0L
if (runtimeTicks == 0L) return null
// Get saved playback position from userData
val playbackPositionTicks = userData.playbackPositionTicks ?: 0L
if (playbackPositionTicks == 0L) return null
// Convert ticks to milliseconds
val positionMs = playbackPositionTicks / 10_000
// Calculate percentage for threshold check
val percentage = (playbackPositionTicks.toDouble() / runtimeTicks.toDouble()) * 100.0
// Apply thresholds: resume only if 5% ≤ progress ≤ 95%
return if (percentage in 5.0..95.0) positionMs else null
}
suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 5): List<MediaItem> = withContext(Dispatchers.IO) {
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
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.getMediaPlaybackUrl(
mediaId = id,
mediaSource = selectedMediaSource
) ?: return@mapNotNull null
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource)
createMediaItem(
mediaId = stringId,
playbackUrl = playbackUrl,
title = episode.name ?: selectedMediaSource.name!!,
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
artworkUrl = artworkUrl,
subtitleConfigurations = subtitleConfigs
)
}
}
@OptIn(UnstableApi::class)
private fun buildExternalSubtitleConfigs(
serverUrl: String,
mediaId: UUID,
mediaSource: MediaSourceInfo
): List<MediaItem.SubtitleConfiguration> {
val streams = mediaSource.mediaStreams ?: return emptyList()
val mediaSourceId = mediaSource.id ?: return emptyList()
val baseUrl = serverUrl.trimEnd('/')
return streams
.filter { it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL }
.mapNotNull { stream ->
val codec = stream.codec ?: return@mapNotNull null
val mimeType = subtitleCodecToMimeType(codec) ?: return@mapNotNull null
// Use deliveryUrl from server if available, otherwise construct it
val url = if (!stream.deliveryUrl.isNullOrBlank()) {
if (stream.deliveryUrl!!.startsWith("http")) {
stream.deliveryUrl!!
} else {
"$baseUrl${stream.deliveryUrl}"
}
} else {
val format = if (codec == "subrip") "srt" else codec
"$baseUrl/Videos/$mediaId/$mediaSourceId/Subtitles/${stream.index}/0/Stream.$format"
}
Log.d("PlayerMediaRepo", "External subtitle: ${stream.displayTitle} ($codec) -> $url")
MediaItem.SubtitleConfiguration.Builder(url.toUri())
.setMimeType(mimeType)
.setLanguage(stream.language)
.setLabel(stream.displayTitle ?: stream.language ?: "Track ${stream.index}")
.setSelectionFlags(
if (stream.isForced) C.SELECTION_FLAG_FORCED
else if (stream.isDefault) C.SELECTION_FLAG_DEFAULT
else 0
)
.build()
}
}
@OptIn(UnstableApi::class)
private fun subtitleCodecToMimeType(codec: String): String? = when (codec.lowercase()) {
"srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP
"ass", "ssa" -> MimeTypes.TEXT_SSA
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
"ttml", "dfxp" -> MimeTypes.APPLICATION_TTML
"sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
else -> {
Log.w("PlayerMediaRepo", "Unknown subtitle codec: $codec")
null
}
}
private fun createMediaItem(
mediaId: String,
playbackUrl: String,
title: String,
subtitle: String?,
artworkUrl: String,
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList()
): MediaItem {
val metadata = MediaMetadata.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setArtworkUri(artworkUrl.toUri())
.build()
return MediaItem.Builder()
.setUri(playbackUrl.toUri())
.setMediaId(mediaId)
.setMediaMetadata(metadata)
.setSubtitleConfigurations(subtitleConfigurations)
.build()
}
}

View File

@@ -0,0 +1,372 @@
package hu.bbara.purefin.core.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.core.player.model.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType
import hu.bbara.purefin.core.player.preference.AudioTrackProperties
import hu.bbara.purefin.core.player.preference.SubtitleTrackProperties
import hu.bbara.purefin.core.player.preference.TrackMatcher
import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository
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.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* 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 trackPreferencesRepository: TrackPreferencesRepository,
private val trackMatcher: TrackMatcher
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var currentMediaContext: MediaContext? = null
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)
scope.launch {
applyTrackPreferences()
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
refreshMetadata(mediaItem)
refreshQueue()
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
if (reason == Player.DISCONTINUITY_REASON_SEEK &&
newPosition.positionMs < oldPosition.positionMs
) {
refreshSubtitleRendererOnBackwardSeek()
}
}
}
init {
player.addListener(listener)
refreshMetadata(player.currentMediaItem)
refreshTracks(player.currentTracks)
refreshQueue()
startProgressLoop()
}
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) {
currentMediaContext = mediaContext
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)
// Save track preference if media context is available
currentMediaContext?.let { context ->
scope.launch {
saveTrackPreference(option, context.preferenceKey)
}
}
}
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) }
}
private suspend fun applyTrackPreferences() {
val context = currentMediaContext ?: return
val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: return
val currentTrackState = _tracks.value
// Apply audio preference
preferences.audioPreference?.let { audioPreference ->
val matchedAudio = trackMatcher.findBestAudioMatch(
currentTrackState.audioTracks,
audioPreference
)
matchedAudio?.let { selectTrack(it) }
}
// Apply subtitle preference
preferences.subtitlePreference?.let { subtitlePreference ->
val matchedSubtitle = trackMatcher.findBestSubtitleMatch(
currentTrackState.textTracks,
subtitlePreference
)
matchedSubtitle?.let { selectTrack(it) }
}
}
private suspend fun saveTrackPreference(option: TrackOption, preferenceKey: String) {
when (option.type) {
TrackType.AUDIO -> {
val properties = AudioTrackProperties(
language = option.language,
channelCount = option.channelCount,
label = option.label
)
trackPreferencesRepository.saveAudioPreference(preferenceKey, properties)
}
TrackType.TEXT -> {
val properties = SubtitleTrackProperties(
language = option.language,
forced = option.forced,
label = option.label,
isOff = option.isOff
)
trackPreferencesRepository.saveSubtitlePreference(preferenceKey, properties)
}
TrackType.VIDEO -> {
// Video preferences not implemented in this feature
}
}
}
private fun refreshSubtitleRendererOnBackwardSeek() {
val currentParams = player.trackSelectionParameters
if (C.TRACK_TYPE_TEXT in currentParams.disabledTrackTypes) return
scope.launch {
player.trackSelectionParameters = currentParams.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
player.trackSelectionParameters = currentParams
}
}
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
)
data class MediaContext(
val mediaId: String,
val preferenceKey: String
)

View File

@@ -0,0 +1,119 @@
package hu.bbara.purefin.core.player.manager
import android.util.Log
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.domain.usecase.UpdateWatchProgressUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
@ViewModelScoped
class ProgressManager @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val updateWatchProgressUseCase: UpdateWatchProgressUseCase
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var progressJob: Job? = null
private var activeItemId: UUID? = null
private var lastPositionMs: Long = 0L
private var lastDurationMs: Long = 0L
private var isPaused: Boolean = false
fun bind(
playbackState: StateFlow<PlaybackStateSnapshot>,
progress: StateFlow<PlaybackProgressSnapshot>,
metadata: StateFlow<MetadataState>
) {
scope.launch {
combine(playbackState, progress, metadata) { state, prog, meta ->
Triple(state, prog, meta)
}.collect { (state, prog, meta) ->
lastPositionMs = prog.positionMs
lastDurationMs = prog.durationMs
isPaused = !state.isPlaying
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
// Media changed or ended - stop session
if (activeItemId != null && (mediaId != activeItemId || state.isEnded)) {
stopSession()
}
// Start session when we have a media item and none is active
if (activeItemId == null && mediaId != null && !state.isEnded) {
startSession(mediaId, prog.positionMs)
}
}
}
}
private fun startSession(itemId: UUID, positionMs: Long) {
activeItemId = itemId
report(itemId, positionMs, isStart = true)
progressJob = scope.launch {
while (isActive) {
delay(5000)
report(itemId, lastPositionMs, isPaused = isPaused)
}
}
}
private fun stopSession() {
progressJob?.cancel()
activeItemId?.let { itemId ->
report(itemId, lastPositionMs, isStop = true)
scope.launch(Dispatchers.IO) {
try {
updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs)
} catch (e: Exception) {
Log.e("ProgressManager", "Local cache update failed", e)
}
}
}
activeItemId = null
}
private fun report(itemId: UUID, positionMs: Long, isPaused: Boolean = false, isStart: Boolean = false, isStop: Boolean = false) {
val ticks = positionMs * 10_000
scope.launch(Dispatchers.IO) {
try {
when {
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks)
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused)
}
Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
} catch (e: Exception) {
Log.e("ProgressManager", "Report failed", e)
}
}
}
fun release() {
progressJob?.cancel()
activeItemId?.let { itemId ->
val ticks = lastPositionMs * 10_000
val posMs = lastPositionMs
val durMs = lastDurationMs
CoroutineScope(Dispatchers.IO).launch {
try {
jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
updateWatchProgressUseCase(itemId, posMs, durMs)
Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms")
} catch (e: Exception) {
Log.e("ProgressManager", "Report failed", e)
}
}
}
scope.cancel()
}
}

View File

@@ -0,0 +1,137 @@
package hu.bbara.purefin.core.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.core.player.model.TrackOption
import hu.bbara.purefin.core.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 isForced = (format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0
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,
forced = isForced
)
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

@@ -0,0 +1,56 @@
package hu.bbara.purefin.core.player.model
data class PlayerUiState(
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val isEnded: Boolean = false,
val isLive: Boolean = false,
val title: String? = null,
val subtitle: String? = null,
val durationMs: Long = 0L,
val positionMs: Long = 0L,
val bufferedMs: Long = 0L,
val error: String? = null,
val playbackSpeed: Float = 1f,
val chapters: List<TimedMarker> = emptyList(),
val ads: List<TimedMarker> = emptyList(),
val queue: List<QueueItemUi> = emptyList(),
val audioTracks: List<TrackOption> = emptyList(),
val textTracks: List<TrackOption> = emptyList(),
val qualityTracks: List<TrackOption> = emptyList(),
val selectedAudioTrackId: String? = null,
val selectedTextTrackId: String? = null,
val selectedQualityTrackId: String? = null,
)
data class TrackOption(
val id: String,
val label: String,
val language: String?,
val bitrate: Int?,
val channelCount: Int?,
val height: Int?,
val groupIndex: Int,
val trackIndex: Int,
val type: TrackType,
val isOff: Boolean,
val forced: Boolean = false
)
enum class TrackType { AUDIO, TEXT, VIDEO }
data class TimedMarker(
val positionMs: Long,
val type: MarkerType,
val label: String? = null
)
enum class MarkerType { CHAPTER, AD }
data class QueueItemUi(
val id: String,
val title: String,
val subtitle: String?,
val artworkUrl: String?,
val isCurrent: Boolean
)

View File

@@ -0,0 +1,10 @@
package hu.bbara.purefin.core.player.model
import android.net.Uri
import androidx.media3.common.MediaItem
data class VideoItem(
val title: String,
val mediaItem: MediaItem,
val uri: Uri
)

View File

@@ -0,0 +1,77 @@
package hu.bbara.purefin.core.player.module
import android.app.Application
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
@Module
@InstallIn(ViewModelComponent::class)
object VideoPlayerModule {
@OptIn(UnstableApi::class)
@Provides
@ViewModelScoped
fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player {
val trackSelector = DefaultTrackSelector(application)
val audioAttributes =
AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
trackSelector.setParameters(
trackSelector
.buildUponParameters()
.setTunnelingEnabled(true)
// .setPreferredAudioLanguage(
// appPreferences.getValue(appPreferences.preferredAudioLanguage)
// )
// .setPreferredTextLanguage(
// appPreferences.getValue(appPreferences.preferredSubtitleLanguage)
// )
)
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(
25_000,
55_000,
5_000,
5_000
)
.build()
// Configure RenderersFactory to use all available decoders and enable passthrough
val renderersFactory = DefaultRenderersFactory(application)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
.setEnableDecoderFallback(true)
val mediaSourceFactory = DefaultMediaSourceFactory(cacheDataSourceFactory)
return ExoPlayer.Builder(application, renderersFactory)
.setMediaSourceFactory(mediaSourceFactory)
.setTrackSelector(trackSelector)
.setPauseAtEndOfMediaItems(true)
.setLoadControl(loadControl)
.setSeekParameters(SeekParameters.PREVIOUS_SYNC)
.setAudioAttributes(audioAttributes, true)
.build()
.apply {
playWhenReady = true
pauseAtEndOfMediaItems = true
}
}
}

View File

@@ -0,0 +1,124 @@
package hu.bbara.purefin.core.player.preference
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType
import javax.inject.Inject
class TrackMatcher @Inject constructor() {
/**
* Finds the best matching audio track based on stored preferences.
* Scoring: language (3) + channelCount (2) + label (1)
* Requires minimum score of 3 (language match) to auto-select.
*
* @return The best matching TrackOption, or null if no good match found
*/
fun findBestAudioMatch(
availableTracks: List<TrackOption>,
preference: AudioTrackProperties
): TrackOption? {
if (availableTracks.isEmpty()) return null
val audioTracks = availableTracks.filter { it.type == TrackType.AUDIO }
if (audioTracks.isEmpty()) return null
val scoredTracks = audioTracks.map { track ->
track to calculateAudioScore(track, preference)
}
val bestMatch = scoredTracks.maxByOrNull { it.second }
// Require minimum score of 3 (language match) to auto-select
return if (bestMatch != null && bestMatch.second >= 3) {
bestMatch.first
} else {
null
}
}
/**
* Finds the best matching subtitle track based on stored preferences.
* Scoring: language (3) + forced (2) + label (1)
* Requires minimum score of 3 (language match) to auto-select.
* Handles "Off" preference explicitly.
*
* @return The best matching TrackOption, or the "Off" option if preference.isOff is true, or null
*/
fun findBestSubtitleMatch(
availableTracks: List<TrackOption>,
preference: SubtitleTrackProperties
): TrackOption? {
if (availableTracks.isEmpty()) return null
val subtitleTracks = availableTracks.filter { it.type == TrackType.TEXT }
if (subtitleTracks.isEmpty()) return null
// Handle "Off" preference
if (preference.isOff) {
return subtitleTracks.firstOrNull { it.isOff }
}
val scoredTracks = subtitleTracks
.filter { !it.isOff } // Exclude "Off" option when matching specific preferences
.map { track ->
track to calculateSubtitleScore(track, preference)
}
val bestMatch = scoredTracks.maxByOrNull { it.second }
// Require minimum score of 3 (language match) to auto-select
return if (bestMatch != null && bestMatch.second >= 3) {
bestMatch.first
} else {
null
}
}
private fun calculateAudioScore(
track: TrackOption,
preference: AudioTrackProperties
): Int {
var score = 0
// Language match: 3 points
if (track.language != null && track.language == preference.language) {
score += 3
}
// Channel count match: 2 points
if (track.channelCount != null && track.channelCount == preference.channelCount) {
score += 2
}
// Label match: 1 point
if (track.label == preference.label) {
score += 1
}
return score
}
private fun calculateSubtitleScore(
track: TrackOption,
preference: SubtitleTrackProperties
): Int {
var score = 0
// Language match: 3 points
if (track.language != null && track.language == preference.language) {
score += 3
}
// Forced flag match: 2 points
if (track.forced == preference.forced) {
score += 2
}
// Label match: 1 point
if (track.label == preference.label) {
score += 1
}
return score
}
}

View File

@@ -0,0 +1,30 @@
package hu.bbara.purefin.core.player.preference
import kotlinx.serialization.Serializable
@Serializable
data class TrackPreferences(
val mediaPreferences: Map<String, MediaTrackPreferences> = emptyMap()
)
@Serializable
data class MediaTrackPreferences(
val mediaId: String,
val audioPreference: AudioTrackProperties? = null,
val subtitlePreference: SubtitleTrackProperties? = null
)
@Serializable
data class AudioTrackProperties(
val language: String? = null,
val channelCount: Int? = null,
val label: String? = null
)
@Serializable
data class SubtitleTrackProperties(
val language: String? = null,
val forced: Boolean = false,
val label: String? = null,
val isOff: Boolean = false
)

View File

@@ -0,0 +1,40 @@
package hu.bbara.purefin.core.player.preference
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class TrackPreferencesModule {
@Provides
@Singleton
fun provideTrackPreferencesDataStore(
@ApplicationContext context: Context
): DataStore<TrackPreferences> {
return DataStoreFactory.create(
serializer = TrackPreferencesSerializer,
produceFile = { context.dataStoreFile("track_preferences.json") },
corruptionHandler = ReplaceFileCorruptionHandler(
produceNewData = { TrackPreferencesSerializer.defaultValue }
)
)
}
@Provides
@Singleton
fun provideTrackPreferencesRepository(
trackPreferencesDataStore: DataStore<TrackPreferences>
): TrackPreferencesRepository {
return TrackPreferencesRepository(trackPreferencesDataStore)
}
}

View File

@@ -0,0 +1,52 @@
package hu.bbara.purefin.core.player.preference
import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class TrackPreferencesRepository @Inject constructor(
private val trackPreferencesDataStore: DataStore<TrackPreferences>
) {
val preferences: Flow<TrackPreferences> = trackPreferencesDataStore.data
fun getMediaPreferences(mediaId: String): Flow<MediaTrackPreferences?> {
return preferences.map { it.mediaPreferences[mediaId] }
}
suspend fun saveAudioPreference(
mediaId: String,
properties: AudioTrackProperties
) {
trackPreferencesDataStore.updateData { current ->
val existingMediaPrefs = current.mediaPreferences[mediaId]
val updatedMediaPrefs = existingMediaPrefs?.copy(audioPreference = properties)
?: MediaTrackPreferences(
mediaId = mediaId,
audioPreference = properties
)
current.copy(
mediaPreferences = current.mediaPreferences + (mediaId to updatedMediaPrefs)
)
}
}
suspend fun saveSubtitlePreference(
mediaId: String,
properties: SubtitleTrackProperties
) {
trackPreferencesDataStore.updateData { current ->
val existingMediaPrefs = current.mediaPreferences[mediaId]
val updatedMediaPrefs = existingMediaPrefs?.copy(subtitlePreference = properties)
?: MediaTrackPreferences(
mediaId = mediaId,
subtitlePreference = properties
)
current.copy(
mediaPreferences = current.mediaPreferences + (mediaId to updatedMediaPrefs)
)
}
}
}

View File

@@ -0,0 +1,30 @@
package hu.bbara.purefin.core.player.preference
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.io.InputStream
import java.io.OutputStream
object TrackPreferencesSerializer : Serializer<TrackPreferences> {
override val defaultValue: TrackPreferences
get() = TrackPreferences()
override suspend fun readFrom(input: InputStream): TrackPreferences {
try {
return Json.decodeFromString<TrackPreferences>(
input.readBytes().decodeToString()
)
} catch (serialization: SerializationException) {
throw CorruptionException("Unable to read TrackPreferences", serialization)
}
}
override suspend fun writeTo(t: TrackPreferences, output: OutputStream) {
output.write(
Json.encodeToString(TrackPreferences.serializer(), t)
.encodeToByteArray()
)
}
}

View File

@@ -0,0 +1,4 @@
package hu.bbara.purefin.core.player.stream
class MediaSourceSelector {
}

View File

@@ -0,0 +1,248 @@
package hu.bbara.purefin.core.player.viewmodel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.player.data.PlayerMediaRepository
import hu.bbara.purefin.core.player.manager.MediaContext
import hu.bbara.purefin.core.player.manager.PlayerManager
import hu.bbara.purefin.core.player.manager.ProgressManager
import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.TrackOption
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.launch
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class PlayerViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val playerManager: PlayerManager,
private val playerMediaRepository: PlayerMediaRepository,
private val mediaRepository: MediaRepository,
private val progressManager: ProgressManager
) : ViewModel() {
val player get() = playerManager.player
private val mediaId: String? = savedStateHandle["MEDIA_ID"]
private val _uiState = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
private val _controlsVisible = MutableStateFlow(true)
val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow()
private var autoHideJob: Job? = null
private var lastNextUpMediaId: String? = null
private var dataErrorMessage: String? = null
init {
progressManager.bind(
playerManager.playbackState,
playerManager.progress,
playerManager.metadata
)
observePlayerState()
loadInitialMedia()
}
private fun observePlayerState() {
viewModelScope.launch {
playerManager.playbackState.collect { state ->
_uiState.update {
it.copy(
isPlaying = state.isPlaying,
isBuffering = state.isBuffering,
isEnded = state.isEnded,
error = state.error ?: dataErrorMessage
)
}
if (state.isEnded) {
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 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 result = playerMediaRepository.getMediaItem(uuid)
if (result != null) {
val (mediaItem, resumePositionMs) = result
// Determine preference key: movies use their own ID, episodes use series ID
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey)
playerManager.play(mediaItem, mediaContext)
// Seek to resume position after play() is called
resumePositionMs?.let { playerManager.seekTo(it) }
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 = playerMediaRepository.getNextUpMediaItems(
episodeId = uuid,
existingIds = queuedIds
)
items.forEach { playerManager.addToQueue(it) }
}
}
fun togglePlayPause() {
playerManager.togglePlayPause()
showControls()
}
fun seekTo(positionMs: Long) {
playerManager.seekTo(positionMs)
}
fun seekBy(deltaMs: Long) {
playerManager.seekBy(deltaMs)
}
fun seekToLiveEdge() {
playerManager.seekToLiveEdge()
}
fun showControls() {
_controlsVisible.value = true
scheduleAutoHide()
}
fun toggleControlsVisibility() {
_controlsVisible.value = !_controlsVisible.value
if (_controlsVisible.value) scheduleAutoHide()
}
private fun scheduleAutoHide() {
autoHideJob?.cancel()
if (!player.isPlaying) return
autoHideJob = viewModelScope.launch {
delay(3500)
_controlsVisible.value = false
}
}
fun next() {
playerManager.next()
showControls()
}
fun previous() {
playerManager.previous()
showControls()
}
fun selectTrack(option: TrackOption) {
playerManager.selectTrack(option)
}
fun setPlaybackSpeed(speed: Float) {
playerManager.setPlaybackSpeed(speed)
_uiState.update { it.copy(playbackSpeed = speed) }
}
fun retry() {
playerManager.retry()
}
fun playQueueItem(id: String) {
playerManager.playQueueItem(id)
showControls()
}
fun clearError() {
dataErrorMessage = null
playerManager.clearError()
_uiState.update { it.copy(error = null) }
}
override fun onCleared() {
super.onCleared()
autoHideJob?.cancel()
progressManager.release()
playerManager.release()
}
private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull()
}