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 |
|
| 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+)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,7 @@ fun HomeRowsScreen(
|
||||||
videos = viewModel.subItems,
|
videos = viewModel.subItems,
|
||||||
progressOf = { null },
|
progressOf = { null },
|
||||||
onOpenVideo = onOpenVideo,
|
onOpenVideo = onOpenVideo,
|
||||||
onLeftAtStart = { },
|
onLeftAtStart = onOpenDrawer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
Box(
|
androidx.compose.runtime.key(epoch) {
|
||||||
modifier = Modifier
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
.fillMaxSize()
|
Box(
|
||||||
// TV overscan safety margins on the content pane.
|
modifier = Modifier
|
||||||
.padding(start = 8.dp, end = 32.dp, top = 8.dp),
|
.fillMaxSize()
|
||||||
) {
|
// TV overscan safety margins on the content pane.
|
||||||
when (destination) {
|
.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 ->
|
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,31 +226,38 @@ 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, 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) {
|
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,120 +273,109 @@ 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)),
|
||||||
)
|
) {
|
||||||
.focusable()
|
// Zoom past the adaptive-icon safe zone: the glyph is only ~45% of
|
||||||
.dpadCenterActivatable(onAccountClick)
|
// the 108dp viewport, so Fit would render it tiny.
|
||||||
.clickable(onClick = onAccountClick),
|
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,
|
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,
|
||||||
coil.compose.AsyncImage(
|
size = 40.dp,
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = AppGraph.tokenStore.userDisplayName
|
text = profile?.displayName
|
||||||
?: AppGraph.tokenStore.username?.substringBefore('@') ?: "Account",
|
?: AppGraph.tokenStore.userDisplayName
|
||||||
color = Color(0xFFB0B0B0),
|
?: AppGraph.tokenStore.username?.substringBefore('@')
|
||||||
|
?: "Account",
|
||||||
|
color = Color(0xFFD0D0D0),
|
||||||
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
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
|
@Composable
|
||||||
private fun RoundTopButton(
|
private fun AccountAvatar(url: String?, size: androidx.compose.ui.unit.Dp) {
|
||||||
bg: Color,
|
if (url != null) {
|
||||||
icon: ImageVector,
|
coil.compose.AsyncImage(
|
||||||
desc: String,
|
model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
||||||
onClick: () -> Unit,
|
.data(url)
|
||||||
focusRequester: FocusRequester? = null,
|
.size(96)
|
||||||
) {
|
.build(),
|
||||||
var focused by remember { mutableStateOf(false) }
|
contentDescription = "Account",
|
||||||
Box(
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.size(size)
|
||||||
.size(44.dp)
|
.clip(CircleShape)
|
||||||
.clip(CircleShape)
|
.background(Color(0xFF262626)),
|
||||||
.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,
|
|
||||||
)
|
)
|
||||||
|
} 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)
|
.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(),
|
||||||
|
|
|
||||||
|
|
@ -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,257 +540,199 @@ 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),
|
||||||
|
)
|
||||||
|
// 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
|
@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)) {
|
Box(
|
||||||
Text(
|
modifier = modifier
|
||||||
text = formatDuration((positionMs.longValue / 1000).toInt()),
|
.fillMaxWidth()
|
||||||
color = Color.White,
|
.height(44.dp)
|
||||||
style = MaterialTheme.typography.labelMedium,
|
.focusRequester(seekBarFocus)
|
||||||
)
|
.onFocusChanged { barFocused = it.isFocused }
|
||||||
Box(
|
.onPreviewKeyEvent { ke ->
|
||||||
modifier = Modifier
|
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
.weight(1f)
|
when (ke.nativeKeyEvent.keyCode) {
|
||||||
.height(44.dp)
|
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||||
.focusRequester(seekBarFocus)
|
seekOverlay(player, positionMs, -10_000L, durationMs); true
|
||||||
.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
|
|
||||||
}
|
}
|
||||||
}
|
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||||
.focusable()
|
seekOverlay(player, positionMs, 10_000L, durationMs); true
|
||||||
.background(
|
}
|
||||||
if (barFocused) Color(0x22FFFFFF) else Color.Transparent,
|
KeyEvent.KEYCODE_DPAD_UP -> channelFocus?.let { runCatching { it.requestFocus() }; true } ?: false
|
||||||
RoundedCornerShape(22.dp),
|
KeyEvent.KEYCODE_DPAD_DOWN -> true
|
||||||
)
|
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
||||||
.padding(horizontal = 12.dp),
|
KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE,
|
||||||
) {
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
||||||
Canvas(
|
player.playWhenReady = !player.playWhenReady
|
||||||
modifier = Modifier
|
true
|
||||||
.align(Alignment.CenterStart)
|
}
|
||||||
.fillMaxWidth()
|
else -> false
|
||||||
.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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.focusable()
|
||||||
Text(
|
.padding(horizontal = 4.dp),
|
||||||
text = formatDuration((durationMs / 1000).toInt()),
|
) {
|
||||||
color = Color(0xFFAAAAAA),
|
// Thin SmartTube-style track: rounded, red progress, white round knob.
|
||||||
style = MaterialTheme.typography.labelMedium,
|
Canvas(
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.align(Alignment.CenterStart)
|
||||||
.padding(start = 16.dp),
|
.fillMaxWidth()
|
||||||
|
.height(if (barFocused) 12.dp else 6.dp)
|
||||||
|
.clip(RoundedCornerShape(7.dp)),
|
||||||
) {
|
) {
|
||||||
Text(
|
drawRoundRect(color = Color(0x55FFFFFF), cornerRadius = CornerRadius(99f))
|
||||||
text = detail.name,
|
val w = size.width * fraction
|
||||||
color = Color.White,
|
if (w > 1f) {
|
||||||
style = MaterialTheme.typography.titleLarge,
|
drawRoundRect(
|
||||||
maxLines = 2,
|
color = Color(0xFFCE2B2B),
|
||||||
)
|
size = Size(w, size.height),
|
||||||
Text(
|
cornerRadius = CornerRadius(99f),
|
||||||
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) {
|
// White round knob on top of the track, always visible (SmartTube).
|
||||||
Text(
|
Canvas(
|
||||||
text = formatDuration((positionMs / 1000).toInt()) + " · " + formatDuration(detail.duration),
|
modifier = Modifier
|
||||||
color = Color(0xFFCCCCCC),
|
.align(Alignment.CenterStart)
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
.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 =
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue