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
This commit is contained in:
2026-02-15 20:38:12 +01:00
parent 9e4a9c64cc
commit 798e95da0d
10 changed files with 372 additions and 9 deletions

View File

@@ -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
) {

View File

@@ -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<PlaybackStateSnapshot> = _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
)

View File

@@ -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

View File

@@ -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 }

View File

@@ -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<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.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.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.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.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

@@ -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
)