mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-28 03:43:02 +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 resumePositionMs: Long? = null,
|
||||
val resumeProgressFraction: Float? = null,
|
||||
val manualSelection: Boolean = false,
|
||||
)
|
||||
|
||||
@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 ->
|
||||
navController.navigate(
|
||||
CatalogRoute(
|
||||
|
|
@ -650,6 +678,7 @@ private fun MainAppContent(
|
|||
navController.popBackStack()
|
||||
},
|
||||
onPlay = onPlay,
|
||||
onPlayManually = onPlayManually,
|
||||
onOpenMeta = { preview ->
|
||||
coroutineScope.launch {
|
||||
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)
|
||||
var reuseHandled by rememberSaveable(route.videoId, effectiveVideoId) { 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 (reuseHandled) return@LaunchedEffect
|
||||
reuseHandled = true
|
||||
if (route.manualSelection) return@LaunchedEffect
|
||||
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
|
||||
val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId)
|
||||
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
|
||||
|
|
@ -879,8 +909,9 @@ private fun MainAppContent(
|
|||
|
||||
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
|
||||
var autoPlayHandled by rememberSaveable(route.videoId, effectiveVideoId) { mutableStateOf(false) }
|
||||
LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled) {
|
||||
LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, route.manualSelection) {
|
||||
if (!reuseHandled) return@LaunchedEffect
|
||||
if (route.manualSelection) return@LaunchedEffect
|
||||
if (reuseNavigated) return@LaunchedEffect
|
||||
if (autoPlayHandled) return@LaunchedEffect
|
||||
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
|
||||
|
|
@ -955,6 +986,7 @@ private fun MainAppContent(
|
|||
episodeThumbnail = route.episodeThumbnail,
|
||||
resumePositionMs = route.resumePositionMs,
|
||||
resumeProgressFraction = route.resumeProgressFraction,
|
||||
manualSelection = route.manualSelection,
|
||||
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
|
||||
val sourceUrl = stream.directPlaybackUrl
|
||||
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.library.LibraryRepository
|
||||
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.TraktCommentReview
|
||||
import com.nuvio.app.features.trakt.TraktCommentsRepository
|
||||
|
|
@ -99,6 +101,7 @@ fun MetaDetailsScreen(
|
|||
id: String,
|
||||
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,
|
||||
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,
|
||||
onCastClick: ((MetaPerson) -> Unit)? = null,
|
||||
onCompanyClick: ((MetaCompany, String) -> Unit)? = null,
|
||||
|
|
@ -127,6 +130,10 @@ fun MetaDetailsScreen(
|
|||
WatchProgressRepository.ensureLoaded()
|
||||
WatchProgressRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val playerSettingsUiState by remember {
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val needsFreshLoad = displayedMeta == null && !uiState.isLoading
|
||||
var selectedEpisodeForActions by remember(type, id) { mutableStateOf<MetaVideo?>(null) }
|
||||
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 season = video.season
|
||||
val episode = video.episode
|
||||
|
|
@ -426,6 +480,35 @@ fun MetaDetailsScreen(
|
|||
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 density = LocalDensity.current
|
||||
val safeAreaTopPx = with(density) {
|
||||
|
|
@ -500,7 +583,9 @@ fun MetaDetailsScreen(
|
|||
playButtonLabel = playButtonLabel,
|
||||
isSaved = isSaved,
|
||||
onPrimaryPlayClick = onPrimaryPlayClick,
|
||||
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
|
||||
onSaveClick = toggleSaved,
|
||||
showManualPlayOption = showManualPlayOption,
|
||||
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
|
||||
hasProductionSection = hasProductionSection,
|
||||
hasTrailersSection = hasTrailersSection,
|
||||
|
|
@ -670,6 +755,10 @@ fun MetaDetailsScreen(
|
|||
areCurrentlyWatched = isSeasonWatched,
|
||||
)
|
||||
},
|
||||
showPlayManually = showManualPlayOption,
|
||||
onPlayManually = {
|
||||
onEpisodeManualPlayClick(selectedEpisode)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -795,7 +884,9 @@ private fun ConfiguredMetaSections(
|
|||
playButtonLabel: String,
|
||||
isSaved: Boolean,
|
||||
onPrimaryPlayClick: () -> Unit,
|
||||
onPrimaryPlayLongClick: (() -> Unit)?,
|
||||
onSaveClick: () -> Unit,
|
||||
showManualPlayOption: Boolean,
|
||||
preferredEpisodeSeasonNumber: Int?,
|
||||
hasProductionSection: Boolean,
|
||||
hasTrailersSection: Boolean,
|
||||
|
|
@ -850,6 +941,7 @@ private fun ConfiguredMetaSections(
|
|||
isSaved = isSaved,
|
||||
isTablet = isTablet,
|
||||
onPlayClick = onPrimaryPlayClick,
|
||||
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,
|
||||
onSaveClick = onSaveClick,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -16,15 +18,18 @@ import androidx.compose.material3.ButtonDefaults
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.AppIconResource
|
||||
import com.nuvio.app.core.ui.appIconPainter
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DetailActionButtons(
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -33,10 +38,12 @@ fun DetailActionButtons(
|
|||
isSaved: Boolean = false,
|
||||
isTablet: Boolean = false,
|
||||
onPlayClick: () -> Unit = {},
|
||||
onPlayLongClick: (() -> Unit)? = null,
|
||||
onSaveClick: () -> Unit = {},
|
||||
) {
|
||||
val playPainter = appIconPainter(AppIconResource.PlayerPlay)
|
||||
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
|
||||
val playShape = RoundedCornerShape(40.dp)
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
|
|
@ -46,48 +53,48 @@ fun DetailActionButtons(
|
|||
Arrangement.spacedBy(12.dp)
|
||||
},
|
||||
) {
|
||||
Button(
|
||||
onClick = onPlayClick,
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (isTablet) {
|
||||
Modifier.width(220.dp)
|
||||
} else {
|
||||
Modifier.weight(1f)
|
||||
}
|
||||
)
|
||||
.height(50.dp),
|
||||
shape = RoundedCornerShape(40.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.onBackground,
|
||||
contentColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
val rowButtonModifier = if (isTablet) {
|
||||
Modifier.width(220.dp)
|
||||
} else {
|
||||
Modifier.weight(1f)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = rowButtonModifier.height(50.dp),
|
||||
shape = playShape,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
contentColor = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onPlayClick,
|
||||
onLongClick = onPlayLongClick,
|
||||
role = Role.Button,
|
||||
)
|
||||
.height(50.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
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(
|
||||
onClick = onSaveClick,
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (isTablet) {
|
||||
Modifier.width(220.dp)
|
||||
} else {
|
||||
Modifier.weight(1f)
|
||||
}
|
||||
)
|
||||
.height(50.dp),
|
||||
modifier = rowButtonModifier.height(50.dp),
|
||||
shape = RoundedCornerShape(40.dp),
|
||||
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.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.PlaylistAddCheckCircle
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -44,6 +45,8 @@ fun EpisodeWatchedActionSheet(
|
|||
onToggleWatched: () -> Unit,
|
||||
onTogglePreviousWatched: () -> Unit,
|
||||
onToggleSeasonWatched: () -> Unit,
|
||||
showPlayManually: Boolean = false,
|
||||
onPlayManually: (() -> Unit)? = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
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 activeRequestKey: String? = null
|
||||
|
||||
fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null) {
|
||||
load(type = type, videoId = videoId, season = season, episode = episode, forceRefresh = false)
|
||||
fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = 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) {
|
||||
load(type = type, videoId = videoId, season = season, episode = episode, forceRefresh = true)
|
||||
fun reload(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
|
||||
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) {
|
||||
PluginRepository.initialize()
|
||||
PluginRepository.uiState.value
|
||||
} else {
|
||||
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
|
||||
if (
|
||||
!forceRefresh &&
|
||||
|
|
@ -65,7 +79,7 @@ object StreamsRepository {
|
|||
PlayerSettingsRepository.ensureLoaded()
|
||||
val playerSettings = PlayerSettingsRepository.uiState.value
|
||||
val autoPlayMode = playerSettings.streamAutoPlayMode
|
||||
val isAutoPlayEnabled = autoPlayMode != StreamAutoPlayMode.MANUAL &&
|
||||
val isAutoPlayEnabled = !manualSelection && autoPlayMode != StreamAutoPlayMode.MANUAL &&
|
||||
!(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH &&
|
||||
!StreamAutoPlayPolicy.isRegexSelectionConfigured(playerSettings.streamAutoPlayRegex))
|
||||
val isDirectAutoPlayFlow = isAutoPlayEnabled
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ fun StreamsScreen(
|
|||
episodeThumbnail: String? = null,
|
||||
resumePositionMs: Long? = null,
|
||||
resumeProgressFraction: Float? = null,
|
||||
manualSelection: Boolean = false,
|
||||
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit = { _, _, _ -> },
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -131,12 +132,13 @@ fun StreamsScreen(
|
|||
(resumePositionMs ?: storedProgress?.lastPositionMs)?.takeIf { it > 0L }
|
||||
}
|
||||
|
||||
LaunchedEffect(type, videoId) {
|
||||
LaunchedEffect(type, videoId, manualSelection) {
|
||||
StreamsRepository.load(
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
season = seasonNumber,
|
||||
episode = episodeNumber,
|
||||
manualSelection = manualSelection,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +225,7 @@ fun StreamsScreen(
|
|||
videoId = videoId,
|
||||
season = seasonNumber,
|
||||
episode = episodeNumber,
|
||||
manualSelection = manualSelection,
|
||||
)
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -17,12 +17,13 @@ IOS_PREFERRED_DEVICE_MODEL="iPhone 14 Pro"
|
|||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/run-mobile.sh android [e|p] [full|playstore]
|
||||
./scripts/run-mobile.sh android [full|playstore]
|
||||
./scripts/run-mobile.sh ios [s|p] [full|appstore]
|
||||
|
||||
Builds the debug app for the selected platform, installs it on all available
|
||||
Android emulators, a booted iOS simulator, or the configured iOS physical
|
||||
device, and launches the app.
|
||||
Android emulators or connected physical devices, a booted iOS simulator, or
|
||||
the configured iOS physical device, and launches the app.
|
||||
EOF
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +42,16 @@ booted_android_emulator_serials() {
|
|||
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() {
|
||||
local serial="$1"
|
||||
local boot_completed=""
|
||||
|
|
@ -102,14 +113,8 @@ validate_ios_distribution() {
|
|||
esac
|
||||
}
|
||||
|
||||
ios_derived_data_path() {
|
||||
local target="$1"
|
||||
local distribution="$2"
|
||||
echo "$IOS_DERIVED_DATA_BASE-$distribution-$target"
|
||||
}
|
||||
|
||||
run_android() {
|
||||
local flavor="${1:-full}"
|
||||
validate_android_flavor() {
|
||||
local flavor="$1"
|
||||
|
||||
case "$flavor" in
|
||||
full|playstore)
|
||||
|
|
@ -120,6 +125,78 @@ run_android() {
|
|||
exit 1
|
||||
;;
|
||||
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 emulator
|
||||
|
|
@ -134,19 +211,19 @@ run_android() {
|
|||
if [[ ${#booted_serials[@]} -gt 0 ]]; then
|
||||
echo "Using running Android emulators: ${booted_serials[*]}"
|
||||
else
|
||||
local avds=()
|
||||
while IFS= read -r avd_name; do
|
||||
[[ -n "$avd_name" ]] || continue
|
||||
avds+=("$avd_name")
|
||||
done < <(android_emulator_avds)
|
||||
local avds=()
|
||||
local avd_name
|
||||
while IFS= read -r avd_name; do
|
||||
[[ -n "$avd_name" ]] || continue
|
||||
avds+=("$avd_name")
|
||||
done < <(android_emulator_avds)
|
||||
|
||||
if [[ ${#avds[@]} -eq 0 ]]; then
|
||||
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
|
||||
fi
|
||||
|
||||
local avd_name
|
||||
for avd_name in "${avds[@]}"; do
|
||||
boot_android_emulator "$avd_name"
|
||||
done
|
||||
|
|
@ -171,34 +248,42 @@ run_android() {
|
|||
wait_for_android_emulator "$serial"
|
||||
done
|
||||
|
||||
local flavor_task_part
|
||||
local apk_path
|
||||
case "$flavor" in
|
||||
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
|
||||
apk_path="$(build_android_apk "$flavor")"
|
||||
|
||||
echo "Building Android $flavor debug APK..."
|
||||
"$GRADLEW" ":composeApp:assemble${flavor_task_part}Debug"
|
||||
install_and_launch_android "emulator" "$apk_path" "${booted_serials[@]}"
|
||||
}
|
||||
|
||||
if [[ ! -f "$apk_path" ]]; then
|
||||
echo "Expected APK not found at: $apk_path" >&2
|
||||
run_android_physical() {
|
||||
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
|
||||
fi
|
||||
|
||||
for serial in "${booted_serials[@]}"; do
|
||||
echo "Installing on emulator $serial..."
|
||||
adb -s "$serial" install -r "$apk_path"
|
||||
echo "Using connected Android physical devices: ${serials[*]}"
|
||||
|
||||
echo "Launching app on $serial..."
|
||||
adb -s "$serial" shell am start -n "$ANDROID_APP_ID/$ANDROID_ACTIVITY"
|
||||
for serial in "${serials[@]}"; do
|
||||
wait_for_android_device "$serial"
|
||||
done
|
||||
|
||||
local apk_path
|
||||
apk_path="$(build_android_apk "$flavor")"
|
||||
|
||||
install_and_launch_android "physical device" "$apk_path" "${serials[@]}"
|
||||
}
|
||||
|
||||
run_ios_simulator() {
|
||||
|
|
@ -296,11 +381,42 @@ main() {
|
|||
|
||||
case "$1" in
|
||||
android)
|
||||
if [[ $# -gt 2 ]]; then
|
||||
if [[ $# -gt 3 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
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)
|
||||
if [[ $# -lt 2 || $# -gt 3 ]]; then
|
||||
|
|
|
|||
Loading…
Reference in a new issue