diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index 7a239c8..f10bd04 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -18,6 +18,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -108,12 +110,17 @@ private fun ChannelsList( onOpenChannel: (String) -> Unit, onOpenDrawer: () -> Unit, ) { + val firstCardFocus = remember { androidx.compose.ui.focus.FocusRequester() } + LaunchedEffect(items.isNotEmpty()) { + if (items.isNotEmpty()) runCatching { firstCardFocus.requestFocus() } + } LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { itemsIndexed(items) { index, channel -> ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, onLeftAtStart = if (index == 0) onOpenDrawer else null, + modifier = if (index == 0) Modifier.focusRequester(firstCardFocus) else Modifier, ) if (index >= items.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.loadMore() } @@ -130,10 +137,15 @@ private fun ChannelsList( } @Composable -private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtStart: (() -> Unit)? = null) { +private fun ChannelCard( + channel: VideoChannelDto, + onClick: () -> Unit, + onLeftAtStart: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { Card( onClick = onClick, - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 6.dp), colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index dd5b06e..290d4ee 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -268,6 +268,14 @@ private fun FocusScrollRow( } } } + // On app start and destination switch: focus the FIRST card automatically + // (user request — otherwise invisible focus confuses "where am I"). + LaunchedEffect(videos.isNotEmpty()) { + if (videos.isNotEmpty() && focusedIndex == -1) { + kotlinx.coroutines.delay(150) // let images/measuring settle + runCatching { focusRequesters[0]?.requestFocus() } + } + } LaunchedEffect(focusedIndex) { if (focusedIndex > 0) { val visible = rowState.layoutInfo.visibleItemsInfo diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt index b52885d..491eaf1 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -2,6 +2,7 @@ package net.jeena.jtubetv.ui.screens import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectTapGestures @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.PersonalVideo @@ -37,6 +39,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.KeyEventType @@ -48,6 +51,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.Text +import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.list.ListSource @@ -212,38 +216,40 @@ private fun TopBar( .padding(horizontal = 24.dp, vertical = 10.dp), horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp), ) { - val searchFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } } - Box( + RoundTopButton( + bg = Color(0xFFCE2B2B), + icon = Icons.Filled.Search, + desc = "Search", + onClick = onSearchClick, + ) + // "Who is logged in" — account button with the username (SmartTube shows the user). + val accountFocused = remember { mutableStateOf(false) } + Row( modifier = Modifier - .size(44.dp) .clip(CircleShape) - .background(Color(0xFFCE2B2B)) - .focusRequester(searchFocus) + .onFocusChanged { accountFocused.value = it.isFocused } + .background(if (accountFocused.value) Color(0x33FFFFFF) else Color.Transparent) + .then( + if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape) + else Modifier + ) .focusable() - .clickable(onClick = onSearchClick), - contentAlignment = Alignment.Center, + .clickable(onClick = onAccountClick) + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), ) { Icon( - imageVector = Icons.Filled.Search, - contentDescription = "Search", - tint = Color.White, - ) - } - Box( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color(0xFF262626)) - .focusable() - .clickable(onClick = onAccountClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Filled.PersonalVideo, + imageVector = Icons.Filled.AccountCircle, contentDescription = "Menu", tint = Color(0xFFCCCCCC), ) + Text( + text = AppGraph.tokenStore.username?.substringBefore('@') ?: "Account", + color = Color(0xFFCCCCCC), + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 8.dp), + ) } Spacer(modifier = Modifier.weight(1f)) Text( @@ -255,6 +261,37 @@ private fun TopBar( } } +@Composable +private fun RoundTopButton( + bg: Color, + icon: ImageVector, + desc: String, + onClick: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .onFocusChanged { focused = it.isFocused } + .background(bg) + .then( + if (focused) Modifier.border( + 3.dp, Color.White, CircleShape + ) else Modifier + ) + .focusable() + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = desc, + tint = Color.White, + ) + } +} + /** * Slide-in menu drawer: destination list + sign out, readable on a dark background * (UX report: unselected grey was illegible). Selected item gets the red accent. diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index 1aa8fc5..8e3570e 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -59,7 +59,7 @@ fun VideoListScreen( // Which card was opened last — re-focus it when playback ends (UX report BUG-3). var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable(epoch) { mutableStateOf(-1) } LaunchedEffect(items.isNotEmpty(), source) { - if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() } + if (items.isNotEmpty()) runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() } } LaunchedEffect(restoreToken) { if (restoreToken > 0) { @@ -67,7 +67,7 @@ fun VideoListScreen( viewModel.refresh() } // focus the card that was playing; fall back to the first card - val target = requesters[lastOpenedIndex] ?: initialFocus + val target = requesters[lastOpenedIndex] ?: requesters[0] ?: initialFocus runCatching { target.requestFocus() } lastOpenedIndex = -1 } diff --git a/scripts/start-emulator.sh b/scripts/start-emulator.sh index 4ff36b1..c10e04c 100755 --- a/scripts/start-emulator.sh +++ b/scripts/start-emulator.sh @@ -5,8 +5,9 @@ # be tested over scrcpy (ATD/system quirks make scrcpy fail against headless # in-container emulators) or when you simply want to watch it. # -# The agent container bridges rutherford:5556 -> 127.0.0.1:5601 with socat, so after -# this script the emulator is reachable as: +# Boots on console :5600 (adbd :5601); the agent container bridges +# rutherford:5556 -> 127.0.0.1:5601 with socat, so after this script the emulator is +# reachable from anywhere as: # # adb connect rutherford:5556 && scrcpy -s rutherford:5556 # @@ -17,14 +18,34 @@ set -euo pipefail SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}" EMU="$SDK/emulator/emulator" ADB="$SDK/platform-tools/adb" +SDKM="$SDK/cmdline-tools/latest/bin/sdkmanager" +AVDM="$SDK/cmdline-tools/latest/bin/avdmanager" AVD="${1:-jtubesc}" -PORT=5600 # console port; adbd listens on 5601 (the container bridges 5556 -> 5601) +PORT=5600 +IMAGE="system-images;android-36;android-tv;x86_64" -[ -x "$EMU" ] || { echo "emulator not found at $EMU"; exit 1; } -[ -f "$HOME/.android/avd/$AVD.ini" ] || { - echo "AVD $AVD not found; list: (ls ~/.android/avd)"; exit 1; } +[ -x "$EMU" ] || { echo "emulator not found at $EMU — install Android SDK cmdline-tools first"; exit 1; } -# Free stale emulator instances holding our ports (only qemu from OUR SDK path). +# --- bootstrap: java, system image, AVD ------------------------------------- +if ! command -v java >/dev/null; then + echo "no java on PATH — required by sdkmanager/AVD tools" + echo " install it (Arch): sudo pacman -S --noconfirm jdk21-openjdk" + echo " then re-run this script" + exit 1 +fi + +if ! "$SDKM" --list_installed 2>/dev/null | grep -q "$IMAGE"; then + echo "installing $IMAGE (~1.5 GB, once)…" + yes | "$SDKM" "$IMAGE" > /dev/null + echo "installed" +fi + +if [ ! -f "$HOME/.android/avd/$AVD.ini" ]; then + echo "creating AVD '$AVD'…" + echo no | "$AVDM" create avd -n "$AVD" -k "$IMAGE" -d tv_1080p > /dev/null +fi + +# --- free stale emulator instances holding our port -------------------------- for pid in $(ps aux | awk '/[q]emu-system-x86_64/ {print $2}' 2>/dev/null); do if tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q "\-port $PORT"; then echo "Killing stale emulator pid $pid" @@ -34,13 +55,20 @@ done sleep 1 ACCEL=() -if [ -e /dev/kvm ]; then - ACCEL+=(-accel kvm); echo "/dev/kvm found — hardware acceleration on" +WINDOWED=1 +if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]; then + echo "desktop session detected — emulator window + GPU host" else - echo "no /dev/kvm — falling back to software rendering (slow)" + WINDOWED=0 + ACCEL+=(-no-window) + echo "headless (no display) — running without a window, swiftshader rendering" +fi +if [ -e /dev/kvm ]; then + [ $WINDOWED -eq 1 ] && ACCEL+=(-accel kvm) + echo "/dev/kvm found — hardware acceleration on" fi -echo "Booting AVD '$AVD' on console :$PORT (adbd :$((PORT+1))) …" +echo "Booting AVD '$AVD' …" setsid nohup "$EMU" -avd "$AVD" -port "$PORT" -gpu host "${ACCEL[@]:-}" \ -no-snapshot > /tmp/jtube-emu.log 2>&1 < /dev/null & echo "emulator PID $! — log: /tmp/jtube-emu.log" @@ -51,6 +79,31 @@ for i in $(seq 1 90); do sleep 5 done +# --- authorize remote adb clients headlessly --------------------------------- +# The host console transport is always trusted; use it to whitelist client keys +# so `adb connect rutherford:5556` from other machines works without a popup. +# Drop extra public keys into /tmp/jtube-adbkeys/ (one per file), e.g. from +# Chester: cat ~/.android/adbkey.pub > /tmp/jtube-adbkeys/chester.pub +"$ADB" -s "emulator-$PORT" root > /dev/null 2>&1 || true +for key_file in "$HOME/.android/adbkey.pub" /tmp/jtube-adbkeys/*.pub; do + [ -f "$key_file" ] || continue + "$ADB" -s "emulator-$PORT" push "$key_file" "/data/misc/adb/adb_keys" > /dev/null 2>&1 || \ + "$ADB" -s "emulator-$PORT" shell "cat > /data/misc/adb/adb_keys" < "$key_file" || { + echo "could not whitelist $key_file"; + } +done +"$ADB" -s "emulator-$PORT" shell "setprop service.adb.tcp.port 5601; stop adbd; start adbd" > /dev/null 2>&1 || true + +# --- the bridge: advertise adbd on the LAN as :5556 --------------------------- +if ! ss -tln 2>/dev/null | grep -q ":5556"; then + if command -v socat > /dev/null; then + setsid nohup socat TCP-LISTEN:5556,bind=0.0.0.0,fork,reuseaddr TCP:127.0.0.1:$((PORT+1)) > /tmp/jtube-socat.log 2>&1 < /dev/null & + echo "socat bridge listening on :5556 (pid $!)" + else + echo "install socat for the remote bridge: sudo pacman -S --noconfirm socat" + fi +fi + # Keep the JTube TV app current + running so scrcpy lands on it. APK="$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" if [ -f "$APK" ]; then