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 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) {

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.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,
)
}

View file

@ -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),
) {

View file

@ -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)
}
},
)
}
}
}
}

View file

@ -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

View file

@ -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,
)
},
),

View file

@ -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