From 798e95da0d9e2d01ad7d8f3366b315645daab901 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 15 Feb 2026 20:38:12 +0100 Subject: [PATCH] feat: implement track preference storage and auto-selection Add persistent storage for audio and subtitle track preferences with automatic selection on playback. Track preferences are stored by matching semantic properties (language, channel count, forced flag) rather than track IDs. Key features: - Movies remember individual audio/subtitle selections - Series share preferences across all episodes (by series ID) - Property-based matching with scoring algorithm - DataStore persistence with kotlinx.serialization - Graceful fallback to Media3 defaults when no match found Implementation: - Created TrackPreferences data layer with DataStore serialization - Added TrackMatcher for property-based track matching - Enhanced TrackOption model with forced flag for subtitles - Integrated auto-selection into PlayerManager on tracks available - Save preferences on manual track selection - PlayerViewModel determines media type and constructs preference key --- ...Repository.kt => PlayerMediaRepository.kt} | 2 +- .../purefin/player/manager/PlayerManager.kt | 81 +++++++++++- .../purefin/player/manager/TrackMapper.kt | 4 +- .../purefin/player/model/PlayerUiModels.kt | 3 +- .../purefin/player/preference/TrackMatcher.kt | 124 ++++++++++++++++++ .../player/preference/TrackPreference.kt | 30 +++++ .../preference/TrackPreferencesModule.kt | 40 ++++++ .../preference/TrackPreferencesRepository.kt | 52 ++++++++ .../preference/TrackPreferencesSerializer.kt | 30 +++++ .../player/viewmodel/PlayerViewModel.kt | 15 ++- 10 files changed, 372 insertions(+), 9 deletions(-) rename app/src/main/java/hu/bbara/purefin/player/data/{MediaRepository.kt => PlayerMediaRepository.kt} (98%) create mode 100644 app/src/main/java/hu/bbara/purefin/player/preference/TrackMatcher.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/preference/TrackPreference.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesModule.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesRepository.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesSerializer.kt diff --git a/app/src/main/java/hu/bbara/purefin/player/data/MediaRepository.kt b/app/src/main/java/hu/bbara/purefin/player/data/PlayerMediaRepository.kt similarity index 98% rename from app/src/main/java/hu/bbara/purefin/player/data/MediaRepository.kt rename to app/src/main/java/hu/bbara/purefin/player/data/PlayerMediaRepository.kt index 6dfc7b0..87ccd94 100644 --- a/app/src/main/java/hu/bbara/purefin/player/data/MediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/player/data/PlayerMediaRepository.kt @@ -17,7 +17,7 @@ import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.ImageType @ViewModelScoped -class MediaRepository @Inject constructor( +class PlayerMediaRepository @Inject constructor( private val jellyfinApiClient: JellyfinApiClient, private val userSessionRepository: UserSessionRepository ) { diff --git a/app/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt b/app/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt index 0b47019..e392482 100644 --- a/app/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt +++ b/app/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt @@ -12,6 +12,10 @@ 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 hu.bbara.purefin.player.preference.AudioTrackProperties +import hu.bbara.purefin.player.preference.SubtitleTrackProperties +import hu.bbara.purefin.player.preference.TrackMatcher +import hu.bbara.purefin.player.preference.TrackPreferencesRepository import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -21,6 +25,7 @@ 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 @@ -32,11 +37,15 @@ import kotlinx.coroutines.launch @OptIn(UnstableApi::class) class PlayerManager @Inject constructor( val player: Player, - private val trackMapper: TrackMapper + 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 = _playbackState.asStateFlow() @@ -76,6 +85,9 @@ class PlayerManager @Inject constructor( override fun onTracksChanged(tracks: Tracks) { refreshTracks(tracks) + scope.launch { + applyTrackPreferences() + } } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { @@ -92,7 +104,8 @@ class PlayerManager @Inject constructor( startProgressLoop() } - fun play(mediaItem: MediaItem) { + fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) { + currentMediaContext = mediaContext player.setMediaItem(mediaItem) player.prepare() player.playWhenReady = true @@ -173,6 +186,13 @@ class PlayerManager @Inject constructor( } 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) { @@ -198,6 +218,58 @@ class PlayerManager @Inject constructor( _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 + } + } + } + fun release() { scope.cancel() player.removeListener(listener) @@ -270,3 +342,8 @@ data class MetadataState( val title: String? = null, val subtitle: String? = null ) + +data class MediaContext( + val mediaId: String, + val preferenceKey: String +) diff --git a/app/src/main/java/hu/bbara/purefin/player/manager/TrackMapper.kt b/app/src/main/java/hu/bbara/purefin/player/manager/TrackMapper.kt index 10ac64b..3941afc 100644 --- a/app/src/main/java/hu/bbara/purefin/player/manager/TrackMapper.kt +++ b/app/src/main/java/hu/bbara/purefin/player/manager/TrackMapper.kt @@ -63,6 +63,7 @@ class TrackMapper @Inject constructor() { 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, @@ -73,7 +74,8 @@ class TrackMapper @Inject constructor() { groupIndex = groupIndex, trackIndex = trackIndex, type = TrackType.TEXT, - isOff = false + isOff = false, + forced = isForced ) text.add(option) if (group.isTrackSelected(trackIndex)) selectedText = id diff --git a/app/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt b/app/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt index d02a450..c96592d 100644 --- a/app/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt +++ b/app/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt @@ -33,7 +33,8 @@ data class TrackOption( val groupIndex: Int, val trackIndex: Int, val type: TrackType, - val isOff: Boolean + val isOff: Boolean, + val forced: Boolean = false ) enum class TrackType { AUDIO, TEXT, VIDEO } diff --git a/app/src/main/java/hu/bbara/purefin/player/preference/TrackMatcher.kt b/app/src/main/java/hu/bbara/purefin/player/preference/TrackMatcher.kt new file mode 100644 index 0000000..1def1b0 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/preference/TrackMatcher.kt @@ -0,0 +1,124 @@ +package hu.bbara.purefin.player.preference + +import hu.bbara.purefin.player.model.TrackOption +import hu.bbara.purefin.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, + 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, + 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 + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/preference/TrackPreference.kt b/app/src/main/java/hu/bbara/purefin/player/preference/TrackPreference.kt new file mode 100644 index 0000000..2f1b5b4 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/preference/TrackPreference.kt @@ -0,0 +1,30 @@ +package hu.bbara.purefin.player.preference + +import kotlinx.serialization.Serializable + +@Serializable +data class TrackPreferences( + val mediaPreferences: Map = 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 +) diff --git a/app/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesModule.kt b/app/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesModule.kt new file mode 100644 index 0000000..7f46b71 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesModule.kt @@ -0,0 +1,40 @@ +package hu.bbara.purefin.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 { + return DataStoreFactory.create( + serializer = TrackPreferencesSerializer, + produceFile = { context.dataStoreFile("track_preferences.json") }, + corruptionHandler = ReplaceFileCorruptionHandler( + produceNewData = { TrackPreferencesSerializer.defaultValue } + ) + ) + } + + @Provides + @Singleton + fun provideTrackPreferencesRepository( + trackPreferencesDataStore: DataStore + ): TrackPreferencesRepository { + return TrackPreferencesRepository(trackPreferencesDataStore) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesRepository.kt b/app/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesRepository.kt new file mode 100644 index 0000000..bc810a0 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesRepository.kt @@ -0,0 +1,52 @@ +package hu.bbara.purefin.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 +) { + val preferences: Flow = trackPreferencesDataStore.data + + fun getMediaPreferences(mediaId: String): Flow { + 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) + ) + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesSerializer.kt b/app/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesSerializer.kt new file mode 100644 index 0000000..6e58bc1 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesSerializer.kt @@ -0,0 +1,30 @@ +package hu.bbara.purefin.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 { + override val defaultValue: TrackPreferences + get() = TrackPreferences() + + override suspend fun readFrom(input: InputStream): TrackPreferences { + try { + return Json.decodeFromString( + 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() + ) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt b/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt index a71ec09..50f4804 100644 --- a/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt @@ -4,7 +4,9 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import hu.bbara.purefin.player.data.MediaRepository +import hu.bbara.purefin.player.data.PlayerMediaRepository +import hu.bbara.purefin.data.MediaRepository +import hu.bbara.purefin.player.manager.MediaContext import hu.bbara.purefin.player.manager.PlayerManager import hu.bbara.purefin.player.manager.ProgressManager import hu.bbara.purefin.player.model.PlayerUiState @@ -23,6 +25,7 @@ import javax.inject.Inject class PlayerViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val playerManager: PlayerManager, + private val playerMediaRepository: PlayerMediaRepository, private val mediaRepository: MediaRepository, private val progressManager: ProgressManager ) : ViewModel() { @@ -133,11 +136,15 @@ class PlayerViewModel @Inject constructor( return } viewModelScope.launch { - val result = mediaRepository.getMediaItem(uuid) + val result = playerMediaRepository.getMediaItem(uuid) if (result != null) { val (mediaItem, resumePositionMs) = result - playerManager.play(mediaItem) + // 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) } @@ -157,7 +164,7 @@ class PlayerViewModel @Inject constructor( val uuid = currentMediaId.toUuidOrNull() ?: return viewModelScope.launch { val queuedIds = uiState.value.queue.map { it.id }.toSet() - val items = mediaRepository.getNextUpMediaItems( + val items = playerMediaRepository.getNextUpMediaItems( episodeId = uuid, existingIds = queuedIds )