Compare commits

...

4 commits

Author SHA1 Message Date
fdc570ac9e home: Title bar with account and channel pages
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
2026-10-13 00:05:30 +00:00
a6dff3435d player: Own the controls UI end to end
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
2026-10-13 00:05:19 +00:00
15a481b7c0 profile: Fetch the user profile on every app start
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.
2026-10-13 00:05:10 +00:00
a71de07969 emu: Stop silent early exits in the emulator start script
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.
2026-10-13 08:14:15 +09:00
12 changed files with 563 additions and 477 deletions

View file

@ -13,6 +13,14 @@ Status vocabulary: `todo` · `doing` · `done`.
| 4 | Subscription videos | done | Verified incl. remote (federated) channels | | 4 | Subscription videos | done | Verified incl. remote (federated) channels |
| 5 | Watch history (server) | done | read-only grid | | 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 | | 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+) ## Next up (v0.2+)

View file

@ -69,45 +69,47 @@ class PeerTubeRepository(
expiresAtMillis = System.currentTimeMillis() + expiresAtMillis = System.currentTimeMillis() +
(token.expiresIn ?: 86400L) * 1000L, (token.expiresIn ?: 86400L) * 1000L,
) )
// Login accepts emails; resolve the real account name for /accounts/{handle}. // Login accepts email addresses too; the login input is not a valid
// The request MUST carry the fresh token (UX report BUG-1: unauthenticated // handle for /accounts/{handle}, so default it, refreshMyProfile()
// /users/me returns 401 and the fallback breaks Channels for email logins). // corrects it right after (and on every app start).
try { store.accountName = username.substringBefore('@')
val authed = plainOkHttp().newBuilder() store.instanceName = runCatching { network.api.serverConfig().instance.name }.getOrNull()
.addInterceptor { chain -> // /users/me fills displayName + avatar; failure is not fatal because
chain.proceed( // the profile is refreshed again on the next app start.
chain.request().newBuilder() refreshMyProfile()
.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
}
} }
suspend fun me(): Result<UserDto> = runCatching { network.api.me() } suspend fun me(): Result<UserDto> = runCatching { network.api.me() }
suspend fun serverConfig(): Result<ServerConfigDto> = runCatching { network.api.serverConfig() } suspend fun serverConfig(): Result<ServerConfigDto> = 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. */ /** Display name of a (possibly foreign) instance, via that instance's /config. */
suspend fun instanceName(rawHost: String): Result<String> = runCatching { suspend fun instanceName(rawHost: String): Result<String> = runCatching {
val base = "https://" + rawHost val base = "https://" + rawHost

View file

@ -165,6 +165,15 @@ data class ChannelStats(
@Serializable @Serializable
data class UserDto( data class UserDto(
val username: String, 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 displayName: String? = null,
val avatars: List<Avatar> = emptyList(), val avatars: List<Avatar> = emptyList(),
val account: UserAccountDto? = null,
)
@Serializable
data class UserAccountDto(
@SerialName("displayName") val displayName: String? = null,
val avatars: List<Avatar> = emptyList(),
) )

View file

@ -19,6 +19,10 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit
private val _sessionState = MutableStateFlow<SessionState>(SessionState.Loading) private val _sessionState = MutableStateFlow<SessionState>(SessionState.Loading)
val sessionState: StateFlow<SessionState> = _sessionState val sessionState: StateFlow<SessionState> = _sessionState
/** Display name + avatar of the logged-in user (for the drawer header). */
private val _userProfile = MutableStateFlow<PeerTubeRepository.UserProfile?>(null)
val userProfile: StateFlow<PeerTubeRepository.UserProfile?> = _userProfile
/** /**
* Bumped on every logout and successful login: all list ViewModels and saved * 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 * 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) val loggingIn = MutableStateFlow(false)
init { init {
_sessionState.value = if (repository.hasSession()) {
if (repository.hasSession()) SessionState.Ready else SessionState.NeedsSetup _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) { fun login(baseUrl: String, username: String, password: String) {
@ -43,6 +58,7 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit
if (result.isSuccess) { if (result.isSuccess) {
_sessionState.value = SessionState.Ready _sessionState.value = SessionState.Ready
sessionEpoch.value += 1 sessionEpoch.value += 1
refreshProfile()
} else { } else {
loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) { loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) {
"Invalid credentials" "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. */ /** Clears the stored session; the user returns to the setup screen. */
fun logout() { fun logout() {
repository.signOut() repository.signOut()
_userProfile.value = null
_sessionState.value = SessionState.NeedsSetup _sessionState.value = SessionState.NeedsSetup
sessionEpoch.value += 1 sessionEpoch.value += 1
} }

View file

@ -74,9 +74,7 @@ class VideoListViewModel(
fun loadChannelHeader(repository: PeerTubeRepository) { fun loadChannelHeader(repository: PeerTubeRepository) {
val s = source val s = source
if (s is ListSource.Channel) { if (s is ListSource.Channel) {
viewModelScope.launch { viewModelScope.launch { channelHeader = repository.videoChannel(s.handle).getOrNull() }
channelHeader = repository.videoChannel(s.handle).getOrNull()
}
} }
} }

View file

@ -259,7 +259,7 @@ fun HomeRowsScreen(
videos = viewModel.subItems, videos = viewModel.subItems,
progressOf = { null }, progressOf = { null },
onOpenVideo = onOpenVideo, onOpenVideo = onOpenVideo,
onLeftAtStart = { }, onLeftAtStart = onOpenDrawer,
) )
} }
} }

View file

@ -1,8 +1,8 @@
package net.jeena.jtubetv.ui.screens package net.jeena.jtubetv.ui.screens
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectTapGestures 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.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.AccountCircle 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
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.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.key
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput 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.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import net.jeena.jtubetv.AppGraph 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.data.api.VideoDto
import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.AppViewModel
import net.jeena.jtubetv.ui.list.ListSource 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, * Home = SmartTube-style: persistent top bar with the app logo + title on the
* full-width rows/list content. The menu drawer slides in when wanted (avatar or * left and the account button (avatar + display name, opens the menu) on the
* the search/BACK flows) and everything is key(epoch) so an account switch * right; the menu drawer slides in below the bar so the logo/title stay in the
* rebuilds all state. * same place whether the drawer is open or not. Content below is key(epoch) so
* an account switch rebuilds all state.
*/ */
@Composable @Composable
fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) {
val epoch by viewModel.sessionEpoch.collectAsState() val epoch by viewModel.sessionEpoch.collectAsState()
val userProfile by viewModel.userProfile.collectAsState()
var destination by rememberSaveable { mutableStateOf(Destination.Home) } var destination by rememberSaveable { mutableStateOf(Destination.Home) }
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name
// NOT saveable: launching the app should never resume a half-watched player session. // NOT saveable: launching the app should never resume a half-watched player session.
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends
var drawerOpen by rememberSaveable { mutableStateOf(false) } var drawerOpen by rememberSaveable { mutableStateOf(false) }
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
var lastBackAt by rememberSaveable { mutableStateOf(0L) } 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 // 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). // 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 // BACK order (TV standard): player (own handler) → drawer close → channel
// drilldown → non-Home destination → Home; from Home: double-press exits. // drilldown → non-Home destination → Home; from Home: double-press exits.
BackHandler(enabled = drawerOpen && playingVideo == null) { BackHandler(enabled = drawerOpen && playingVideo == null) {
drawerOpen = false dismissDrawer()
runCatching { searchFocus.requestFocus() }
} }
BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) { BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) {
channelDetails = null channelDetails = null
@ -133,35 +144,48 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
playingVideo == null playingVideo == null
) { ) {
when { when {
drawerOpen -> { drawerOpen = false; true } drawerOpen -> { dismissDrawer(); true }
channelDetails != null -> { channelDetails = null; true } channelDetails != null -> { channelDetails = null; true }
else -> false else -> false
} }
} else false } else false
}, },
) { ) {
androidx.compose.runtime.key(epoch) {
Column { Column {
// SmartTube-style top bar: red round search + avatar opening the menu. // Persistent top bar: logo/title left, account button right.
TopBar( TopBar(
onSearchClick = { destination = Destination.Search; channelDetails = null },
onAccountClick = { drawerOpen = !drawerOpen }, onAccountClick = { drawerOpen = !drawerOpen },
searchFocus = searchFocus, profile = userProfile,
) )
androidx.compose.runtime.key(epoch) {
Box(modifier = Modifier.fillMaxSize()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// TV overscan safety margins on the content pane. // TV overscan safety margins on the content pane.
.padding(start = 8.dp, end = 32.dp, top = 8.dp), .padding(start = 8.dp, end = 32.dp, top = 8.dp),
) { ) {
when (destination) { 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 -> Destination.Home ->
HomeRowsScreen( HomeRowsScreen(
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } }, // UP over the first row opens the drawer.
upToTopBar = { drawerOpen = true },
) )
Destination.MyVideos -> Destination.MyVideos ->
VideoListScreen( VideoListScreen(
@ -170,7 +194,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } }, upToTopBar = { drawerOpen = true },
) )
Destination.Channels -> ChannelsScreen( Destination.Channels -> ChannelsScreen(
onOpenChannel = { handle -> channelDetails = handle }, onOpenChannel = { handle -> channelDetails = handle },
@ -184,7 +208,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } }, upToTopBar = { drawerOpen = true },
) )
Destination.Subscriptions -> Destination.Subscriptions ->
VideoListScreen( VideoListScreen(
@ -193,7 +217,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } }, upToTopBar = { drawerOpen = true },
) )
Destination.History -> Destination.History ->
VideoListScreen( VideoListScreen(
@ -202,14 +226,13 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } }, upToTopBar = { drawerOpen = true },
) )
} }
} }
} }
}
// Slide-in menu drawer (SmartTube style: hidden until wanted). // Slide-in menu drawer, below the top bar so logo/title stay put.
if (drawerOpen) { if (drawerOpen) {
MenuDrawer( MenuDrawer(
selected = destination, selected = destination,
@ -219,14 +242,22 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
drawerOpen = false drawerOpen = false
}, },
onSignOut = { viewModel.logout() }, onSignOut = { viewModel.logout() },
onDismiss = { drawerOpen = false }, onDismiss = { dismissDrawer() },
) )
} }
}
}
}
if (playingVideo != null) { if (playingVideo != null) {
PlayerScreen( PlayerScreen(
shortUuid = playingVideo!!, shortUuid = playingVideo!!,
onBack = { playingVideo = null }, onBack = { playingVideo = null },
onOpenChannel = { handle, displayName ->
playingVideo = null
channelDetails = handle
channelName = displayName
},
) )
} }
} }
@ -242,60 +273,99 @@ private fun Destination.icon(): ImageVector =
Destination.History -> Icons.Filled.History 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 @Composable
private fun TopBar( private fun TopBar(
onSearchClick: () -> Unit,
onAccountClick: () -> Unit, onAccountClick: () -> Unit,
searchFocus: FocusRequester, profile: PeerTubeRepository.UserProfile?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 10.dp), .padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically,
) { ) {
RoundTopButton( // The launcher icon is an adaptive icon, which Compose can't paint
bg = Color(0xFFCE2B2B), // directly — the launcher look is its two layers: dark background +
icon = Icons.Filled.Search, // foreground glyph (both vector drawables).
desc = "Search", // Bottom-aligned to the text line and nudged up so the logo's bottom
onClick = onSearchClick, // edge sits on the text baseline, not below it.
focusRequester = searchFocus, Box(
)
// "Who is logged in" — account button: avatar circle + display name (SmartTube).
val accountFocused = remember { mutableStateOf(false) }
Row(
modifier = Modifier modifier = Modifier
.onFocusChanged { accountFocused.value = it.isFocused } .align(Alignment.Bottom)
.background(Color.Transparent) .padding(bottom = 17.dp) // slightly below the text baseline
.then( .size(32.dp) // match the avatar row so both headers align
if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape) .clip(RoundedCornerShape(8.dp))
else Modifier .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),
) )
.focusable() }
.dpadCenterActivatable(onAccountClick) Spacer(modifier = Modifier.width(10.dp))
.clickable(onClick = onAccountClick), 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, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
) { ) {
val avatarUrl = AppGraph.tokenStore.userAvatarUrl AccountAvatar(
if (avatarUrl != null) { url = profile?.avatarUrl ?: AppGraph.tokenStore.userAvatarUrl,
size = 40.dp,
)
Text(
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)
// keep the button compact; the bar never wraps under a long name
.widthIn(max = 160.dp),
maxLines = 1,
)
}
}
}
@Composable
private fun AccountAvatar(url: String?, size: androidx.compose.ui.unit.Dp) {
if (url != null) {
coil.compose.AsyncImage( coil.compose.AsyncImage(
model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
.data(avatarUrl) .data(url)
.size(96) .size(96)
.build(), .build(),
contentDescription = "Account", contentDescription = "Account",
modifier = Modifier modifier = Modifier
.size(44.dp) .size(size)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF262626)), .background(Color(0xFF262626)),
) )
} else { } else {
Box( Box(
modifier = Modifier modifier = Modifier
.size(44.dp) .size(size)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF262626)), .background(Color(0xFF262626)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@ -307,56 +377,6 @@ private fun TopBar(
) )
} }
} }
Text(
text = AppGraph.tokenStore.userDisplayName
?: AppGraph.tokenStore.username?.substringBefore('@') ?: "Account",
color = Color(0xFFB0B0B0),
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 8.dp),
)
}
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,
)
}
} }
/** /**
@ -381,7 +401,9 @@ private fun MenuDrawer(
.width(240.dp) .width(240.dp)
.fillMaxSize() .fillMaxSize()
.background(Color(0xFF111111)) .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 -> .onPreviewKeyEvent { ke ->
// RIGHT from the drawer closes it; focus returns to the top bar. // RIGHT from the drawer closes it; focus returns to the top bar.
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
@ -392,12 +414,6 @@ private fun MenuDrawer(
} else false } 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 -> Destination.entries.forEachIndexed { index, dest ->
MenuDrawerItem( MenuDrawerItem(
icon = dest.icon(), icon = dest.icon(),

View file

@ -11,6 +11,7 @@ import androidx.activity.compose.BackHandler
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Forward10 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.PlayArrow
import androidx.compose.material.icons.filled.Replay10 import androidx.compose.material.icons.filled.Replay10
import androidx.compose.material3.CircularProgressIndicator 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -172,6 +177,9 @@ class PlayerViewModel(
fun PlayerScreen( fun PlayerScreen(
shortUuid: String, shortUuid: String,
onBack: () -> Unit, 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( viewModel: PlayerViewModel = viewModel(
key = "player$shortUuid", key = "player$shortUuid",
factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore), factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore),
@ -189,7 +197,7 @@ fun PlayerScreen(
when (val s = state) { when (val s = state) {
is PlayerUiState.Loading -> PlayerLoading() is PlayerUiState.Loading -> PlayerLoading()
is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack) 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, viewModel: PlayerViewModel,
shortUuid: String, shortUuid: String,
onBack: () -> Unit, onBack: () -> Unit,
onOpenChannel: (String, String) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val keyFocus = remember { FocusRequester() } val keyFocus = remember { FocusRequester() }
var paused by remember { mutableStateOf(false) } var paused by remember { mutableStateOf(false) }
var playerViewRef by remember { mutableStateOf<PlayerView?>(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 // 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. // re-reveal the native controller right after a play/pause toggle.
var consumedDownKey by remember { mutableStateOf(0) } var consumedDownKey by remember { mutableStateOf(0) }
// Overlay focus/position state for the paused controls (#4). // Overlay focus/position state for the controls (#4).
val playPauseFocus = remember { FocusRequester() }
val seekBarFocus = remember { FocusRequester() } val seekBarFocus = remember { FocusRequester() }
val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) } val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) }
@ -290,12 +301,12 @@ private fun PlayerSurface(
.build() .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. // back to the plain key handler so the next OK pauses again.
LaunchedEffect(paused) { LaunchedEffect(paused) {
if (paused) { if (paused) {
positionMs.longValue = player.currentPosition positionMs.longValue = player.currentPosition
runCatching { playPauseFocus.requestFocus() } runCatching { seekBarFocus.requestFocus() }
} else { } else {
runCatching { keyFocus.requestFocus() } runCatching { keyFocus.requestFocus() }
} }
@ -349,11 +360,12 @@ private fun PlayerSurface(
player.addListener(object : Player.Listener { player.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
paused = !isPlaying 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 { onDispose {
viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt()) viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt())
player.release() player.release()
@ -390,7 +402,10 @@ private fun PlayerSurface(
onBack() onBack()
true 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 player.playWhenReady = !player.playWhenReady
true true
} }
@ -407,21 +422,38 @@ private fun PlayerSurface(
} else false } 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.focusRequester(keyFocus) .focusRequester(keyFocus)
.onFocusChanged { surfaceFocused = it.isFocused }
.focusable() .focusable()
.onPreviewKeyEvent { ke -> .onPreviewKeyEvent { ke ->
val code = ke.nativeKeyEvent.keyCode val code = ke.nativeKeyEvent.keyCode
when { 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 onBack(); true
} }
// While paused the overlay handles arrows + CENTER (#4); unhandled // While paused our overlay has focus and handles everything.
// keys must not wake the native bar either.
paused -> false paused -> false
// Transient controls hold focus while playing: let them move it.
controlsVisible && !surfaceFocused -> false
else -> { else -> {
val handled = handleKey(ke.nativeKeyEvent) val handled = handleKey(ke.nativeKeyEvent)
if (!handled) { if (!handled) {
@ -430,15 +462,17 @@ private fun PlayerSurface(
) { ) {
consumedDownKey = 0 // trailing UP of a handled press consumedDownKey = 0 // trailing UP of a handled press
} else { } else {
// UP/DOWN etc. reveal the controls briefly // UP/DOWN etc. reveal OUR controls, focus the Play button.
playerViewRef?.showController() controlsVisible = true
if (surfaceFocused) {
runCatching { seekBarFocus.requestFocus() }
}
} }
true true
} else { } else {
if (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { if (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) {
consumedDownKey = ke.nativeKeyEvent.keyCode consumedDownKey = ke.nativeKeyEvent.keyCode
} }
if (player.playWhenReady) playerViewRef?.hideController()
true true
} }
} }
@ -452,27 +486,41 @@ private fun PlayerSurface(
// screen regardless of the letterbox geometry (issue #1). // screen regardless of the letterbox geometry (issue #1).
(LayoutInflater.from(ctx) (LayoutInflater.from(ctx)
.inflate(R.layout.player_video_view, null) as PlayerView).apply { .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 resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
this.player = player this.player = player
isFocusable = false 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 descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
playerViewRef = this // A click (mouse) reveals our overlay instead.
setOnClickListener { controlsVisible = true }
} }
}, },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// Paused → SmartTube-style controls: seek bar over transport buttons over info. // Cover the picture with the loading indicator until the player is really
if (paused) { // 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( PausedControls(
detail = s.detail, detail = s.detail,
player = player, player = player,
positionMs = positionMs, positionMs = positionMs,
playPauseFocus = playPauseFocus, playing = !paused,
seekBarFocus = seekBarFocus, seekBarFocus = seekBarFocus,
onOpenChannel = onOpenChannel,
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
) )
} }
@ -492,88 +540,138 @@ private fun seekOverlay(
} }
/** /**
* SmartTube-style paused controls (#4): a big always-visible seek bar ABOVE the info * SmartTube-style controls (#4): channel icon + title + channel line above the
* bar (it used to hide behind the semi-transparent info panel), transport buttons the * seek bar, position/duration below it; on a gradient so the video stays visible.
* D-pad can actually select (arrows pick, OK activates), then the info panel. * Persistent when paused, transient while playing (any non-media key reveals it,
* auto-hides after 5 s).
*/ */
@Composable @Composable
private fun PausedControls( private fun PausedControls(
detail: VideoDetailDto, detail: VideoDetailDto,
player: ExoPlayer, player: ExoPlayer,
positionMs: androidx.compose.runtime.MutableLongState, 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, seekBarFocus: FocusRequester,
// (handle, displayName) — opens the channel's videos page; exits the player.
onOpenChannel: (String, String) -> Unit = { _, _ -> },
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val durationMs = detail.duration * 1000L 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( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color(0xDC000000)) .background(
.padding(horizontal = 24.dp, vertical = 18.dp), 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( SeekBarRow(
positionMs = positionMs, positionMs = positionMs,
durationMs = durationMs, durationMs = durationMs,
seekBarFocus = seekBarFocus, seekBarFocus = seekBarFocus,
playPauseFocus = playPauseFocus,
player = player, 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),
) )
Row( // Position / duration under the bar, right-aligned.
horizontalArrangement = Arrangement.spacedBy(14.dp), Text(
verticalAlignment = Alignment.CenterVertically, text = formatDuration((positionMs.longValue / 1000).toInt()) + " / " +
modifier = Modifier.padding(top = 14.dp), formatDuration((durationMs / 1000).toInt()),
) { color = Color(0xFFCCCCCC),
PauseControl( style = MaterialTheme.typography.bodyMedium,
icon = Icons.Filled.Replay10, modifier = Modifier
label = "Back 10 s", .align(Alignment.End)
seekBarFocus = seekBarFocus, .padding(top = 8.dp),
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 @Composable
private fun SeekBarRow( private fun SeekBarRow(
positionMs: androidx.compose.runtime.MutableLongState, positionMs: androidx.compose.runtime.MutableLongState,
durationMs: Long, durationMs: Long,
seekBarFocus: FocusRequester, seekBarFocus: FocusRequester,
playPauseFocus: FocusRequester,
player: ExoPlayer, player: ExoPlayer,
channelFocus: FocusRequester? = null, // UP target: the channel icon (or stay)
modifier: Modifier = Modifier,
) { ) {
var barFocused by remember { mutableStateOf(false) } var barFocused by remember { mutableStateOf(false) }
val fraction = val fraction =
if (durationMs > 0) (positionMs.longValue.toFloat() / durationMs).coerceIn(0f, 1f) else 0f 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( Box(
modifier = Modifier modifier = modifier
.weight(1f) .fillMaxWidth()
.height(44.dp) .height(44.dp)
.focusRequester(seekBarFocus) .focusRequester(seekBarFocus)
.onFocusChanged { barFocused = it.isFocused } .onFocusChanged { barFocused = it.isFocused }
@ -586,35 +684,29 @@ private fun SeekBarRow(
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
seekOverlay(player, positionMs, 10_000L, durationMs); true seekOverlay(player, positionMs, 10_000L, durationMs); true
} }
KeyEvent.KEYCODE_DPAD_UP -> true // topmost control — stay here KeyEvent.KEYCODE_DPAD_UP -> channelFocus?.let { runCatching { it.requestFocus() }; true } ?: false
KeyEvent.KEYCODE_DPAD_DOWN -> { KeyEvent.KEYCODE_DPAD_DOWN -> true
runCatching { playPauseFocus.requestFocus() }; true
}
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_MEDIA_PLAY -> { KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> {
player.playWhenReady = true // OK on the bar starts playback player.playWhenReady = !player.playWhenReady
true true
} }
else -> false else -> false
} }
} }
.focusable() .focusable()
.background( .padding(horizontal = 4.dp),
if (barFocused) Color(0x22FFFFFF) else Color.Transparent,
RoundedCornerShape(22.dp),
)
.padding(horizontal = 12.dp),
) { ) {
// Thin SmartTube-style track: rounded, red progress, white round knob.
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
.fillMaxWidth() .fillMaxWidth()
.height(if (barFocused) 14.dp else 10.dp) .height(if (barFocused) 12.dp else 6.dp)
// rounded ends for track and progress
.clip(RoundedCornerShape(7.dp)), .clip(RoundedCornerShape(7.dp)),
) { ) {
drawRoundRect(color = Color(0x44FFFFFF), cornerRadius = CornerRadius(99f)) drawRoundRect(color = Color(0x55FFFFFF), cornerRadius = CornerRadius(99f))
val w = size.width * fraction val w = size.width * fraction
if (w > 1f) { if (w > 1f) {
drawRoundRect( drawRoundRect(
@ -624,125 +716,23 @@ private fun SeekBarRow(
) )
} }
} }
} // White round knob on top of the track, always visible (SmartTube).
Text( Canvas(
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 modifier = Modifier
.size(64.dp) .align(Alignment.CenterStart)
.clip(CircleShape), .fillMaxWidth()
) .height((if (barFocused) 18.dp else 14.dp).coerceAtLeast(14.dp)),
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
) { ) {
Text( drawCircle(
text = detail.name,
color = Color.White, color = Color.White,
style = MaterialTheme.typography.titleLarge, radius = (if (barFocused) 9.dp else 7.dp).toPx(),
maxLines = 2, center = Offset(fraction * size.width, size.height / 2f),
) )
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,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = formatDuration((positionMs / 1000).toInt()) + " · " + formatDuration(detail.duration),
color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodyMedium,
)
Text(text = "Paused", color = Color(0xFFCE2B2B), style = MaterialTheme.typography.labelLarge)
} }
} }
} }
private fun formatViews(views: Long): String = private fun formatViews(views: Long): String =
when { when {
views >= 1_000_000 -> "%.1fM views".format(views / 1_000_000.0) 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" 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 "" if (iso.isNullOrBlank()) return ""
return try { return try {
val age = java.time.Duration.between(java.time.Instant.parse(iso), java.time.Instant.now()) java.time.ZonedDateTime.ofInstant(
val days = age.toDays() java.time.Instant.parse(iso),
when { java.time.ZoneId.systemDefault(),
days >= 365 -> "${days / 365}y ago" ).format(
days >= 30 -> "${days / 30}mo ago" java.time.format.DateTimeFormatter.ofPattern("d MMM yyyy, HH:mm", java.util.Locale.getDefault())
days >= 1 -> "${days}d ago" )
age.toHours() >= 1 -> "${age.toHours()}h ago"
else -> "just now"
}
} catch (e: java.time.format.DateTimeParseException) { } catch (e: java.time.format.DateTimeParseException) {
"" e.printStackTrace()
"" // unknown date → omit it
} }
} }
private fun formatDuration(seconds: Int): String { private fun formatDuration(seconds: Int): String {
val h = seconds / 3600 val h = seconds / 3600
val m = (seconds % 3600) / 60 val m = (seconds % 3600) / 60

View file

@ -2,9 +2,12 @@ package net.jeena.jtubetv.ui.screens.list
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
@ -15,21 +18,24 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.R import net.jeena.jtubetv.R
import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.data.api.VideoDto
import net.jeena.jtubetv.ui.list.PagedListViewModel 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, key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver,
) { androidx.compose.foundation.lazy.grid.LazyGridState() } ) { androidx.compose.foundation.lazy.grid.LazyGridState() }
when (source) { if (source is ListSource.Channel) {
is ListSource.Search -> SearchHeader(viewModel) // Fetch the banner/avatar/counts for the channel page header.
else -> {} 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()) { if (viewModel.loadingInitial && items.isEmpty()) {
@ -127,6 +143,15 @@ fun VideoListScreen(
state = gridState, state = gridState,
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), 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 -> itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
val video: VideoDto = when (item) { val video: VideoDto = when (item) {
is VideoListItem.HistoryEntry -> item.history.video is VideoListItem.HistoryEntry -> item.history.video
@ -140,7 +165,7 @@ fun VideoListScreen(
onClick = { lastOpenedIndex = index; onOpenVideo(video) }, onClick = { lastOpenedIndex = index; onOpenVideo(video) },
showPrivacy = source is ListSource.MyVideos, showPrivacy = source is ListSource.MyVideos,
compact = true, compact = true,
onLeftAtStart = if (index == 0) onOpenDrawer else null, onLeftAtStart = if (index % cellsPerRow.coerceAtLeast(1) == 0) onOpenDrawer else null,
onUpToTopBar = upToTopBar?.takeIf { index < 4 }, onUpToTopBar = upToTopBar?.takeIf { index < 4 },
modifier = Modifier modifier = Modifier
.focusRequester(fr), .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 = private fun itemStableKey(item: VideoListItem): String =
when (item) { when (item) {
is VideoListItem.Video -> "v" + item.video.id is VideoListItem.Video -> "v" + item.video.id

View file

@ -9,6 +9,12 @@ emulator works, and what needs to exist again after a rebuild.
`scripts/start-emulator.sh`). **Headless is the default**; set `scripts/start-emulator.sh`). **Headless is the default**; set
`JTUBE_EMU_MODE=windowed` if you want the Qt window (e.g. for desktop `JTUBE_EMU_MODE=windowed` if you want the Qt window (e.g. for desktop
interaction testing). View it via scrcpy either way. 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 - **Agent container**: image `agent-container:latest`, container name pattern
`ac-<project>-<hash>`, launched with **`--network host`** — it shares the `ac-<project>-<hash>`, launched with **`--network host`** — it shares the
host's network namespace. This is load-bearing: see below. 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 `/tmp/jtube-adbkeys/*.pub`) and launches the emulator with
`ADB_VENDOR_KEYS` pointing at it. To trust a new client: put its `ADB_VENDOR_KEYS` pointing at it. To trust a new client: put its
`~/.android/adbkey.pub` into `/tmp/jtube-adbkeys/` **before** launching. `~/.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: - Viewing: `scrcpy -s emulator-5600` on the host, or from any LAN machine:
`adb connect rutherford:5556 && scrcpy -s 127.0.0.1:5556`. `adb connect rutherford:5556 && scrcpy -s 127.0.0.1:5556`.

View file

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

View file

@ -62,11 +62,13 @@ if [ "$MODE" = windowed ]; then
else else
echo "JTUBE_EMU_MODE=windowed but no display detected — falling back to headless" echo "JTUBE_EMU_MODE=windowed but no display detected — falling back to headless"
WINDOWED=0 WINDOWED=0
ACCEL+=(-no-window) ACCEL+=(-no-window -gpu swiftshader_indirect)
fi fi
else else
WINDOWED=0 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" echo "headless (default; JTUBE_EMU_MODE=windowed for a window) — no emulator window"
fi fi
if [ -e /dev/kvm ]; then if [ -e /dev/kvm ]; then
@ -79,21 +81,27 @@ fi
# blocked, so remote keys are trusted via ADB_VENDOR_KEYS at launch instead. # 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 # Drop extra public keys into /tmp/jtube-adbkeys/ (one per file), e.g. from the
# agent container: docker exec <ctr> sh -c 'cat ~/.android/adbkey.pub' > /tmp/jtube-adbkeys/agent-container.pub # agent container: docker exec <ctr> sh -c 'cat ~/.android/adbkey.pub' > /tmp/jtube-adbkeys/agent-container.pub
mkdir -p /tmp/jtube-adbkeys
VKEYS=/tmp/jtube-adbkeys-combined VKEYS=/tmp/jtube-adbkeys-combined
{ {
cat "$HOME/.android/adbkey.pub" 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" } > "$VKEYS"
chmod 600 "$VKEYS" chmod 600 "$VKEYS"
echo "Booting AVD '$AVD' …" 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 & -no-snapshot > /tmp/jtube-emu.log 2>&1 < /dev/null &
echo "emulator PID $! — log: /tmp/jtube-emu.log" echo "emulator PID $! — log: /tmp/jtube-emu.log"
for i in $(seq 1 90); do for i in $(seq 1 90); do
ok=$("$ADB" -s "emulator-$PORT" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') # adb exits non-zero while the device is still booting — don't let
[ "$ok" = "1" ] && { echo "BOOTED"; break; } # 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 sleep 5
done done