mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-01 21:24:52 +00:00
feat: add manual play option and update stream loading logic
This commit is contained in:
parent
b3082eb412
commit
ae0e8d3386
7 changed files with 366 additions and 86 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue