Compare commits
4 commits
c19024355c
...
fdc570ac9e
| Author | SHA1 | Date | |
|---|---|---|---|
| fdc570ac9e | |||
| a6dff3435d | |||
| 15a481b7c0 | |||
| a71de07969 |
12 changed files with 563 additions and 477 deletions
|
|
@ -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+)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<UserDto> = runCatching { network.api.me() }
|
||||
|
||||
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. */
|
||||
suspend fun instanceName(rawHost: String): Result<String> = runCatching {
|
||||
val base = "https://" + rawHost
|
||||
|
|
|
|||
|
|
@ -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<Avatar> = emptyList(),
|
||||
val account: UserAccountDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserAccountDto(
|
||||
@SerialName("displayName") val displayName: String? = null,
|
||||
val avatars: List<Avatar> = emptyList(),
|
||||
)
|
||||
|
|
@ -19,6 +19,10 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit
|
|||
private val _sessionState = MutableStateFlow<SessionState>(SessionState.Loading)
|
||||
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
|
||||
* 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ fun HomeRowsScreen(
|
|||
videos = viewModel.subItems,
|
||||
progressOf = { null },
|
||||
onOpenVideo = onOpenVideo,
|
||||
onLeftAtStart = { },
|
||||
onLeftAtStart = onOpenDrawer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
var playingVideo by rememberSaveable { mutableStateOf<String?>(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(),
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-<project>-<hash>`, 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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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 <ctr> 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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue