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 e1dfac31..763a1d61 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 @@ -73,6 +75,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 @@ -87,6 +90,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 @@ -125,6 +129,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 && item !is AfinityMovie) { + pendingPlayItem = item + pendingPlaySelection = selection + showVersionPickerForPlay = true + } else { + onPlayClick(item, selection) + } + } + Box(modifier = modifier.fillMaxSize()) { when { uiState.isLoading -> { @@ -200,18 +225,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) }, @@ -238,6 +252,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 + }, + ) + } + } } } @@ -813,18 +859,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) }, ) } @@ -1501,15 +1536,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..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) { @@ -120,33 +126,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) } @@ -318,6 +298,7 @@ fun PlayerScreen( playlistQueue = playlistState.queue, currentPlaylistIndex = playlistState.currentIndex, onJumpToEpisode = viewModel::jumpToEpisode, + onVersionToggleRequest = { showVersionPicker = !showVersionPicker }, ) TrickplayPreview( @@ -347,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/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..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,6 +103,7 @@ 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) } @@ -330,6 +331,8 @@ fun PlayerControls( onEpisodeSwitcherToggle = { showEpisodeSwitcher = !showEpisodeSwitcher }, showEpisodeSwitcherButton = currentItem is AfinityEpisode && playlistQueue.size > 1, + onVersionToggle = onVersionToggleRequest, + showVersionButton = uiState.availableSources.size > 1, modifier = Modifier.align(Alignment.BottomCenter), ) } @@ -583,6 +586,8 @@ fun PlayerControls( onDismiss = { showEpisodeSwitcher = false }, ) } + + } @OptIn(UnstableApi::class) @@ -791,6 +796,8 @@ private fun BottomControls( onSubtitleToggle: () -> Unit, onEpisodeSwitcherToggle: () -> Unit = {}, showEpisodeSwitcherButton: Boolean = false, + onVersionToggle: () -> Unit = {}, + showVersionButton: Boolean = false, ) { Box( modifier = @@ -816,6 +823,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..21113d3a --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/player/components/VersionPickerSheet.kt @@ -0,0 +1,149 @@ +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.Row +import androidx.compose.foundation.layout.Spacer +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.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 +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 + .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), + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.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() + }, + ) + } + } + } +} + +@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