Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -16,6 +17,13 @@ data class AfinitySource(
val size: Long,
val mediaStreams: List<AfinityMediaStream>,
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(
Expand All @@ -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(),
Expand All @@ -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,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
93 changes: 60 additions & 33 deletions app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<AfinityItem?>(null) }
var pendingPlaySelection by remember { mutableStateOf<PlaybackSelection?>(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 -> {
Expand Down Expand Up @@ -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) },
Expand All @@ -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
},
)
}
}
}
}

Expand Down Expand Up @@ -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)
},
)
}
Expand Down Expand Up @@ -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)
},
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AfinitySource>,
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<String>()
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(" · ")
}
Loading
Loading