From a71de0796955d2691461ec149c89e1d8291ed34c Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 08:14:15 +0900 Subject: [PATCH 1/4] emu: Stop silent early exits in the emulator start script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script's set -euo pipefail made it die without any visible error in two places: an empty /tmp/jtube-adbkeys/ failed the glob cat (the error itself was discarded, so the output just stopped mid-script — this hits any host after a reboot), and the boot-wait loop was killed on its first iteration because adb exits non-zero while the device is still booting. Both were swallowing the whole && chain (adb connect, scrcpy), making it look like the emulator was broken when it was fine or about to be. The stale bridge-emulator.sh duplicate (different port, broken vendor keys path) is removed in the same spirit — keeping it invites running the wrong script. Docs updated to match the one script. --- docs/agent-container.md | 9 +++++++- scripts/bridge-emulator.sh | 42 -------------------------------------- scripts/start-emulator.sh | 20 ++++++++++++------ 3 files changed, 22 insertions(+), 49 deletions(-) delete mode 100755 scripts/bridge-emulator.sh diff --git a/docs/agent-container.md b/docs/agent-container.md index 48a15d2..5d6b66f 100644 --- a/docs/agent-container.md +++ b/docs/agent-container.md @@ -9,6 +9,12 @@ emulator works, and what needs to exist again after a rebuild. `scripts/start-emulator.sh`). **Headless is the default**; set `JTUBE_EMU_MODE=windowed` if you want the Qt window (e.g. for desktop interaction testing). View it via scrcpy either way. +- **Only the host can start the emulator** (by design — no SSH from the + container to the host, no SDK/KVM inside the container). If the agent + finds no emulator, it should tell the user to run + `./scripts/start-emulator.sh` on the host rather than try to work around + it. Reachability check: `adb connect rutherford:5556` then + `adb -s rutherford:5556 shell getprop sys.boot_completed` → `1`. - **Agent container**: image `agent-container:latest`, container name pattern `ac--`, launched with **`--network host`** — it shares the host's network namespace. This is load-bearing: see below. @@ -46,7 +52,8 @@ So inside the container: `/tmp/jtube-adbkeys/*.pub`) and launches the emulator with `ADB_VENDOR_KEYS` pointing at it. To trust a new client: put its `~/.android/adbkey.pub` into `/tmp/jtube-adbkeys/` **before** launching. -- Logs: `/tmp/jtube-emu.log` (QEMU) and `/tmp/jtube-emu-run.log` (script). +- Logs: `/tmp/jtube-emu.log` (QEMU), `/tmp/jtube-socat.log` (bridge), + `/tmp/jtube-adbkeys-combined` (rebuilt each launch). - Viewing: `scrcpy -s emulator-5600` on the host, or from any LAN machine: `adb connect rutherford:5556 && scrcpy -s 127.0.0.1:5556`. diff --git a/scripts/bridge-emulator.sh b/scripts/bridge-emulator.sh deleted file mode 100755 index 5fc8ed2..0000000 --- a/scripts/bridge-emulator.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# -# bridge-emulator.sh — (host-side) expose the emulator's adbd to the LAN so agents -# inside the container/other machines can use it: binds :5557 -> 127.0.0.1:5601. -# The emulator console transport (emulator-5600) stays local-only; this bridges the -# adbd TCP port 5601. -set -euo pipefail -ADBD_PORT=5601 -BRIDGE_PORT=5557 - -# start emulator if not running -ADB="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/platform-tools/adb" -if ! "$ADB" devices | grep -q "emulator-5600.*device"; then - echo "emulator-5600 not running — starting it…" - ADB_VENDOR_KEYS="$HOME/.android/adbkey" setsid nohup \ - "${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/emulator/emulator" -avd jtubesc -port 5600 \ - -no-window -gpu swiftshader_indirect -no-snapshot -no-audio -no-boot-anim \ - > /tmp/jtube-emu.log 2>&1 < /dev/null & - echo "waiting for boot (log: /tmp/jtube-emu.log)…" - for i in $(seq 1 60); do - ok=$("$ADB" -s emulator-5600 shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') - [ "$ok" = "1" ] && { echo "BOOTED"; break; } - sleep 5 - done -fi - -# keep JTube TV installed + running -APK="$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" -if [ -f "$APK" ]; then - "$ADB" -s emulator-5600 install -r "$APK" >/dev/null && echo "JTube TV installed" - "$ADB" -s emulator-5600 shell am start -n net.jeena.jtubetv/.MainActivity && echo "app started" -fi - -# the bridge (bind all LAN addresses) -if ! ss -tln 2>/dev/null | grep -q ":$BRIDGE_PORT"; then - setsid nohup socat TCP-LISTEN:$BRIDGE_PORT,bind=0.0.0.0,fork,reuseaddr \ - TCP:127.0.0.1:$ADBD_PORT > /tmp/jtube-bridge.log 2>&1 < /dev/null & - echo "bridge: :$BRIDGE_PORT -> 127.0.0.1:$ADBD_PORT (pid $!)" -fi - -echo "From this host: adb connect localhost:$BRIDGE_PORT" -echo "From other machines: adb connect rutherford:$BRIDGE_PORT" \ No newline at end of file diff --git a/scripts/start-emulator.sh b/scripts/start-emulator.sh index 5995100..fdb9af7 100755 --- a/scripts/start-emulator.sh +++ b/scripts/start-emulator.sh @@ -62,11 +62,13 @@ if [ "$MODE" = windowed ]; then else echo "JTUBE_EMU_MODE=windowed but no display detected — falling back to headless" WINDOWED=0 - ACCEL+=(-no-window) + ACCEL+=(-no-window -gpu swiftshader_indirect) fi else WINDOWED=0 - ACCEL+=(-no-window) + # Headless has no X display, so -gpu host/GLX can't init EGL ("Failed to get EGL + # display") and the emulator dies on launch — fall back to software rendering. + ACCEL+=(-no-window -gpu swiftshader_indirect) echo "headless (default; JTUBE_EMU_MODE=windowed for a window) — no emulator window" fi if [ -e /dev/kvm ]; then @@ -79,21 +81,27 @@ fi # blocked, so remote keys are trusted via ADB_VENDOR_KEYS at launch instead. # Drop extra public keys into /tmp/jtube-adbkeys/ (one per file), e.g. from the # agent container: docker exec sh -c 'cat ~/.android/adbkey.pub' > /tmp/jtube-adbkeys/agent-container.pub +mkdir -p /tmp/jtube-adbkeys VKEYS=/tmp/jtube-adbkeys-combined { cat "$HOME/.android/adbkey.pub" - cat /tmp/jtube-adbkeys/*.pub 2>/dev/null + # Optional extra keys — must not fail the script under `set -e` when absent. + shopt -s nullglob + for k in /tmp/jtube-adbkeys/*.pub; do cat "$k"; done + shopt -u nullglob } > "$VKEYS" chmod 600 "$VKEYS" echo "Booting AVD '$AVD' …" -ADB_VENDOR_KEYS="$VKEYS" setsid nohup "$EMU" -avd "$AVD" -port "$PORT" -gpu host "${ACCEL[@]:-}" \ +ADB_VENDOR_KEYS="$VKEYS" setsid nohup "$EMU" -avd "$AVD" -port "$PORT" "${ACCEL[@]:-}" \ -no-snapshot > /tmp/jtube-emu.log 2>&1 < /dev/null & echo "emulator PID $! — log: /tmp/jtube-emu.log" for i in $(seq 1 90); do - ok=$("$ADB" -s "emulator-$PORT" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') - [ "$ok" = "1" ] && { echo "BOOTED"; break; } + # adb exits non-zero while the device is still booting — don't let + # pipefail+set -e kill the wait loop. + ok=$( ("$ADB" -s "emulator-$PORT" shell getprop sys.boot_completed 2>/dev/null || true) | tr -d '\r') + if [ "$ok" = "1" ]; then echo "BOOTED"; break; fi sleep 5 done From 15a481b7c0d9b20b7510278ebddc583f69d2104f Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 00:05:10 +0000 Subject: [PATCH 2/4] profile: Fetch the user profile on every app start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login flow stored displayName/avatar inside one swallow-all try that serverConfig() also had to survive first, and /users/me carries both in its nested account object on current PeerTube versions (verified live against 8.3.1) — so stored sessions kept showing the raw username and no avatar. The profile now refreshes after login and whenever an existing session starts, and the relative avatar fallback no longer points into /api/v1. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 68 ++++++++++--------- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 9 +++ .../java/net/jeena/jtubetv/ui/AppViewModel.kt | 21 +++++- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 8922c39..496ca1f 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -69,45 +69,47 @@ class PeerTubeRepository( expiresAtMillis = System.currentTimeMillis() + (token.expiresIn ?: 86400L) * 1000L, ) - // Login accepts emails; resolve the real account name for /accounts/{handle}. - // The request MUST carry the fresh token (UX report BUG-1: unauthenticated - // /users/me returns 401 and the fallback breaks Channels for email logins). - try { - val authed = plainOkHttp().newBuilder() - .addInterceptor { chain -> - chain.proceed( - chain.request().newBuilder() - .header("Authorization", "Bearer ${token.accessToken}") - .build() - ) - } - .build() - val meApi = Retrofit.Builder() - .baseUrl(apiUrlOf(base) + "/") - .client(authed) - .addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType())) - .build() - .create(PeerTubeApi::class.java) - store.instanceName = try { - meApi.serverConfig().instance.name - } catch (_: Exception) { null } - val me = meApi.me() - store.accountName = me.username - store.userDisplayName = me.displayName ?: me.username - me.avatars.maxByOrNull { it.width }?.let { avatar -> - store.userAvatarUrl = avatar.fileUrl - ?.takeIf { url -> url.startsWith("http") } - ?: apiUrlOf(base) + avatar.path - } - } catch (_: Exception) { - store.accountName = username.substringBefore('@') // best effort fallback - } + // Login accepts email addresses too; the login input is not a valid + // handle for /accounts/{handle}, so default it, refreshMyProfile() + // corrects it right after (and on every app start). + store.accountName = username.substringBefore('@') + store.instanceName = runCatching { network.api.serverConfig().instance.name }.getOrNull() + // /users/me fills displayName + avatar; failure is not fatal because + // the profile is refreshed again on the next app start. + refreshMyProfile() } suspend fun me(): Result = runCatching { network.api.me() } suspend fun serverConfig(): Result = runCatching { network.api.serverConfig() } + /** Display name + avatar URL of the logged-in user, from /users/me. */ + data class UserProfile(val displayName: String, val avatarUrl: String?) + + /** + * Fetches and persists the profile of the logged-in user. Called after login + * AND on every app start with a stored session: it used to run only at login + * inside a swallow-all try, so sessions from earlier app versions showed the + * raw username and no avatar. Returns null on failure. + */ + suspend fun refreshMyProfile(): UserProfile? { + // /users/me carries displayName + avatars inside `account` (verified + // live v8.3.1); the flat fields exist on some older versions only. + val me = runCatching { network.api.me() }.getOrNull() ?: return null + val name = me.account?.displayName ?: me.displayName ?: me.username + val avatars = me.account?.avatars?.takeIf { it.isNotEmpty() } ?: me.avatars + store.accountName = me.username + val displayName = name + store.userDisplayName = displayName + val avatarUrl = avatars.maxByOrNull { it.width }?.let { avatar -> + avatar.fileUrl?.takeIf { it.startsWith("http") } + // lazy-static avatars live at the instance root, NOT under /api/v1 + ?: store.baseUrl?.let { base -> normalizeBaseUrl(base) + avatar.path } + } + store.userAvatarUrl = avatarUrl + return UserProfile(displayName, avatarUrl) + } + /** Display name of a (possibly foreign) instance, via that instance's /config. */ suspend fun instanceName(rawHost: String): Result = runCatching { val base = "https://" + rawHost diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 91d3777..4507861 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -165,6 +165,15 @@ data class ChannelStats( @Serializable data class UserDto( val username: String, + // /users/me does NOT return these at top level on newer PeerTube versions — + // the account object carries displayName + avatars (verified live v8.3.1). val displayName: String? = null, val avatars: List = emptyList(), + val account: UserAccountDto? = null, +) + +@Serializable +data class UserAccountDto( + @SerialName("displayName") val displayName: String? = null, + val avatars: List = emptyList(), ) \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt index e6e542f..8bc14bd 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -19,6 +19,10 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit private val _sessionState = MutableStateFlow(SessionState.Loading) val sessionState: StateFlow = _sessionState + /** Display name + avatar of the logged-in user (for the drawer header). */ + private val _userProfile = MutableStateFlow(null) + val userProfile: StateFlow = _userProfile + /** * Bumped on every logout and successful login: all list ViewModels and saved * UI state are keyed by it, so switching accounts never shows the previous @@ -30,8 +34,19 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit val loggingIn = MutableStateFlow(false) init { - _sessionState.value = - if (repository.hasSession()) SessionState.Ready else SessionState.NeedsSetup + if (repository.hasSession()) { + _sessionState.value = SessionState.Ready + // Re-fetch the profile on every app start: stored sessions from older + // app versions may lack displayName/avatar (or the login-time fetch + // failed back then). Failure keeps the stored/fallback values. + refreshProfile() + } else { + _sessionState.value = SessionState.NeedsSetup + } + } + + private fun refreshProfile() { + viewModelScope.launch { _userProfile.value = repository.refreshMyProfile() } } fun login(baseUrl: String, username: String, password: String) { @@ -43,6 +58,7 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit if (result.isSuccess) { _sessionState.value = SessionState.Ready sessionEpoch.value += 1 + refreshProfile() } else { loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) { "Invalid credentials" @@ -67,6 +83,7 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit /** Clears the stored session; the user returns to the setup screen. */ fun logout() { repository.signOut() + _userProfile.value = null _sessionState.value = SessionState.NeedsSetup sessionEpoch.value += 1 } From a6dff3435daf60c3f4d392f98991447be76da8ef Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 00:05:19 +0000 Subject: [PATCH 3/4] player: Own the controls UI end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Space and Enter now toggle playback while playing (the remote OK always did), and the native PlayerView controller is switched off completely: hovering the video with a mouse revealed the old play UI with skip buttons and a settings gear, which must never appear. Our SmartTube-style overlay is the only controls surface — persistent when paused, shown transiently for 5 s while playing — with the transport icon row dropped, position/duration in one style right of the bar, the absolute published date under the title and a clickable channel icon that opens the channel's video page. The loading indicator now tracks the player's real buffering state (STATE_READY) instead of ending with metadata resolution, so the window is never black without an indicator. Closes #8 --- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 476 +++++++++--------- 1 file changed, 233 insertions(+), 243 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index 8254ee1..b40baf9 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -11,6 +11,7 @@ import androidx.activity.compose.BackHandler import androidx.media3.ui.AspectRatioFrameLayout import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,6 +26,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Replay10 import androidx.compose.material3.CircularProgressIndicator @@ -45,12 +47,15 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -172,6 +177,9 @@ class PlayerViewModel( fun PlayerScreen( shortUuid: String, onBack: () -> Unit, + // From the paused/controls overlay's channel icon: (handle, displayName), + // must also exit the player screen first. + onOpenChannel: (String, String) -> Unit = { _, _ -> }, viewModel: PlayerViewModel = viewModel( key = "player$shortUuid", factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore), @@ -189,7 +197,7 @@ fun PlayerScreen( when (val s = state) { is PlayerUiState.Loading -> PlayerLoading() is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack) - is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack) + is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel) } } @@ -251,16 +259,19 @@ private fun PlayerSurface( viewModel: PlayerViewModel, shortUuid: String, onBack: () -> Unit, + onOpenChannel: (String, String) -> Unit, ) { val context = LocalContext.current val keyFocus = remember { FocusRequester() } var paused by remember { mutableStateOf(false) } - var playerViewRef by remember { mutableStateOf(null) } + // True while the player hasn't reached STATE_READY yet (initial buffering OR + // a re-buffer after seeking) — shows an indicator until media really plays + // (issue #8: the Loading screen ended too early and the rest was black). + var buffering by remember { mutableStateOf(true) } // Key press that was consumed at ACTION_DOWN — its trailing ACTION_UP must not // re-reveal the native controller right after a play/pause toggle. var consumedDownKey by remember { mutableStateOf(0) } - // Overlay focus/position state for the paused controls (#4). - val playPauseFocus = remember { FocusRequester() } + // Overlay focus/position state for the controls (#4). val seekBarFocus = remember { FocusRequester() } val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) } @@ -290,12 +301,12 @@ private fun PlayerSurface( .build() } - // Pause: move focus into the overlay (the Play button, like SmartTube); resume: + // Pause: move focus into the overlay (the seek bar — the only control); resume: // back to the plain key handler so the next OK pauses again. LaunchedEffect(paused) { if (paused) { positionMs.longValue = player.currentPosition - runCatching { playPauseFocus.requestFocus() } + runCatching { seekBarFocus.requestFocus() } } else { runCatching { keyFocus.requestFocus() } } @@ -349,11 +360,12 @@ private fun PlayerSurface( player.addListener(object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { paused = !isPlaying - if (!isPlaying) playerViewRef?.hideController() // overlay replaces the bar + } + + override fun onPlaybackStateChanged(playbackState: Int) { + buffering = playbackState != Player.STATE_READY } }) - // Controller does not flash at start; only UP/DOWN/other keys reveal it. - playerViewRef?.hideController() onDispose { viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt()) player.release() @@ -390,7 +402,10 @@ private fun PlayerSurface( onBack() true } - KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + // Keyboard users: Space/Enter must pause/resume like the remote OK. + KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER -> { player.playWhenReady = !player.playWhenReady true } @@ -407,21 +422,38 @@ private fun PlayerSurface( } else false } + // The native PlayerView controller is fully disabled (useController = false, + // mouse hover must NOT reveal it) — our own overlay is the only controls UI: + // persistent while paused, and transient (5 s) on any non-media key while + // playing. + var controlsVisible by remember { mutableStateOf(false) } + var surfaceFocused by remember { mutableStateOf(true) } + LaunchedEffect(controlsVisible, paused) { + if (controlsVisible && !paused) { + kotlinx.coroutines.delay(5000) + controlsVisible = false + runCatching { keyFocus.requestFocus() } + } + } + Box( modifier = Modifier .fillMaxSize() .background(Color.Black) .focusRequester(keyFocus) + .onFocusChanged { surfaceFocused = it.isFocused } .focusable() .onPreviewKeyEvent { ke -> val code = ke.nativeKeyEvent.keyCode when { - paused && (code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE) -> { + // BACK/Esc leave the player in any state, from any focus. + code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> { onBack(); true } - // While paused the overlay handles arrows + CENTER (#4); unhandled - // keys must not wake the native bar either. + // While paused our overlay has focus and handles everything. paused -> false + // Transient controls hold focus while playing: let them move it. + controlsVisible && !surfaceFocused -> false else -> { val handled = handleKey(ke.nativeKeyEvent) if (!handled) { @@ -430,15 +462,17 @@ private fun PlayerSurface( ) { consumedDownKey = 0 // trailing UP of a handled press } else { - // UP/DOWN etc. reveal the controls briefly - playerViewRef?.showController() + // UP/DOWN etc. reveal OUR controls, focus the Play button. + controlsVisible = true + if (surfaceFocused) { + runCatching { seekBarFocus.requestFocus() } + } } true } else { if (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { consumedDownKey = ke.nativeKeyEvent.keyCode } - if (player.playWhenReady) playerViewRef?.hideController() true } } @@ -452,27 +486,41 @@ private fun PlayerSurface( // screen regardless of the letterbox geometry (issue #1). (LayoutInflater.from(ctx) .inflate(R.layout.player_video_view, null) as PlayerView).apply { - useController = true + // Native controller must never appear (issue: hovering with the + // mouse showed the old play UI) — we own the controls entirely. + useController = false resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT this.player = player isFocusable = false - // We draw our own pause controls (#4); the native bar must only - // appear where we call showController explicitly. - setControllerAutoShow(false) descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS - playerViewRef = this + // A click (mouse) reveals our overlay instead. + setOnClickListener { controlsVisible = true } } }, modifier = Modifier.fillMaxSize(), ) - // Paused → SmartTube-style controls: seek bar over transport buttons over info. - if (paused) { + // Cover the picture with the loading indicator until the player is really + // ready to render — metadata resolution alone (the Loading state) is not + // enough (issue #8). + if (buffering) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black) + .pointerInput(Unit) {}, // swallow taps/clicks while covering + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + } + // Controls: SmartTube-style (title + channel above, then the seek bar). + // Persistent when paused, transient while playing. + if (paused || controlsVisible) { PausedControls( detail = s.detail, player = player, positionMs = positionMs, - playPauseFocus = playPauseFocus, + playing = !paused, seekBarFocus = seekBarFocus, + onOpenChannel = onOpenChannel, modifier = Modifier.align(Alignment.BottomCenter), ) } @@ -492,257 +540,199 @@ private fun seekOverlay( } /** - * SmartTube-style paused controls (#4): a big always-visible seek bar ABOVE the info - * bar (it used to hide behind the semi-transparent info panel), transport buttons the - * D-pad can actually select (arrows pick, OK activates), then the info panel. + * SmartTube-style controls (#4): channel icon + title + channel line above the + * seek bar, position/duration below it; on a gradient so the video stays visible. + * Persistent when paused, transient while playing (any non-media key reveals it, + * auto-hides after 5 s). */ @Composable private fun PausedControls( detail: VideoDetailDto, player: ExoPlayer, positionMs: androidx.compose.runtime.MutableLongState, - playPauseFocus: FocusRequester, + // True while play runs (transient overlay): keeps the times ticking; + // false = persistent paused overlay. + playing: Boolean, seekBarFocus: FocusRequester, + // (handle, displayName) — opens the channel's videos page; exits the player. + onOpenChannel: (String, String) -> Unit = { _, _ -> }, modifier: Modifier = Modifier, ) { val durationMs = detail.duration * 1000L + // While playing, the position text must keep counting. + LaunchedEffect(playing) { + while (playing) { + positionMs.longValue = player.currentPosition + kotlinx.coroutines.delay(1000) + } + } + // SmartTube-style: controls sit on a gradient over the video (the picture + // stays visible above), no opaque slab, no boxed buttons, no label text. Column( modifier = modifier .fillMaxWidth() - .background(Color(0xDC000000)) - .padding(horizontal = 24.dp, vertical = 18.dp), + .background( + Brush.verticalGradient( + 0f to Color(0x00000000), + 0.25f to Color(0xCC000000), + 1f to Color(0xF0000000), + ) + ) + .padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 14.dp), ) { + // Channel icon: a button — opens the channel's videos page (its avatar is + // optional: federated channels may have none). + var channelFocused by remember { mutableStateOf(false) } + val channelFocus = remember { FocusRequester() } + var channelModifier: Modifier = Modifier.size(48.dp) + channelModifier = channelModifier + .focusRequester(channelFocus) + .onFocusChanged { channelFocused = it.isFocused } + .onPreviewKeyEvent { ke -> + if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (ke.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_DOWN -> { + runCatching { seekBarFocus.requestFocus() }; true + } + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> true + else -> false + } + } + .dpadCenterActivatable { + onOpenChannel( + detail.channel.name + "@" + (detail.channel.host ?: ""), + detail.channel.displayName, + ) + } + .focusable() + .then(if (channelFocused) Modifier.border(3.dp, Color.White, CircleShape) else Modifier) + Row(verticalAlignment = Alignment.CenterVertically) { + val avatar = detail.channel.avatars.maxByOrNull { it.width } + if (avatar != null) { + coil.compose.AsyncImage( + model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), + contentDescription = "Open channel", + contentScale = ContentScale.Crop, + modifier = channelModifier + .clip(CircleShape), + ) + } + Column(modifier = Modifier.padding(start = if (avatar != null) 14.dp else 0.dp)) { + Text( + text = detail.name, + color = Color.White, + style = MaterialTheme.typography.titleLarge, + maxLines = 2, + ) + Text( + text = detail.channel.displayName + " · " + formatViews(detail.views) + + " · " + formatPublished(detail.publishedAt), + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + // The seek bar is the only control: focused = scrub, OK = play/pause. SeekBarRow( positionMs = positionMs, durationMs = durationMs, seekBarFocus = seekBarFocus, - playPauseFocus = playPauseFocus, player = player, + // UP climbs to the channel icon when it exists. + channelFocus = if (detail.channel.avatars.isNotEmpty()) channelFocus else null, + modifier = Modifier.padding(top = 16.dp), + ) + // Position / duration under the bar, right-aligned. + Text( + text = formatDuration((positionMs.longValue / 1000).toInt()) + " / " + + formatDuration((durationMs / 1000).toInt()), + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .align(Alignment.End) + .padding(top = 8.dp), ) - Row( - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 14.dp), - ) { - PauseControl( - icon = Icons.Filled.Replay10, - label = "Back 10 s", - seekBarFocus = seekBarFocus, - isLeftEdge = true, - isActive = false, - onClick = { seekOverlay(player, positionMs, -10_000L, durationMs) }, - ) - PauseControl( - icon = Icons.Filled.PlayArrow, - label = "Play", - seekBarFocus = seekBarFocus, - focusRequester = playPauseFocus, - isActive = true, - onClick = { player.playWhenReady = true }, - ) - PauseControl( - icon = Icons.Filled.Forward10, - label = "Forward 10 s", - seekBarFocus = seekBarFocus, - isRightEdge = true, - isActive = false, - onClick = { seekOverlay(player, positionMs, 10_000L, durationMs) }, - ) - } - InfoRow(detail, positionMs.longValue, Modifier.padding(top = 16.dp)) } } -/** Focusable + seekable progress bar: arrows scrub ±10 s, OK starts playback. */ +/** Focusable + seekable progress bar: arrows scrub ±10 s, OK toggles playback. */ @Composable private fun SeekBarRow( positionMs: androidx.compose.runtime.MutableLongState, durationMs: Long, seekBarFocus: FocusRequester, - playPauseFocus: FocusRequester, player: ExoPlayer, + channelFocus: FocusRequester? = null, // UP target: the channel icon (or stay) + modifier: Modifier = Modifier, ) { var barFocused by remember { mutableStateOf(false) } val fraction = if (durationMs > 0) (positionMs.longValue.toFloat() / durationMs).coerceIn(0f, 1f) else 0f - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = formatDuration((positionMs.longValue / 1000).toInt()), - color = Color.White, - style = MaterialTheme.typography.labelMedium, - ) - Box( - modifier = Modifier - .weight(1f) - .height(44.dp) - .focusRequester(seekBarFocus) - .onFocusChanged { barFocused = it.isFocused } - .onPreviewKeyEvent { ke -> - if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (ke.nativeKeyEvent.keyCode) { - KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> { - seekOverlay(player, positionMs, -10_000L, durationMs); true - } - KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { - seekOverlay(player, positionMs, 10_000L, durationMs); true - } - KeyEvent.KEYCODE_DPAD_UP -> true // topmost control — stay here - KeyEvent.KEYCODE_DPAD_DOWN -> { - runCatching { playPauseFocus.requestFocus() }; true - } - KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, - KeyEvent.KEYCODE_MEDIA_PLAY -> { - player.playWhenReady = true // OK on the bar starts playback - true - } - else -> false + Box( + modifier = modifier + .fillMaxWidth() + .height(44.dp) + .focusRequester(seekBarFocus) + .onFocusChanged { barFocused = it.isFocused } + .onPreviewKeyEvent { ke -> + if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (ke.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> { + seekOverlay(player, positionMs, -10_000L, durationMs); true } - } - .focusable() - .background( - if (barFocused) Color(0x22FFFFFF) else Color.Transparent, - RoundedCornerShape(22.dp), - ) - .padding(horizontal = 12.dp), - ) { - Canvas( - modifier = Modifier - .align(Alignment.CenterStart) - .fillMaxWidth() - .height(if (barFocused) 14.dp else 10.dp) - // rounded ends for track and progress - .clip(RoundedCornerShape(7.dp)), - ) { - drawRoundRect(color = Color(0x44FFFFFF), cornerRadius = CornerRadius(99f)) - val w = size.width * fraction - if (w > 1f) { - drawRoundRect( - color = Color(0xFFCE2B2B), - size = Size(w, size.height), - cornerRadius = CornerRadius(99f), - ) + KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + seekOverlay(player, positionMs, 10_000L, durationMs); true + } + KeyEvent.KEYCODE_DPAD_UP -> channelFocus?.let { runCatching { it.requestFocus() }; true } ?: false + KeyEvent.KEYCODE_DPAD_DOWN -> true + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE, + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> { + player.playWhenReady = !player.playWhenReady + true + } + else -> false } } - } - Text( - text = formatDuration((durationMs / 1000).toInt()), - color = Color(0xFFAAAAAA), - style = MaterialTheme.typography.labelMedium, - ) - } -} - -/** Round transport button; arrows move focus (bar above via [seekBarFocus]), OK clicks. */ -@Composable -private fun PauseControl( - icon: androidx.compose.ui.graphics.vector.ImageVector, - label: String, - seekBarFocus: FocusRequester, - onClick: () -> Unit, - focusRequester: FocusRequester? = null, - isLeftEdge: Boolean = false, - isRightEdge: Boolean = false, - isActive: Boolean = false, -) { - var focused by remember { mutableStateOf(false) } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - var m: Modifier = Modifier.size(92.dp) - if (focusRequester != null) m = m.focusRequester(focusRequester) - Box( - modifier = m - .onFocusChanged { focused = it.isFocused } - .onPreviewKeyEvent { ke -> - if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (ke.nativeKeyEvent.keyCode) { - KeyEvent.KEYCODE_DPAD_UP -> { - runCatching { seekBarFocus.requestFocus() }; true - } - KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> true - KeyEvent.KEYCODE_DPAD_LEFT -> isLeftEdge - KeyEvent.KEYCODE_DPAD_RIGHT -> isRightEdge - else -> false - } - } - .dpadCenterActivatable(onClick) - .focusable() - .background( - when { - focused && isActive -> Color(0xFFCE2B2B) - focused -> Color.White - else -> Color(0xFF2A2A2A) - }, - RoundedCornerShape(14.dp), - ), - contentAlignment = Alignment.Center, - ) { - androidx.compose.material3.Icon( - imageVector = icon, - contentDescription = null, - tint = when { - focused && isActive -> Color.White - focused -> Color(0xFF101010) - else -> Color.White - }, - modifier = Modifier.size(42.dp), - ) - } - Text( - text = label, - color = if (focused) Color.White else Color(0xFFB0B0B0), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(top = 6.dp), - ) - } -} - -/** Bottom info panel: channel (icon + name), title, views, date, position. */ -@Composable -private fun InfoRow(detail: VideoDetailDto, positionMs: Long, modifier: Modifier = Modifier) { - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - val avatar = detail.channel.avatars.maxByOrNull { it.width } - if (avatar != null) { - coil.compose.AsyncImage( - model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(64.dp) - .clip(CircleShape), - ) - } - Column( + .focusable() + .padding(horizontal = 4.dp), + ) { + // Thin SmartTube-style track: rounded, red progress, white round knob. + Canvas( modifier = Modifier - .weight(1f) - .padding(start = 16.dp), + .align(Alignment.CenterStart) + .fillMaxWidth() + .height(if (barFocused) 12.dp else 6.dp) + .clip(RoundedCornerShape(7.dp)), ) { - Text( - text = detail.name, - color = Color.White, - style = MaterialTheme.typography.titleLarge, - maxLines = 2, - ) - Text( - text = detail.channel.displayName, - color = Color(0xFFCCCCCC), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = formatViews(detail.views) + " · " + formatAge(detail.publishedAt), - color = Color(0xFF9A9A9A), - style = MaterialTheme.typography.bodySmall, - ) + drawRoundRect(color = Color(0x55FFFFFF), cornerRadius = CornerRadius(99f)) + val w = size.width * fraction + if (w > 1f) { + drawRoundRect( + color = Color(0xFFCE2B2B), + size = Size(w, size.height), + cornerRadius = CornerRadius(99f), + ) + } } - Column(horizontalAlignment = Alignment.End) { - Text( - text = formatDuration((positionMs / 1000).toInt()) + " · " + formatDuration(detail.duration), - color = Color(0xFFCCCCCC), - style = MaterialTheme.typography.bodyMedium, + // White round knob on top of the track, always visible (SmartTube). + Canvas( + modifier = Modifier + .align(Alignment.CenterStart) + .fillMaxWidth() + .height((if (barFocused) 18.dp else 14.dp).coerceAtLeast(14.dp)), + ) { + drawCircle( + color = Color.White, + radius = (if (barFocused) 9.dp else 7.dp).toPx(), + center = Offset(fraction * size.width, size.height / 2f), ) - Text(text = "Paused", color = Color(0xFFCE2B2B), style = MaterialTheme.typography.labelLarge) } } } + + private fun formatViews(views: Long): String = when { views >= 1_000_000 -> "%.1fM views".format(views / 1_000_000.0) @@ -751,23 +741,23 @@ private fun formatViews(views: Long): String = else -> "$views views" } -private fun formatAge(iso: String?): String { +/** Absolute published date + time in the device locale (relative age was too coarse). */ +private fun formatPublished(iso: String?): String { if (iso.isNullOrBlank()) return "" return try { - val age = java.time.Duration.between(java.time.Instant.parse(iso), java.time.Instant.now()) - val days = age.toDays() - when { - days >= 365 -> "${days / 365}y ago" - days >= 30 -> "${days / 30}mo ago" - days >= 1 -> "${days}d ago" - age.toHours() >= 1 -> "${age.toHours()}h ago" - else -> "just now" - } + java.time.ZonedDateTime.ofInstant( + java.time.Instant.parse(iso), + java.time.ZoneId.systemDefault(), + ).format( + java.time.format.DateTimeFormatter.ofPattern("d MMM yyyy, HH:mm", java.util.Locale.getDefault()) + ) } catch (e: java.time.format.DateTimeParseException) { - "" + e.printStackTrace() + "" // unknown date → omit it } } + private fun formatDuration(seconds: Int): String { val h = seconds / 3600 val m = (seconds % 3600) / 60 From fdc570ac9ef017fc58bc2092757a47686fb4f271 Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 00:05:30 +0000 Subject: [PATCH 4/4] home: Title bar with account and channel pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header-removal experiment went too far: the app now has a slim persistent title bar — logo + "JTube TV" on the left (it anchors the layout while the drawer is open), avatar + display name on the right as a purely visual indicator so it never steals D-pad focus. LEFT from the first card of every row opens the drawer (the subscriptions row silently skipped it, and the grid only wired the very first item), and UP no longer surprises by opening anything. The channel page that the Channels screen and the player's channel icon point at never actually rendered — ListSource.Channel, its repository call and the header state all existed without a UI. Videos of one channel now open as a grid with banner, avatar, name and handle; page headers (search field, channel banner) live inside the grid so they scroll along instead of stacking under the cards. Closes #7 --- BACKLOG.md | 8 + .../jtubetv/ui/list/VideoListViewModel.kt | 4 +- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 2 +- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 298 +++++++++--------- .../ui/screens/list/VideoListScreen.kt | 83 ++++- 5 files changed, 245 insertions(+), 150 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index f9e6182..4b29b20 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -13,6 +13,14 @@ Status vocabulary: `todo` · `doing` · `done`. | 4 | Subscription videos | done | Verified incl. remote (federated) channels | | 5 | Watch history (server) | done | read-only grid | | 6 | Playback + report to server history | done | ExoPlayer HLS w/ `videoFileToken` for non-public media; watching reports every ~20 s verified server-side; resume position retrievable | +| 7 | Channel videos view (channel page) | done | Banner + avatar + name + video count header, paged video grid; opened from the Channels screen and the player's channel icon | + +## Discussed but deferred (2026-10-12, while reworking the player controls) + +- Like/dislike display: counts are in the video detail DTO; rating (own like via + `PUT /api/v1/videos/{uuid}/rating`) is a write op — display first, rating later. +- Subtitles: `GET /api/v1/videos/{uuid}/captions` → Media3 `SubtitleConfiguration` + on the player; language selection in the controls overlay. ## Next up (v0.2+) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt index a723e8e..dca398c 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -74,9 +74,7 @@ class VideoListViewModel( fun loadChannelHeader(repository: PeerTubeRepository) { val s = source if (s is ListSource.Channel) { - viewModelScope.launch { - channelHeader = repository.videoChannel(s.handle).getOrNull() - } + viewModelScope.launch { channelHeader = repository.videoChannel(s.handle).getOrNull() } } } 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 1cd6eea..74cf99a 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 @@ -259,7 +259,7 @@ fun HomeRowsScreen( videos = viewModel.subItems, progressOf = { null }, onOpenVideo = onOpenVideo, - onLeftAtStart = { }, + onLeftAtStart = onOpenDrawer, ) } } 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 a6f5aad..cd1c618 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 @@ -1,8 +1,8 @@ package net.jeena.jtubetv.ui.screens import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image 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 @@ -12,10 +12,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.AccountCircle @@ -36,14 +39,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale 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.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput @@ -51,6 +56,8 @@ 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.R +import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.list.ListSource @@ -67,23 +74,28 @@ enum class Destination(val title: String) { } /** - * Home = SmartTube-style: hidden menu, red round search + avatar in a top bar, - * full-width rows/list content. The menu drawer slides in when wanted (avatar or - * the search/BACK flows) and everything is key(epoch) so an account switch - * rebuilds all state. + * Home = SmartTube-style: persistent top bar with the app logo + title on the + * left and the account button (avatar + display name, opens the menu) on the + * right; the menu drawer slides in below the bar so the logo/title stay in the + * same place whether the drawer is open or not. Content below is key(epoch) so + * an account switch rebuilds all state. */ @Composable fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { val epoch by viewModel.sessionEpoch.collectAsState() + val userProfile by viewModel.userProfile.collectAsState() var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle + var channelName by rememberSaveable { mutableStateOf(null) } // display name // NOT saveable: launching the app should never resume a half-watched player session. var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends var drawerOpen by rememberSaveable { mutableStateOf(false) } val context = androidx.compose.ui.platform.LocalContext.current var lastBackAt by rememberSaveable { mutableStateOf(0L) } - val searchFocus = remember { FocusRequester() } + + /** Close the drawer; focus falls back into the content pane. */ + val dismissDrawer = { drawerOpen = false } // After BACK from the player: bump the restore token so lists re-focus the opened // card and re-fetch positions (UX report BUG-3 + BUG-7). @@ -100,8 +112,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi // BACK order (TV standard): player (own handler) → drawer close → channel // drilldown → non-Home destination → Home; from Home: double-press exits. BackHandler(enabled = drawerOpen && playingVideo == null) { - drawerOpen = false - runCatching { searchFocus.requestFocus() } + dismissDrawer() } BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) { channelDetails = null @@ -133,35 +144,48 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi playingVideo == null ) { when { - drawerOpen -> { drawerOpen = false; true } + drawerOpen -> { dismissDrawer(); true } channelDetails != null -> { channelDetails = null; true } else -> false } } else false }, ) { - androidx.compose.runtime.key(epoch) { Column { - // SmartTube-style top bar: red round search + avatar opening the menu. + // Persistent top bar: logo/title left, account button right. TopBar( - onSearchClick = { destination = Destination.Search; channelDetails = null }, onAccountClick = { drawerOpen = !drawerOpen }, - searchFocus = searchFocus, + profile = userProfile, ) - Box( - modifier = Modifier - .fillMaxSize() - // TV overscan safety margins on the content pane. - .padding(start = 8.dp, end = 32.dp, top = 8.dp), - ) { - when (destination) { + androidx.compose.runtime.key(epoch) { + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + // TV overscan safety margins on the content pane. + .padding(start = 8.dp, end = 32.dp, top = 8.dp), + ) { + when { + // Channel videos page (opened from the Channels screen or the + // player's channel icon) overrides the destination content. + channelDetails != null -> + VideoListScreen( + source = ListSource.Channel(channelDetails!!, channelName ?: ""), + onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, + epoch = epoch, + onOpenDrawer = { drawerOpen = true }, + upToTopBar = { drawerOpen = true }, + ) + else -> when (destination) { Destination.Home -> HomeRowsScreen( onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + // UP over the first row opens the drawer. + upToTopBar = { drawerOpen = true }, ) Destination.MyVideos -> VideoListScreen( @@ -170,7 +194,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + upToTopBar = { drawerOpen = true }, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, @@ -184,7 +208,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + upToTopBar = { drawerOpen = true }, ) Destination.Subscriptions -> VideoListScreen( @@ -193,7 +217,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + upToTopBar = { drawerOpen = true }, ) Destination.History -> VideoListScreen( @@ -202,31 +226,38 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + upToTopBar = { drawerOpen = true }, ) + } + } + } + + // Slide-in menu drawer, below the top bar so logo/title stay put. + if (drawerOpen) { + MenuDrawer( + selected = destination, + onSelect = { + destination = it + channelDetails = null + drawerOpen = false + }, + onSignOut = { viewModel.logout() }, + onDismiss = { dismissDrawer() }, + ) + } } } } - } - - // Slide-in menu drawer (SmartTube style: hidden until wanted). - if (drawerOpen) { - MenuDrawer( - selected = destination, - onSelect = { - destination = it - channelDetails = null - drawerOpen = false - }, - onSignOut = { viewModel.logout() }, - onDismiss = { drawerOpen = false }, - ) - } if (playingVideo != null) { PlayerScreen( shortUuid = playingVideo!!, onBack = { playingVideo = null }, + onOpenChannel = { handle, displayName -> + playingVideo = null + channelDetails = handle + channelName = displayName + }, ) } } @@ -242,120 +273,109 @@ private fun Destination.icon(): ImageVector = Destination.History -> Icons.Filled.History } -/** Top bar: red round search + account avatar (opens the menu), SmartTube-style. */ +/** + * Persistent top bar: logo + "JTube TV" on the left (unchanged while the drawer + * is open, so they anchor the layout) and the account button on the right. + */ @Composable private fun TopBar( - onSearchClick: () -> Unit, onAccountClick: () -> Unit, - searchFocus: FocusRequester, + profile: PeerTubeRepository.UserProfile?, modifier: Modifier = Modifier, ) { Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 10.dp), - horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp), + .padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - RoundTopButton( - bg = Color(0xFFCE2B2B), - icon = Icons.Filled.Search, - desc = "Search", - onClick = onSearchClick, - focusRequester = searchFocus, - ) - // "Who is logged in" — account button: avatar circle + display name (SmartTube). - val accountFocused = remember { mutableStateOf(false) } - Row( + // The launcher icon is an adaptive icon, which Compose can't paint + // directly — the launcher look is its two layers: dark background + + // foreground glyph (both vector drawables). + // Bottom-aligned to the text line and nudged up so the logo's bottom + // edge sits on the text baseline, not below it. + Box( modifier = Modifier - .onFocusChanged { accountFocused.value = it.isFocused } - .background(Color.Transparent) - .then( - if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape) - else Modifier - ) - .focusable() - .dpadCenterActivatable(onAccountClick) - .clickable(onClick = onAccountClick), + .align(Alignment.Bottom) + .padding(bottom = 17.dp) // slightly below the text baseline + .size(32.dp) // match the avatar row so both headers align + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF101010)), + ) { + // Zoom past the adaptive-icon safe zone: the glyph is only ~45% of + // the 108dp viewport, so Fit would render it tiny. + Image( + painter = painterResource(R.drawable.icon_foreground), + contentDescription = "JTube TV logo", + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .scale(2.1f), + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "JTube TV", + color = Color.White, + style = androidx.compose.material3.MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.weight(1f)) + // Account indicator: avatar + display name. Purely visual on TV (D-pad + // focus and activation stay in the content/drawer); a click (mouse/touch) + // still opens the menu drawer. + Row( + modifier = Modifier.clickable(onClick = onAccountClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), ) { - val avatarUrl = AppGraph.tokenStore.userAvatarUrl - if (avatarUrl != null) { - coil.compose.AsyncImage( - model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) - .data(avatarUrl) - .size(96) - .build(), - contentDescription = "Account", - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color(0xFF262626)), - ) - } else { - Box( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color(0xFF262626)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = "Account", - tint = Color(0xFFCCCCCC), - ) - } - } + AccountAvatar( + url = profile?.avatarUrl ?: AppGraph.tokenStore.userAvatarUrl, + size = 40.dp, + ) Text( - text = AppGraph.tokenStore.userDisplayName - ?: AppGraph.tokenStore.username?.substringBefore('@') ?: "Account", - color = Color(0xFFB0B0B0), + text = profile?.displayName + ?: AppGraph.tokenStore.userDisplayName + ?: AppGraph.tokenStore.username?.substringBefore('@') + ?: "Account", + color = Color(0xFFD0D0D0), style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(end = 8.dp), + modifier = Modifier + .padding(end = 8.dp) + // keep the button compact; the bar never wraps under a long name + .widthIn(max = 160.dp), + maxLines = 1, ) } - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "JTube TV", - color = Color(0xFF9A9A9A), - style = androidx.compose.material3.MaterialTheme.typography.titleMedium, - modifier = Modifier.align(Alignment.CenterVertically), - ) } } - @Composable -private fun RoundTopButton( - bg: Color, - icon: ImageVector, - desc: String, - onClick: () -> Unit, - focusRequester: FocusRequester? = null, -) { - 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 - ) - .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .focusable() - .dpadCenterActivatable(onClick) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = desc, - tint = Color.White, +private fun AccountAvatar(url: String?, size: androidx.compose.ui.unit.Dp) { + if (url != null) { + coil.compose.AsyncImage( + model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(url) + .size(96) + .build(), + contentDescription = "Account", + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(Color(0xFF262626)), ) + } else { + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(Color(0xFF262626)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = "Account", + tint = Color(0xFFCCCCCC), + ) + } } } @@ -381,7 +401,9 @@ private fun MenuDrawer( .width(240.dp) .fillMaxSize() .background(Color(0xFF111111)) - .padding(vertical = 24.dp) + // Logo/title live in the persistent top bar above; only a slim + // top gap here so the item list starts below the bar. + .padding(top = 8.dp, bottom = 24.dp) .onPreviewKeyEvent { ke -> // RIGHT from the drawer closes it; focus returns to the top bar. if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && @@ -392,12 +414,6 @@ private fun MenuDrawer( } else false }, ) { - Text( - text = "JTube TV", - color = Color.White, - style = androidx.compose.material3.MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(start = 28.dp, bottom = 20.dp), - ) Destination.entries.forEachIndexed { index, dest -> MenuDrawerItem( icon = dest.icon(), 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 02e43b3..17d7681 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 @@ -2,9 +2,12 @@ package net.jeena.jtubetv.ui.screens.list import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed @@ -15,21 +18,24 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.R import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.list.PagedListViewModel @@ -80,9 +86,19 @@ fun VideoListScreen( key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, ) { androidx.compose.foundation.lazy.grid.LazyGridState() } - when (source) { - is ListSource.Search -> SearchHeader(viewModel) - else -> {} + if (source is ListSource.Channel) { + // Fetch the banner/avatar/counts for the channel page header. + LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) } + } + + // Cards-per-row of the adaptive grid — LEFT from the first card of ANY row + // must open the drawer (issue #7: "every time I'm all the way on the left"). + val cellsPerRow by remember { + androidx.compose.runtime.derivedStateOf { + val info = gridState.layoutInfo + val w = info.visibleItemsInfo.firstOrNull()?.size?.width ?: 0 + if (w > 0) (info.viewportSize.width / w).coerceIn(1, 10) else 0 + } } if (viewModel.loadingInitial && items.isEmpty()) { @@ -127,6 +143,15 @@ fun VideoListScreen( state = gridState, modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) { + // Page headers (search field / channel banner) live INSIDE the grid so + // they scroll with the content and never stack under the cards. + item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { + when (source) { + is ListSource.Search -> SearchHeader(viewModel) + is ListSource.Channel -> ChannelHeader(channelHeader = viewModel.channelHeader) + else -> {} + } + } itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item -> val video: VideoDto = when (item) { is VideoListItem.HistoryEntry -> item.history.video @@ -140,7 +165,7 @@ fun VideoListScreen( onClick = { lastOpenedIndex = index; onOpenVideo(video) }, showPrivacy = source is ListSource.MyVideos, compact = true, - onLeftAtStart = if (index == 0) onOpenDrawer else null, + onLeftAtStart = if (index % cellsPerRow.coerceAtLeast(1) == 0) onOpenDrawer else null, onUpToTopBar = upToTopBar?.takeIf { index < 4 }, modifier = Modifier .focusRequester(fr), @@ -194,6 +219,54 @@ private fun SearchHeader(viewModel: VideoListViewModel) { } } +/** Channel page header: banner strip, then avatar + display name + handle + counts. */ +@Composable +private fun ChannelHeader(channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?) { + if (channelHeader == null) return + androidx.compose.foundation.layout.Column { + val banner = channelHeader.banners.maxByOrNull { it.width } + if (banner != null) { + AsyncImage( + model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + val avatar = channelHeader.avatars.maxByOrNull { it.width } + if (avatar != null) { + AsyncImage( + model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(start = 16.dp, top = 10.dp) + .size(52.dp) + .clip(androidx.compose.foundation.shape.CircleShape), + ) + } + Column(modifier = Modifier.padding(start = 16.dp)) { + Text( + text = channelHeader.displayName, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + ) + Text( + text = (if (channelHeader.videosCount > 0) channelHeader.videosCount.toString() + " videos · " else "") + + channelHeader.name + "@" + channelHeader.host, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + private fun itemStableKey(item: VideoListItem): String = when (item) { is VideoListItem.Video -> "v" + item.video.id