mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
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:
@@ -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
|
||||
) {
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user