diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 801435c8..3e53419c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 097f212f..a37711f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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(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, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index f1b04a11..32b3a03d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -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), ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt index 74dd9641..5b52a708 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt @@ -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) + } + }, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index e06edfa2..1c5a66e0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 4896fc5b..87532cfe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -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, ) }, ), diff --git a/scripts/run-mobile.sh b/scripts/run-mobile.sh index 8dacdf1e..b6b0e147 100755 --- a/scripts/run-mobile.sh +++ b/scripts/run-mobile.sh @@ -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