feat: add manual play option and update stream loading logic

This commit is contained in:
tapframe 2026-04-07 14:56:05 +05:30
parent b3082eb412
commit ae0e8d3386
7 changed files with 366 additions and 86 deletions

View file

@ -212,6 +212,7 @@ data class StreamRoute(
val streamContextId: Long? = null, val streamContextId: Long? = null,
val resumePositionMs: Long? = null, val resumePositionMs: Long? = null,
val resumeProgressFraction: Float? = null, val resumeProgressFraction: Float? = null,
val manualSelection: Boolean = false,
) )
@Serializable @Serializable
@ -460,6 +461,33 @@ private fun MainAppContent(
) )
} }
val onPlayManually: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit =
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs ->
val streamContextId = pauseDescription
?.takeIf { it.isNotBlank() }
?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) }
navController.navigate(
StreamRoute(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
streamContextId = streamContextId,
resumePositionMs = resumePositionMs,
resumeProgressFraction = null,
manualSelection = true,
)
)
}
val onCatalogClick: (HomeCatalogSection) -> Unit = { section -> val onCatalogClick: (HomeCatalogSection) -> Unit = { section ->
navController.navigate( navController.navigate(
CatalogRoute( CatalogRoute(
@ -650,6 +678,7 @@ private fun MainAppContent(
navController.popBackStack() navController.popBackStack()
}, },
onPlay = onPlay, onPlay = onPlay,
onPlayManually = onPlayManually,
onOpenMeta = { preview -> onOpenMeta = { preview ->
coroutineScope.launch { coroutineScope.launch {
val resolvedId = if (preview.id.startsWith("tmdb:")) { val resolvedId = if (preview.id.startsWith("tmdb:")) {
@ -835,10 +864,11 @@ private fun MainAppContent(
// Reuse Last Link: auto-play from cache if enabled (only on first entry) // Reuse Last Link: auto-play from cache if enabled (only on first entry)
var reuseHandled by rememberSaveable(route.videoId, effectiveVideoId) { mutableStateOf(false) } var reuseHandled by rememberSaveable(route.videoId, effectiveVideoId) { mutableStateOf(false) }
var reuseNavigated by remember { mutableStateOf(false) } var reuseNavigated by remember { mutableStateOf(false) }
LaunchedEffect(effectiveVideoId, hasResolvedVideoId, playerSettings.streamReuseLastLinkEnabled) { LaunchedEffect(effectiveVideoId, hasResolvedVideoId, playerSettings.streamReuseLastLinkEnabled, route.manualSelection) {
if (!hasResolvedVideoId) return@LaunchedEffect if (!hasResolvedVideoId) return@LaunchedEffect
if (reuseHandled) return@LaunchedEffect if (reuseHandled) return@LaunchedEffect
reuseHandled = true reuseHandled = true
if (route.manualSelection) return@LaunchedEffect
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId) val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId)
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
@ -879,8 +909,9 @@ private fun MainAppContent(
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle() val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
var autoPlayHandled by rememberSaveable(route.videoId, effectiveVideoId) { mutableStateOf(false) } var autoPlayHandled by rememberSaveable(route.videoId, effectiveVideoId) { mutableStateOf(false) }
LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled) { LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, route.manualSelection) {
if (!reuseHandled) return@LaunchedEffect if (!reuseHandled) return@LaunchedEffect
if (route.manualSelection) return@LaunchedEffect
if (reuseNavigated) return@LaunchedEffect if (reuseNavigated) return@LaunchedEffect
if (autoPlayHandled) return@LaunchedEffect if (autoPlayHandled) return@LaunchedEffect
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
@ -955,6 +986,7 @@ private fun MainAppContent(
episodeThumbnail = route.episodeThumbnail, episodeThumbnail = route.episodeThumbnail,
resumePositionMs = route.resumePositionMs, resumePositionMs = route.resumePositionMs,
resumeProgressFraction = route.resumeProgressFraction, resumeProgressFraction = route.resumeProgressFraction,
manualSelection = route.manualSelection,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction -> onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
val sourceUrl = stream.directPlaybackUrl val sourceUrl = stream.directPlaybackUrl
if (sourceUrl != null) { if (sourceUrl != null) {

View file

@ -73,6 +73,8 @@ import com.nuvio.app.features.details.components.TrailerPlayerPopup
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCommentReview import com.nuvio.app.features.trakt.TraktCommentReview
import com.nuvio.app.features.trakt.TraktCommentsRepository import com.nuvio.app.features.trakt.TraktCommentsRepository
@ -99,6 +101,7 @@ fun MetaDetailsScreen(
id: String, id: String,
onBack: () -> Unit, onBack: () -> Unit,
onPlay: ((type: String, videoId: String, parentMetaId: String, parentMetaType: String, title: String, logo: String?, poster: String?, background: String?, seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, episodeThumbnail: String?, pauseDescription: String?, resumePositionMs: Long?) -> Unit)? = null, onPlay: ((type: String, videoId: String, parentMetaId: String, parentMetaType: String, title: String, logo: String?, poster: String?, background: String?, seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, episodeThumbnail: String?, pauseDescription: String?, resumePositionMs: Long?) -> Unit)? = null,
onPlayManually: ((type: String, videoId: String, parentMetaId: String, parentMetaType: String, title: String, logo: String?, poster: String?, background: String?, seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, episodeThumbnail: String?, pauseDescription: String?, resumePositionMs: Long?) -> Unit)? = null,
onOpenMeta: ((MetaPreview) -> Unit)? = null, onOpenMeta: ((MetaPreview) -> Unit)? = null,
onCastClick: ((MetaPerson) -> Unit)? = null, onCastClick: ((MetaPerson) -> Unit)? = null,
onCompanyClick: ((MetaCompany, String) -> Unit)? = null, onCompanyClick: ((MetaCompany, String) -> Unit)? = null,
@ -127,6 +130,10 @@ fun MetaDetailsScreen(
WatchProgressRepository.ensureLoaded() WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState WatchProgressRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val playerSettingsUiState by remember {
PlayerSettingsRepository.ensureLoaded()
PlayerSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val needsFreshLoad = displayedMeta == null && !uiState.isLoading val needsFreshLoad = displayedMeta == null && !uiState.isLoading
var selectedEpisodeForActions by remember(type, id) { mutableStateOf<MetaVideo?>(null) } var selectedEpisodeForActions by remember(type, id) { mutableStateOf<MetaVideo?>(null) }
val commentsEnabled by remember { val commentsEnabled by remember {
@ -397,6 +404,53 @@ fun MetaDetailsScreen(
} }
} }
} }
val manualPlayHandler = onPlayManually
val showManualPlayOption = manualPlayHandler != null && StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState)
val onPrimaryPlayLongClick: (() -> Unit)? = manualPlayHandler
?.takeIf { showManualPlayOption }
?.let { manualPlay ->
{
when {
(meta.type == "series" || hasEpisodes) && seriesAction != null -> {
manualPlay(
meta.type,
seriesStreamVideoId ?: seriesAction.videoId,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
seriesAction.seasonNumber,
seriesAction.episodeNumber,
seriesAction.episodeTitle,
seriesAction.episodeThumbnail,
seriesPauseDescription,
seriesAction.resumePositionMs,
)
}
else -> {
manualPlay(
meta.type,
meta.id,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
null,
null,
null,
null,
meta.description,
movieProgress?.lastPositionMs,
)
}
}
}
}
val onEpisodePlayClick: (MetaVideo) -> Unit = { video -> val onEpisodePlayClick: (MetaVideo) -> Unit = { video ->
val season = video.season val season = video.season
val episode = video.episode val episode = video.episode
@ -426,6 +480,35 @@ fun MetaDetailsScreen(
savedProgress?.lastPositionMs, savedProgress?.lastPositionMs,
) )
} }
val onEpisodeManualPlayClick: (MetaVideo) -> Unit = { video ->
val season = video.season
val episode = video.episode
val playbackVideoId = buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = season,
episodeNumber = episode,
fallbackVideoId = video.id,
)
val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId
val savedProgress = watchProgressUiState.byVideoId[playbackVideoId]
?.takeUnless { it.isCompleted }
onPlayManually?.invoke(
meta.type,
streamVideoId,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
season,
episode,
video.title,
video.thumbnail,
video.overview,
savedProgress?.lastPositionMs,
)
}
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val density = LocalDensity.current val density = LocalDensity.current
val safeAreaTopPx = with(density) { val safeAreaTopPx = with(density) {
@ -500,7 +583,9 @@ fun MetaDetailsScreen(
playButtonLabel = playButtonLabel, playButtonLabel = playButtonLabel,
isSaved = isSaved, isSaved = isSaved,
onPrimaryPlayClick = onPrimaryPlayClick, onPrimaryPlayClick = onPrimaryPlayClick,
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
onSaveClick = toggleSaved, onSaveClick = toggleSaved,
showManualPlayOption = showManualPlayOption,
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
hasProductionSection = hasProductionSection, hasProductionSection = hasProductionSection,
hasTrailersSection = hasTrailersSection, hasTrailersSection = hasTrailersSection,
@ -670,6 +755,10 @@ fun MetaDetailsScreen(
areCurrentlyWatched = isSeasonWatched, areCurrentlyWatched = isSeasonWatched,
) )
}, },
showPlayManually = showManualPlayOption,
onPlayManually = {
onEpisodeManualPlayClick(selectedEpisode)
},
) )
} }
@ -795,7 +884,9 @@ private fun ConfiguredMetaSections(
playButtonLabel: String, playButtonLabel: String,
isSaved: Boolean, isSaved: Boolean,
onPrimaryPlayClick: () -> Unit, onPrimaryPlayClick: () -> Unit,
onPrimaryPlayLongClick: (() -> Unit)?,
onSaveClick: () -> Unit, onSaveClick: () -> Unit,
showManualPlayOption: Boolean,
preferredEpisodeSeasonNumber: Int?, preferredEpisodeSeasonNumber: Int?,
hasProductionSection: Boolean, hasProductionSection: Boolean,
hasTrailersSection: Boolean, hasTrailersSection: Boolean,
@ -850,6 +941,7 @@ private fun ConfiguredMetaSections(
isSaved = isSaved, isSaved = isSaved,
isTablet = isTablet, isTablet = isTablet,
onPlayClick = onPrimaryPlayClick, onPlayClick = onPrimaryPlayClick,
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,
onSaveClick = onSaveClick, onSaveClick = onSaveClick,
) )
} }

View file

@ -1,5 +1,7 @@
package com.nuvio.app.features.details.components package com.nuvio.app.features.details.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -16,15 +18,18 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.AppIconResource import com.nuvio.app.core.ui.AppIconResource
import com.nuvio.app.core.ui.appIconPainter import com.nuvio.app.core.ui.appIconPainter
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun DetailActionButtons( fun DetailActionButtons(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -33,10 +38,12 @@ fun DetailActionButtons(
isSaved: Boolean = false, isSaved: Boolean = false,
isTablet: Boolean = false, isTablet: Boolean = false,
onPlayClick: () -> Unit = {}, onPlayClick: () -> Unit = {},
onPlayLongClick: (() -> Unit)? = null,
onSaveClick: () -> Unit = {}, onSaveClick: () -> Unit = {},
) { ) {
val playPainter = appIconPainter(AppIconResource.PlayerPlay) val playPainter = appIconPainter(AppIconResource.PlayerPlay)
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus) val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
val playShape = RoundedCornerShape(40.dp)
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@ -46,48 +53,48 @@ fun DetailActionButtons(
Arrangement.spacedBy(12.dp) Arrangement.spacedBy(12.dp)
}, },
) { ) {
Button( val rowButtonModifier = if (isTablet) {
onClick = onPlayClick, Modifier.width(220.dp)
modifier = Modifier } else {
.then( Modifier.weight(1f)
if (isTablet) { }
Modifier.width(220.dp)
} else { Surface(
Modifier.weight(1f) modifier = rowButtonModifier.height(50.dp),
} shape = playShape,
) color = MaterialTheme.colorScheme.onBackground,
.height(50.dp), contentColor = MaterialTheme.colorScheme.background,
shape = RoundedCornerShape(40.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.onBackground,
contentColor = MaterialTheme.colorScheme.background,
),
) { ) {
Icon( Row(
painter = playPainter, modifier = Modifier
contentDescription = null, .fillMaxWidth()
modifier = Modifier.size(18.dp), .combinedClickable(
) onClick = onPlayClick,
Spacer(modifier = Modifier.width(6.dp)) onLongClick = onPlayLongClick,
Text( role = Role.Button,
text = playLabel, )
style = MaterialTheme.typography.titleSmall, .height(50.dp),
maxLines = 1, horizontalArrangement = Arrangement.Center,
overflow = TextOverflow.Ellipsis, verticalAlignment = Alignment.CenterVertically,
) ) {
Icon(
painter = playPainter,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = playLabel,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} }
OutlinedButton( OutlinedButton(
onClick = onSaveClick, onClick = onSaveClick,
modifier = Modifier modifier = rowButtonModifier.height(50.dp),
.then(
if (isTablet) {
Modifier.width(220.dp)
} else {
Modifier.weight(1f)
}
)
.height(50.dp),
shape = RoundedCornerShape(40.dp), shape = RoundedCornerShape(40.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) { ) {

View file

@ -12,6 +12,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PlaylistAddCheckCircle import androidx.compose.material.icons.filled.PlaylistAddCheckCircle
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -44,6 +45,8 @@ fun EpisodeWatchedActionSheet(
onToggleWatched: () -> Unit, onToggleWatched: () -> Unit,
onTogglePreviousWatched: () -> Unit, onTogglePreviousWatched: () -> Unit,
onToggleSeasonWatched: () -> Unit, onToggleSeasonWatched: () -> Unit,
showPlayManually: Boolean = false,
onPlayManually: (() -> Unit)? = null,
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -108,6 +111,19 @@ fun EpisodeWatchedActionSheet(
} }
}, },
) )
if (showPlayManually && onPlayManually != null) {
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.PlayArrow,
title = "Play manually",
onClick = {
onPlayManually()
coroutineScope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
},
)
}
} }
} }
} }

View file

@ -32,22 +32,36 @@ object StreamsRepository {
private var activeJob: Job? = null private var activeJob: Job? = null
private var activeRequestKey: String? = null private var activeRequestKey: String? = null
fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null) { fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
load(type = type, videoId = videoId, season = season, episode = episode, forceRefresh = false) load(
type = type,
videoId = videoId,
season = season,
episode = episode,
manualSelection = manualSelection,
forceRefresh = false,
)
} }
fun reload(type: String, videoId: String, season: Int? = null, episode: Int? = null) { fun reload(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
load(type = type, videoId = videoId, season = season, episode = episode, forceRefresh = true) load(
type = type,
videoId = videoId,
season = season,
episode = episode,
manualSelection = manualSelection,
forceRefresh = true,
)
} }
private fun load(type: String, videoId: String, season: Int?, episode: Int?, forceRefresh: Boolean) { private fun load(type: String, videoId: String, season: Int?, episode: Int?, manualSelection: Boolean, forceRefresh: Boolean) {
val pluginUiState = if (AppFeaturePolicy.pluginsEnabled) { val pluginUiState = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.initialize() PluginRepository.initialize()
PluginRepository.uiState.value PluginRepository.uiState.value
} else { } else {
PluginsUiState(pluginsEnabled = false) PluginsUiState(pluginsEnabled = false)
} }
val requestKey = "$type::$videoId::$season::$episode::pluginsGrouped=${pluginUiState.groupStreamsByRepository}" val requestKey = "$type::$videoId::$season::$episode::$manualSelection::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
val currentState = _uiState.value val currentState = _uiState.value
if ( if (
!forceRefresh && !forceRefresh &&
@ -65,7 +79,7 @@ object StreamsRepository {
PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.ensureLoaded()
val playerSettings = PlayerSettingsRepository.uiState.value val playerSettings = PlayerSettingsRepository.uiState.value
val autoPlayMode = playerSettings.streamAutoPlayMode val autoPlayMode = playerSettings.streamAutoPlayMode
val isAutoPlayEnabled = autoPlayMode != StreamAutoPlayMode.MANUAL && val isAutoPlayEnabled = !manualSelection && autoPlayMode != StreamAutoPlayMode.MANUAL &&
!(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH && !(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH &&
!StreamAutoPlayPolicy.isRegexSelectionConfigured(playerSettings.streamAutoPlayRegex)) !StreamAutoPlayPolicy.isRegexSelectionConfigured(playerSettings.streamAutoPlayRegex))
val isDirectAutoPlayFlow = isAutoPlayEnabled val isDirectAutoPlayFlow = isAutoPlayEnabled

View file

@ -94,6 +94,7 @@ fun StreamsScreen(
episodeThumbnail: String? = null, episodeThumbnail: String? = null,
resumePositionMs: Long? = null, resumePositionMs: Long? = null,
resumeProgressFraction: Float? = null, resumeProgressFraction: Float? = null,
manualSelection: Boolean = false,
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit = { _, _, _ -> }, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit = { _, _, _ -> },
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -131,12 +132,13 @@ fun StreamsScreen(
(resumePositionMs ?: storedProgress?.lastPositionMs)?.takeIf { it > 0L } (resumePositionMs ?: storedProgress?.lastPositionMs)?.takeIf { it > 0L }
} }
LaunchedEffect(type, videoId) { LaunchedEffect(type, videoId, manualSelection) {
StreamsRepository.load( StreamsRepository.load(
type = type, type = type,
videoId = videoId, videoId = videoId,
season = seasonNumber, season = seasonNumber,
episode = episodeNumber, episode = episodeNumber,
manualSelection = manualSelection,
) )
} }
@ -223,6 +225,7 @@ fun StreamsScreen(
videoId = videoId, videoId = videoId,
season = seasonNumber, season = seasonNumber,
episode = episodeNumber, episode = episodeNumber,
manualSelection = manualSelection,
) )
}, },
), ),

View file

@ -17,12 +17,13 @@ IOS_PREFERRED_DEVICE_MODEL="iPhone 14 Pro"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: Usage:
./scripts/run-mobile.sh android [e|p] [full|playstore]
./scripts/run-mobile.sh android [full|playstore] ./scripts/run-mobile.sh android [full|playstore]
./scripts/run-mobile.sh ios [s|p] [full|appstore] ./scripts/run-mobile.sh ios [s|p] [full|appstore]
Builds the debug app for the selected platform, installs it on all available Builds the debug app for the selected platform, installs it on all available
Android emulators, a booted iOS simulator, or the configured iOS physical Android emulators or connected physical devices, a booted iOS simulator, or
device, and launches the app. the configured iOS physical device, and launches the app.
EOF EOF
} }
@ -41,6 +42,16 @@ booted_android_emulator_serials() {
adb devices | awk '$2 == "device" && $1 ~ /^emulator-/ { print $1 }' adb devices | awk '$2 == "device" && $1 ~ /^emulator-/ { print $1 }'
} }
connected_android_physical_serials() {
adb devices | awk '$2 == "device" && $1 !~ /^emulator-/ { print $1 }'
}
wait_for_android_device() {
local serial="$1"
adb -s "$serial" wait-for-device >/dev/null
}
wait_for_android_emulator() { wait_for_android_emulator() {
local serial="$1" local serial="$1"
local boot_completed="" local boot_completed=""
@ -102,14 +113,8 @@ validate_ios_distribution() {
esac esac
} }
ios_derived_data_path() { validate_android_flavor() {
local target="$1" local flavor="$1"
local distribution="$2"
echo "$IOS_DERIVED_DATA_BASE-$distribution-$target"
}
run_android() {
local flavor="${1:-full}"
case "$flavor" in case "$flavor" in
full|playstore) full|playstore)
@ -120,6 +125,78 @@ run_android() {
exit 1 exit 1
;; ;;
esac esac
}
android_flavor_task_part() {
local flavor="$1"
case "$flavor" in
full)
echo "Full"
;;
playstore)
echo "Playstore"
;;
esac
}
android_apk_path() {
local flavor="$1"
case "$flavor" in
full)
echo "$ROOT_DIR/composeApp/build/outputs/apk/full/debug/composeApp-full-debug.apk"
;;
playstore)
echo "$ROOT_DIR/composeApp/build/outputs/apk/playstore/debug/composeApp-playstore-debug.apk"
;;
esac
}
build_android_apk() {
local flavor="$1"
local flavor_task_part
local apk_path
flavor_task_part="$(android_flavor_task_part "$flavor")"
apk_path="$(android_apk_path "$flavor")"
echo "Building Android $flavor debug APK..." >&2
"$GRADLEW" ":composeApp:assemble${flavor_task_part}Debug" >&2
if [[ ! -f "$apk_path" ]]; then
echo "Expected APK not found at: $apk_path" >&2
exit 1
fi
printf '%s\n' "$apk_path"
}
install_and_launch_android() {
local device_label="$1"
local apk_path="$2"
shift 2
local serial
for serial in "$@"; do
echo "Installing on $device_label $serial..."
adb -s "$serial" install -r "$apk_path"
echo "Launching app on $serial..."
adb -s "$serial" shell am start -n "$ANDROID_APP_ID/$ANDROID_ACTIVITY"
done
}
ios_derived_data_path() {
local target="$1"
local distribution="$2"
echo "$IOS_DERIVED_DATA_BASE-$distribution-$target"
}
run_android_emulator() {
local flavor="${1:-full}"
validate_android_flavor "$flavor"
require_command adb require_command adb
require_command emulator require_command emulator
@ -134,19 +211,19 @@ run_android() {
if [[ ${#booted_serials[@]} -gt 0 ]]; then if [[ ${#booted_serials[@]} -gt 0 ]]; then
echo "Using running Android emulators: ${booted_serials[*]}" echo "Using running Android emulators: ${booted_serials[*]}"
else else
local avds=() local avds=()
while IFS= read -r avd_name; do local avd_name
[[ -n "$avd_name" ]] || continue while IFS= read -r avd_name; do
avds+=("$avd_name") [[ -n "$avd_name" ]] || continue
done < <(android_emulator_avds) avds+=("$avd_name")
done < <(android_emulator_avds)
if [[ ${#avds[@]} -eq 0 ]]; then if [[ ${#avds[@]} -eq 0 ]]; then
echo "No Android emulators available." >&2 echo "No Android emulators available." >&2
echo "Create an AVD first, then rerun: ./scripts/run-mobile.sh android" >&2 echo "Create an AVD first, then rerun: ./scripts/run-mobile.sh android e" >&2
exit 1 exit 1
fi fi
local avd_name
for avd_name in "${avds[@]}"; do for avd_name in "${avds[@]}"; do
boot_android_emulator "$avd_name" boot_android_emulator "$avd_name"
done done
@ -171,34 +248,42 @@ run_android() {
wait_for_android_emulator "$serial" wait_for_android_emulator "$serial"
done done
local flavor_task_part
local apk_path local apk_path
case "$flavor" in apk_path="$(build_android_apk "$flavor")"
full)
flavor_task_part="Full"
apk_path="$ROOT_DIR/composeApp/build/outputs/apk/full/debug/composeApp-full-debug.apk"
;;
playstore)
flavor_task_part="Playstore"
apk_path="$ROOT_DIR/composeApp/build/outputs/apk/playstore/debug/composeApp-playstore-debug.apk"
;;
esac
echo "Building Android $flavor debug APK..." install_and_launch_android "emulator" "$apk_path" "${booted_serials[@]}"
"$GRADLEW" ":composeApp:assemble${flavor_task_part}Debug" }
if [[ ! -f "$apk_path" ]]; then run_android_physical() {
echo "Expected APK not found at: $apk_path" >&2 local flavor="${1:-full}"
validate_android_flavor "$flavor"
require_command adb
local serials=()
local serial
while IFS= read -r serial; do
[[ -n "$serial" ]] || continue
serials+=("$serial")
done < <(connected_android_physical_serials)
if [[ ${#serials[@]} -eq 0 ]]; then
echo "No Android physical devices available." >&2
echo "Connect and authorize a device, then rerun: ./scripts/run-mobile.sh android p" >&2
exit 1 exit 1
fi fi
for serial in "${booted_serials[@]}"; do echo "Using connected Android physical devices: ${serials[*]}"
echo "Installing on emulator $serial..."
adb -s "$serial" install -r "$apk_path"
echo "Launching app on $serial..." for serial in "${serials[@]}"; do
adb -s "$serial" shell am start -n "$ANDROID_APP_ID/$ANDROID_ACTIVITY" wait_for_android_device "$serial"
done done
local apk_path
apk_path="$(build_android_apk "$flavor")"
install_and_launch_android "physical device" "$apk_path" "${serials[@]}"
} }
run_ios_simulator() { run_ios_simulator() {
@ -296,11 +381,42 @@ main() {
case "$1" in case "$1" in
android) android)
if [[ $# -gt 2 ]]; then if [[ $# -gt 3 ]]; then
usage usage
exit 1 exit 1
fi fi
run_android "${2:-full}"
local android_target="e"
local android_flavor="full"
if [[ $# -ge 2 ]]; then
case "$2" in
e|p)
android_target="$2"
;;
full|playstore)
android_flavor="$2"
;;
*)
echo "Unknown Android target or flavor: $2" >&2
usage
exit 1
;;
esac
fi
if [[ $# -eq 3 ]]; then
android_flavor="$3"
fi
case "$android_target" in
e)
run_android_emulator "$android_flavor"
;;
p)
run_android_physical "$android_flavor"
;;
esac
;; ;;
ios) ios)
if [[ $# -lt 2 || $# -gt 3 ]]; then if [[ $# -lt 2 || $# -gt 3 ]]; then