From f40c1c355fa317c889c5f248f1860567c3e35358 Mon Sep 17 00:00:00 2001 From: Adhish Mathur Date: Wed, 4 Mar 2026 01:38:06 +0530 Subject: [PATCH 1/2] feat(player): add support for Jellyfin Merge Versions plugin This commit introduces full support for selecting and playing different media versions grouped by the Jellyfin Merge Versions plugin. Key Features: - Pre-play Version Picker: Intercepts the primary Play action on the ItemDetailScreen. If an item contains multiple remote sources (versions), it prompts the user with a newly created VersionPickerDialog to select their preferred version before launching the player. - In-Player Version Switching: Added a Versions button to the PlayerControls interface (visible only when multiple sources exist). Users can seamlessly switch between versions mid-playback via the new VersionPickerSheet, which seamlessly resumes playback from the current position. - Sticky Autoplay Logic: Re-architected auto-play logic within PlayerViewModel (loadMedia and playQueueItem). Autoplaying the next episode now intelligently selects the source that best matches the previously played version (matching by source name, then video height) across fetched items. Data Layer & Architecture: - Enriched the AfinitySource data model to extract and store display-oriented fields from Jellyfin's MediaSourceInfo (bitrate, container, audioCodec, videoCodec, width, height). - Introduced PlayerEvent.SwitchVersion to orchestrate on-the-fly source switching. - Refactored PlayerViewModel to manage the lifecycle and UI state of version pickers natively. --- .../data/models/media/AfinitySource.kt | 20 +++ .../afinity/data/models/player/PlayerState.kt | 4 + .../makd/afinity/ui/item/ItemDetailScreen.kt | 95 +++++++---- .../ui/item/components/VersionPickerDialog.kt | 143 ++++++++++++++++ .../makd/afinity/ui/player/PlayerScreen.kt | 26 --- .../makd/afinity/ui/player/PlayerViewModel.kt | 94 ++++++++++- .../ui/player/components/PlayerControls.kt | 41 +++++ .../player/components/VersionPickerSheet.kt | 157 ++++++++++++++++++ app/src/main/res/values/strings.xml | 4 + 9 files changed, 518 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/com/makd/afinity/ui/item/components/VersionPickerDialog.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/player/components/VersionPickerSheet.kt diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinitySource.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinitySource.kt index 85beb948..417b44f1 100644 --- a/app/src/main/java/com/makd/afinity/data/models/media/AfinitySource.kt +++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinitySource.kt @@ -5,6 +5,7 @@ import com.makd.afinity.data.database.entities.AfinitySourceDto import com.makd.afinity.data.repository.JellyfinRepository import java.io.File import java.util.UUID +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaProtocol import org.jellyfin.sdk.model.api.MediaSourceInfo @@ -16,6 +17,13 @@ data class AfinitySource( val size: Long, val mediaStreams: List, val downloadId: Long? = null, + // Version display metadata + val bitrate: Long? = null, + val container: String? = null, + val videoCodec: String? = null, + val audioCodec: String? = null, + val width: Int? = null, + val height: Int? = null, ) suspend fun MediaSourceInfo.toAfinitySource( @@ -35,6 +43,12 @@ suspend fun MediaSourceInfo.toAfinitySource( MediaProtocol.HTTP -> this.path.orEmpty() else -> "" } + val videoStream = mediaStreams?.firstOrNull { + it.type == MediaStreamType.VIDEO + } + val audioStream = mediaStreams?.firstOrNull { + it.type == MediaStreamType.AUDIO + } return AfinitySource( id = id.orEmpty(), name = name.orEmpty(), @@ -43,6 +57,12 @@ suspend fun MediaSourceInfo.toAfinitySource( size = size ?: 0, mediaStreams = mediaStreams?.map { it.toAfinityMediaStream(jellyfinRepository) } ?: emptyList(), + bitrate = bitrate?.toLong(), + container = container, + videoCodec = videoStream?.codec, + audioCodec = audioStream?.codec, + width = videoStream?.width, + height = videoStream?.height, ) } diff --git a/app/src/main/java/com/makd/afinity/data/models/player/PlayerState.kt b/app/src/main/java/com/makd/afinity/data/models/player/PlayerState.kt index a2d4f5c8..a6e9d282 100644 --- a/app/src/main/java/com/makd/afinity/data/models/player/PlayerState.kt +++ b/app/src/main/java/com/makd/afinity/data/models/player/PlayerState.kt @@ -86,6 +86,10 @@ sealed class PlayerEvent { data object CycleScreenRotation : PlayerEvent() data object RequestCastDeviceSelection : PlayerEvent() + + data class SwitchVersion(val mediaSourceId: String) : PlayerEvent() + + data object ToggleVersionPicker : PlayerEvent() } data class GestureConfig( diff --git a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt index d9529ad3..996a02f5 100644 --- a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt @@ -37,7 +37,9 @@ import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur @@ -72,6 +74,7 @@ import com.makd.afinity.data.models.media.AfinityItem import com.makd.afinity.data.models.media.AfinityMovie import com.makd.afinity.data.models.media.AfinitySeason import com.makd.afinity.data.models.media.AfinityShow +import com.makd.afinity.data.models.media.AfinitySourceType import com.makd.afinity.data.models.media.AfinityVideo import com.makd.afinity.data.models.tmdb.TmdbReview import com.makd.afinity.navigation.Destination @@ -86,6 +89,7 @@ import com.makd.afinity.ui.item.components.QualitySelectionDialog import com.makd.afinity.ui.item.components.SeasonDetailContent import com.makd.afinity.ui.item.components.SeasonsSection import com.makd.afinity.ui.item.components.TaglineSection +import com.makd.afinity.ui.item.components.VersionPickerDialog import com.makd.afinity.ui.item.components.WriterSection import com.makd.afinity.ui.item.components.shared.CastSection import com.makd.afinity.ui.item.components.shared.ExternalLinksSection @@ -124,6 +128,27 @@ fun ItemDetailScreen( val selectedEpisodeDownloadInfo by viewModel.selectedEpisodeDownloadInfo.collectAsStateWithLifecycle() + // Pre-play version picker state. When an item has multiple merged versions we intercept + // the play action and show a VersionPickerSheet before launching the player. + var pendingPlayItem by remember { mutableStateOf(null) } + var pendingPlaySelection by remember { mutableStateOf(null) } + var showVersionPickerForPlay by remember { mutableStateOf(false) } + + // Intercepts play requests: shows the version picker first if the item has > 1 source, + // otherwise fires onPlayClick immediately. + fun interceptPlayClick(item: AfinityItem, selection: PlaybackSelection?) { + val remoteSources = item.sources.filter { + it.type == com.makd.afinity.data.models.media.AfinitySourceType.REMOTE + } + if (remoteSources.size > 1) { + pendingPlayItem = item + pendingPlaySelection = selection + showVersionPickerForPlay = true + } else { + onPlayClick(item, selection) + } + } + Box(modifier = modifier.fillMaxSize()) { when { uiState.isLoading -> { @@ -171,7 +196,7 @@ fun ItemDetailScreen( episodesPagingData = uiState.episodesPagingData, downloadInfo = uiState.downloadInfo, tmdbReviews = uiState.tmdbReviews, - onPlayClick = { item, selection -> onPlayClick(item, selection) }, + onPlayClick = { item, selection -> interceptPlayClick(item, selection) }, onBoxSetItemClick = { item -> if (item is AfinityEpisode) { viewModel.selectEpisode(item) @@ -198,18 +223,7 @@ fun ItemDetailScreen( onDismiss = { viewModel.clearSelectedEpisode() }, onPlayClick = { episodeToPlay, selection -> viewModel.clearSelectedEpisode() - - val seasonId = (uiState.item as? AfinitySeason)?.id - - PlayerLauncher.launch( - context = context, - itemId = episodeToPlay.id, - mediaSourceId = selection.mediaSourceId, - audioStreamIndex = selection.audioStreamIndex, - subtitleStreamIndex = selection.subtitleStreamIndex, - seasonId = seasonId, - startPositionMs = selection.startPositionMs, - ) + interceptPlayClick(episodeToPlay, selection) }, onToggleFavorite = { viewModel.toggleEpisodeFavorite(episode) }, onToggleWatchlist = { viewModel.toggleEpisodeWatchlist(episode) }, @@ -236,6 +250,38 @@ fun ItemDetailScreen( ) } } + + // Pre-play version picker: shown when tapping Play on an item with multiple merged versions. + if (showVersionPickerForPlay) { + val item = pendingPlayItem + if (item != null) { + val remoteSources = item.sources.filter { + it.type == com.makd.afinity.data.models.media.AfinitySourceType.REMOTE + } + VersionPickerDialog( + sources = remoteSources, + onVersionSelected = { source -> + showVersionPickerForPlay = false + val finalSelection = pendingPlaySelection?.copy( + mediaSourceId = source.id, + ) ?: PlaybackSelection( + mediaSourceId = source.id, + audioStreamIndex = null, + subtitleStreamIndex = null, + videoStreamIndex = null, + ) + onPlayClick(item, finalSelection) + pendingPlayItem = null + pendingPlaySelection = null + }, + onDismiss = { + showVersionPickerForPlay = false + pendingPlayItem = null + pendingPlaySelection = null + }, + ) + } + } } } @@ -807,18 +853,7 @@ private fun LandscapeItemDetailContent( selectedMediaSource?.id ?: selection.mediaSourceId ) - PlayerLauncher.launch( - context = navController.context, - itemId = item.id, - mediaSourceId = - finalSelection.mediaSourceId, - audioStreamIndex = - finalSelection.audioStreamIndex, - subtitleStreamIndex = - finalSelection.subtitleStreamIndex, - startPositionMs = - finalSelection.startPositionMs, - ) + onPlayClick(item, finalSelection) }, ) } @@ -1494,15 +1529,7 @@ private fun PortraitItemDetailContent( selectedMediaSource?.id ?: selection.mediaSourceId ) - PlayerLauncher.launch( - context = navController.context, - itemId = item.id, - mediaSourceId = finalSelection.mediaSourceId, - audioStreamIndex = finalSelection.audioStreamIndex, - subtitleStreamIndex = - finalSelection.subtitleStreamIndex, - startPositionMs = finalSelection.startPositionMs, - ) + onPlayClick(item, finalSelection) }, ) } diff --git a/app/src/main/java/com/makd/afinity/ui/item/components/VersionPickerDialog.kt b/app/src/main/java/com/makd/afinity/ui/item/components/VersionPickerDialog.kt new file mode 100644 index 00000000..e258e2fa --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/item/components/VersionPickerDialog.kt @@ -0,0 +1,143 @@ +package com.makd.afinity.ui.item.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.makd.afinity.R +import com.makd.afinity.data.models.media.AfinitySource + +/** + * A card-style dialog for selecting a media version before playback starts. + * Used from [ItemDetailScreen] when an item has multiple merged versions. + */ +@Composable +fun VersionPickerDialog( + sources: List, + onVersionSelected: (AfinitySource) -> Unit, + onDismiss: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = stringResource(R.string.player_version_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.version_dialog_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f, fill = false) + ) { + items(sources) { source -> + VersionOption( + source = source, + isSelected = false, + onSelect = { onVersionSelected(source) }, + ) + } + } + } + } + } +} + +@Composable +private fun VersionOption( + source: AfinitySource, + isSelected: Boolean, + onSelect: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth().clickable(onClick = onSelect), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 0.dp, + ) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + val label = + source.name.takeIf { it.isNotBlank() && it != "Default" } + ?: stringResource(R.string.player_version_default_label) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + + val details = buildVersionDetail(source) + if (details.isNotBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = details, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +private fun buildVersionDetail(source: AfinitySource): String { + val parts = mutableListOf() + val h = source.height + val w = source.width + if (h != null && h > 0) { + parts += when { + h > 2160 -> "8K" + h > 1080 -> "4K" + h > 720 -> "1080p" + h > 480 -> "720p" + else -> "${h}p" + } + } else if (w != null && w > 0) { + parts += "${w}×?" + } + source.videoCodec?.uppercase()?.let { parts += it } + source.audioCodec?.uppercase()?.let { parts += it } + val bitrate = source.bitrate + if (bitrate != null && bitrate > 0) { + parts += "${bitrate / 1_000_000} Mbps" + } + return parts.joinToString(" · ") +} diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt index a6a3b5bd..3ab841b7 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt @@ -120,33 +120,7 @@ fun PlayerScreen( } } - LaunchedEffect(navController) { - viewModel.setAutoplayCallback { nextItem -> - try { - nextItem.sources.forEachIndexed { index, source -> - Timber.d("Source $index: ${source.id} (${source.type})") - } - val mediaSourceId = nextItem.sources.firstOrNull()?.id - if (mediaSourceId == null) { - Timber.e("No media source available for next item: ${nextItem.name}") - return@setAutoplayCallback - } - - viewModel.handlePlayerEvent( - PlayerEvent.LoadMedia( - item = nextItem, - mediaSourceId = mediaSourceId, - audioStreamIndex = null, - subtitleStreamIndex = null, - startPositionMs = 0L, - ) - ) - } catch (e: Exception) { - Timber.e(e, "Failed to load next item: ${nextItem.name}") - } - } - } var hasNavigatedBack by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt index 9e9b5d17..17e3d56f 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt @@ -36,6 +36,7 @@ import com.makd.afinity.data.models.media.AfinityImages import com.makd.afinity.data.models.media.AfinityItem import com.makd.afinity.data.models.media.AfinitySegment import com.makd.afinity.data.models.media.AfinitySegmentType +import com.makd.afinity.data.models.media.AfinitySource import com.makd.afinity.data.models.media.AfinitySourceType import com.makd.afinity.data.models.player.GestureConfig import com.makd.afinity.data.models.player.PlayerEvent @@ -71,6 +72,8 @@ import org.jellyfin.sdk.model.api.MediaStreamType import timber.log.Timber import java.util.UUID import javax.inject.Inject +import com.makd.afinity.data.models.media.AfinityMovie +import com.makd.afinity.data.models.media.AfinitySources @androidx.media3.common.util.UnstableApi @HiltViewModel @@ -121,7 +124,6 @@ constructor( private var isVideoPortrait: Boolean = false private var isOrientationOverridden: Boolean = false - var onAutoplayNextEpisode: ((AfinityItem) -> Unit)? = null var enterPictureInPicture: (() -> Unit)? = null var updatePipParams: (() -> Unit)? = null private val screenAspectRatio: Float by lazy { @@ -511,7 +513,7 @@ constructor( val nextItem = playlistManager.next() if (nextItem != null) { Timber.d("Episode ended, auto-advancing to: ${nextItem.name}") - onAutoplayNextEpisode?.invoke(nextItem) + playQueueItem(nextItem) } else { Timber.d("Episode ended, no next item in queue") } @@ -701,6 +703,28 @@ constructor( is PlayerEvent.RequestCastDeviceSelection -> { updateUiState { it.copy(showCastChooser = true) } } + + is PlayerEvent.ToggleVersionPicker -> { + updateUiState { it.copy(showVersionPicker = !it.showVersionPicker) } + } + + is PlayerEvent.SwitchVersion -> { + val item = currentItem ?: return@launch + if (event.mediaSourceId == _uiState.value.currentMediaSourceId) { + // same version, just close picker + updateUiState { it.copy(showVersionPicker = false) } + return@launch + } + val resumePosition = player.currentPosition + updateUiState { it.copy(isLoading = true, showVersionPicker = false) } + loadMedia( + item = item, + mediaSourceId = event.mediaSourceId, + audioStreamIndex = null, + subtitleStreamIndex = null, + startPositionMs = resumePosition, + ) + } } } } @@ -776,6 +800,10 @@ constructor( ) { stopAudiobookshelfIfPlaying() try { + // Capture previous source before we overwrite currentItem + val previousSourceId = _uiState.value.currentMediaSourceId + val previousSource = currentItem?.sources?.firstOrNull { it.id == previousSourceId } + val fullItem: AfinityItem = if (item.sources.isEmpty()) { Timber.d("Item ${item.name} has no sources, fetching full details...") @@ -793,9 +821,16 @@ constructor( val chapters = fullItem.chapters updateUiState { it.copy(chapters = chapters) } + + val finalMediaSourceId = if (mediaSourceId.isBlank() && fullItem.sources.isNotEmpty()) { + findBestMatchingSource(previousSource, fullItem.sources)?.id ?: "" + } else { + mediaSourceId + } + val mediaSource = fullItem.sources.firstOrNull { - if (mediaSourceId.isBlank()) true else it.id == mediaSourceId + if (finalMediaSourceId.isBlank()) true else it.id == finalMediaSourceId } ?: fullItem.sources.firstOrNull() val actualMediaSourceId = mediaSource?.id @@ -842,6 +877,8 @@ constructor( currentItem = fullItem, audioStreamIndex = audioPosition, subtitleStreamIndex = subtitleStreamIndex, + availableSources = fullItem.sources, + currentMediaSourceId = actualMediaSourceId, ) } currentSessionId = UUID.randomUUID().toString() @@ -1347,8 +1384,37 @@ constructor( playlistManager.clearQueue() } - fun setAutoplayCallback(callback: (AfinityItem) -> Unit) { - onAutoplayNextEpisode = callback + /** + * Given a list of [candidates] (sources of the next episode), returns the one that best + * matches [reference] (the source currently playing). Matching priority: + * 1. Exact name match (case-insensitive) — most reliable when Merge Versions plugin is used, + * as it propagates the folder/file name as the source name (e.g. "1080p BluRay"). + * 2. Resolution (height) match — catches cases where names differ slightly. + * 3. First candidate — default fallback. + */ + fun findBestMatchingSource( + reference: AfinitySource?, + candidates: List, + ): AfinitySource? { + if (candidates.isEmpty()) return null + if (reference == null) return candidates.first() + + // 1. Name match + val refName = reference.name.trim().lowercase() + if (refName.isNotBlank() && refName != "default") { + candidates.firstOrNull { it.name.trim().lowercase() == refName } + ?.let { return it } + } + + // 2. Resolution match (height) + val refHeight = reference.height + if (refHeight != null && refHeight > 0) { + candidates.firstOrNull { it.height == refHeight } + ?.let { return it } + } + + // 3. Fallback + return candidates.first() } private fun toggleControls() { @@ -1452,10 +1518,22 @@ constructor( } private fun playQueueItem(item: AfinityItem) { + val currentSourceId = _uiState.value.currentMediaSourceId + val currentSource = _uiState.value.currentItem + ?.sources + ?.firstOrNull { it.id == currentSourceId } + + val bestMatch = findBestMatchingSource( + reference = currentSource, + candidates = item.sources, + ) + + val mediaSourceId = bestMatch?.id ?: item.sources.firstOrNull()?.id ?: "" + handlePlayerEvent( PlayerEvent.LoadMedia( item = item, - mediaSourceId = item.sources.firstOrNull()?.id ?: "", + mediaSourceId = mediaSourceId, audioStreamIndex = null, subtitleStreamIndex = null, startPositionMs = 0L, @@ -1663,6 +1741,10 @@ constructor( val isCasting: Boolean = false, val showCastChooser: Boolean = false, val isSpeedingUp: Boolean = false, + // Version picker + val availableSources: List = emptyList(), + val currentMediaSourceId: String? = null, + val showVersionPicker: Boolean = false, ) } diff --git a/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt b/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt index 3ed1ae49..766ec382 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt @@ -108,6 +108,7 @@ fun PlayerControls( var showSubtitleSelector by remember { mutableStateOf(false) } var showSpeedDialog by remember { mutableStateOf(false) } var showEpisodeSwitcher by remember { mutableStateOf(false) } + var showVersionSelector by remember { mutableStateOf(false) } val currentItem = uiState.currentItem @@ -330,6 +331,8 @@ fun PlayerControls( onEpisodeSwitcherToggle = { showEpisodeSwitcher = !showEpisodeSwitcher }, showEpisodeSwitcherButton = currentItem is AfinityEpisode && playlistQueue.size > 1, + onVersionToggle = { showVersionSelector = !showVersionSelector }, + showVersionButton = uiState.availableSources.size > 1, modifier = Modifier.align(Alignment.BottomCenter), ) } @@ -583,6 +586,28 @@ fun PlayerControls( onDismiss = { showEpisodeSwitcher = false }, ) } + + if (showVersionSelector && uiState.availableSources.size > 1) { + Box( + modifier = + Modifier.fillMaxSize().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + showVersionSelector = false + } + ) { + VersionPickerSheet( + sources = uiState.availableSources, + currentSourceId = uiState.currentMediaSourceId, + onVersionSelected = { source -> + onPlayerEvent(PlayerEvent.SwitchVersion(source.id)) + showVersionSelector = false + }, + onDismiss = { showVersionSelector = false }, + ) + } + } } @OptIn(UnstableApi::class) @@ -791,6 +816,8 @@ private fun BottomControls( onSubtitleToggle: () -> Unit, onEpisodeSwitcherToggle: () -> Unit = {}, showEpisodeSwitcherButton: Boolean = false, + onVersionToggle: () -> Unit = {}, + showVersionButton: Boolean = false, ) { Box( modifier = @@ -816,6 +843,20 @@ private fun BottomControls( verticalAlignment = Alignment.CenterVertically, ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (showVersionButton) { + IconButton( + onClick = onVersionToggle, + modifier = Modifier.size(40.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_versions), + contentDescription = stringResource(R.string.cd_version_selector), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + } + } + if (showEpisodeSwitcherButton) { IconButton( onClick = onEpisodeSwitcherToggle, diff --git a/app/src/main/java/com/makd/afinity/ui/player/components/VersionPickerSheet.kt b/app/src/main/java/com/makd/afinity/ui/player/components/VersionPickerSheet.kt new file mode 100644 index 00000000..b2e28a5b --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/player/components/VersionPickerSheet.kt @@ -0,0 +1,157 @@ +package com.makd.afinity.ui.player.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.makd.afinity.R +import com.makd.afinity.data.models.media.AfinitySource +import java.util.Locale + +/** + * Floating overlay (matching the existing audio/subtitle selector style) that lists + * all available media versions for a merged-versions item. Used inside the player + * and triggered from [PlayerControls]. + */ +@Composable +fun VersionPickerSheet( + sources: List, + currentSourceId: String?, + onVersionSelected: (AfinitySource) -> Unit, + onDismiss: () -> Unit, +) { + Box( + modifier = + Modifier.fillMaxWidth().padding(bottom = 110.dp, end = 56.dp), + contentAlignment = Alignment.BottomEnd, + ) { + Box( + modifier = + Modifier + .clickable(onClick = {}) // consume — do not propagate + .background(Color.Black.copy(alpha = 0.95f), RoundedCornerShape(8.dp)) + .padding(12.dp) + .width(240.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = stringResource(R.string.player_version_title), + style = MaterialTheme.typography.titleSmall, + color = Color.White, + modifier = Modifier.padding(bottom = 6.dp), + ) + LazyColumn( + contentPadding = PaddingValues(bottom = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(sources, key = { it.id }) { source -> + val isSelected = source.id == currentSourceId + VersionItem( + source = source, + isSelected = isSelected, + onClick = { + onVersionSelected(source) + onDismiss() + }, + ) + } + } + } + } + } +} + +@Composable +private fun VersionItem( + source: AfinitySource, + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background( + if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + else Color.Transparent + ) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSelected) { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + } else { + Spacer(Modifier.width(22.dp)) + } + + Column(modifier = Modifier.weight(1f)) { + // Primary label — plugin sets name from folder/file structure e.g. "1080p BluRay" + Text( + text = source.name.ifBlank { stringResource(R.string.player_version_default_label) }, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = Color.White, + maxLines = 1, + ) + + // Sub-label with tech details + val sub = buildVersionSubLabel(source) + if (sub.isNotBlank()) { + Text( + text = sub, + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.6f), + fontSize = 10.sp, + maxLines = 1, + ) + } + } + } +} + +private fun buildVersionSubLabel(source: AfinitySource): String { + return buildList { + val w = source.width + val h = source.height + if (w != null && h != null && w > 0 && h > 0) add("${w}×${h}") + source.videoCodec?.uppercase()?.let { if (it.isNotBlank()) add(it) } + source.bitrate?.let { bps -> + val mbps = bps / 1_000_000.0 + add(String.format(Locale.US, "%.1f Mbps", mbps)) + } + source.container?.uppercase()?.let { if (it.isNotBlank()) add(it) } + }.joinToString(" · ") +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1aa57f9..86ae00a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -274,6 +274,10 @@ Audio Track Subtitles + Version + Default + Select which version to play + Version Selector S%1$s:E%2$s: %3$s Unknown From 9679e5d1f484933cc7fa390934a934b3bcf557db Mon Sep 17 00:00:00 2001 From: Adhish Mathur Date: Wed, 4 Mar 2026 23:36:40 +0530 Subject: [PATCH 2/2] fix(player): disable pre-play version dialog for movies; fix version picker overlay positioning in player --- .../makd/afinity/ui/item/ItemDetailScreen.kt | 2 +- .../makd/afinity/ui/player/PlayerScreen.kt | 38 ++++++++++++ .../ui/player/components/PlayerControls.kt | 26 +------- .../player/components/VersionPickerSheet.kt | 62 ++++++++----------- 4 files changed, 69 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt index 996a02f5..4f0ec19c 100644 --- a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt @@ -140,7 +140,7 @@ fun ItemDetailScreen( val remoteSources = item.sources.filter { it.type == com.makd.afinity.data.models.media.AfinitySourceType.REMOTE } - if (remoteSources.size > 1) { + if (remoteSources.size > 1 && item !is AfinityMovie) { pendingPlayItem = item pendingPlaySelection = selection showVersionPickerForPlay = true diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt index 3ab841b7..7f1f5fad 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt @@ -4,9 +4,12 @@ import android.graphics.Typeface import androidx.activity.compose.BackHandler import androidx.annotation.OptIn import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -20,6 +23,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LocalLifecycleOwner @@ -43,6 +47,7 @@ import com.makd.afinity.ui.player.components.MpvSurface import com.makd.afinity.ui.player.components.PlayerControls import com.makd.afinity.ui.player.components.PlayerIndicators import com.makd.afinity.ui.player.components.TrickplayPreview +import com.makd.afinity.ui.player.components.VersionPickerSheet import com.makd.afinity.ui.player.utils.KeepScreenOn import com.makd.afinity.ui.player.utils.PlayerSystemBarsController import com.makd.afinity.ui.player.utils.ScreenBrightnessController @@ -85,6 +90,7 @@ fun PlayerScreen( var seekOriginTime by remember { mutableLongStateOf(0L) } var dragStartVolume by remember { mutableIntStateOf(-1) } var dragStartBrightness by remember { mutableFloatStateOf(-1f) } + var showVersionPicker by remember { mutableStateOf(false) } LocalLifecycleOwner.current LaunchedEffect(item.id, mediaSourceId, isLiveChannel, liveStreamUrl) { @@ -292,6 +298,7 @@ fun PlayerScreen( playlistQueue = playlistState.queue, currentPlaylistIndex = playlistState.currentIndex, onJumpToEpisode = viewModel::jumpToEpisode, + onVersionToggleRequest = { showVersionPicker = !showVersionPicker }, ) TrickplayPreview( @@ -321,6 +328,37 @@ fun PlayerScreen( }, modifier = Modifier.align(Alignment.Center), ) + + // Version picker — rendered here so align(BottomEnd) maps to the actual screen Box + if (showVersionPicker && uiState.availableSources.size > 1) { + Box( + modifier = + Modifier.fillMaxSize().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { showVersionPicker = false } + ) { + Box( + modifier = + Modifier.align(Alignment.BottomEnd) + .padding(bottom = 110.dp, end = 56.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { /* consume */ } + ) { + VersionPickerSheet( + sources = uiState.availableSources, + currentSourceId = uiState.currentMediaSourceId, + onVersionSelected = { source -> + viewModel.handlePlayerEvent(PlayerEvent.SwitchVersion(source.id)) + showVersionPicker = false + }, + onDismiss = { showVersionPicker = false }, + ) + } + } + } } } diff --git a/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt b/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt index 766ec382..93137632 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt @@ -103,12 +103,12 @@ fun PlayerControls( playlistQueue: List = emptyList(), currentPlaylistIndex: Int = -1, onJumpToEpisode: (java.util.UUID) -> Unit = {}, + onVersionToggleRequest: () -> Unit = {}, ) { var showAudioSelector by remember { mutableStateOf(false) } var showSubtitleSelector by remember { mutableStateOf(false) } var showSpeedDialog by remember { mutableStateOf(false) } var showEpisodeSwitcher by remember { mutableStateOf(false) } - var showVersionSelector by remember { mutableStateOf(false) } val currentItem = uiState.currentItem @@ -331,7 +331,7 @@ fun PlayerControls( onEpisodeSwitcherToggle = { showEpisodeSwitcher = !showEpisodeSwitcher }, showEpisodeSwitcherButton = currentItem is AfinityEpisode && playlistQueue.size > 1, - onVersionToggle = { showVersionSelector = !showVersionSelector }, + onVersionToggle = onVersionToggleRequest, showVersionButton = uiState.availableSources.size > 1, modifier = Modifier.align(Alignment.BottomCenter), ) @@ -587,27 +587,7 @@ fun PlayerControls( ) } - if (showVersionSelector && uiState.availableSources.size > 1) { - Box( - modifier = - Modifier.fillMaxSize().clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - showVersionSelector = false - } - ) { - VersionPickerSheet( - sources = uiState.availableSources, - currentSourceId = uiState.currentMediaSourceId, - onVersionSelected = { source -> - onPlayerEvent(PlayerEvent.SwitchVersion(source.id)) - showVersionSelector = false - }, - onDismiss = { showVersionSelector = false }, - ) - } - } + } @OptIn(UnstableApi::class) diff --git a/app/src/main/java/com/makd/afinity/ui/player/components/VersionPickerSheet.kt b/app/src/main/java/com/makd/afinity/ui/player/components/VersionPickerSheet.kt index b2e28a5b..21113d3a 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/components/VersionPickerSheet.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/components/VersionPickerSheet.kt @@ -5,17 +5,16 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -47,40 +46,33 @@ fun VersionPickerSheet( ) { Box( modifier = - Modifier.fillMaxWidth().padding(bottom = 110.dp, end = 56.dp), - contentAlignment = Alignment.BottomEnd, + Modifier + .clickable(onClick = {}) // consume — do not propagate + .background(Color.Black.copy(alpha = 0.95f), RoundedCornerShape(8.dp)) + .padding(12.dp) + .width(240.dp) + .heightIn(max = 180.dp), ) { - Box( - modifier = - Modifier - .clickable(onClick = {}) // consume — do not propagate - .background(Color.Black.copy(alpha = 0.95f), RoundedCornerShape(8.dp)) - .padding(12.dp) - .width(240.dp), + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = stringResource(R.string.player_version_title), - style = MaterialTheme.typography.titleSmall, - color = Color.White, - modifier = Modifier.padding(bottom = 6.dp), + Text( + text = stringResource(R.string.player_version_title), + style = MaterialTheme.typography.titleSmall, + color = Color.White, + modifier = Modifier.padding(bottom = 4.dp), + ) + sources.forEach { source -> + val isSelected = source.id == currentSourceId + VersionItem( + source = source, + isSelected = isSelected, + onClick = { + onVersionSelected(source) + onDismiss() + }, ) - LazyColumn( - contentPadding = PaddingValues(bottom = 4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(sources, key = { it.id }) { source -> - val isSelected = source.id == currentSourceId - VersionItem( - source = source, - isSelected = isSelected, - onClick = { - onVersionSelected(source) - onDismiss() - }, - ) - } - } } } }