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 )