search: usable empty state, left-out rule, vidiverse scope

The Search screen's empty state replaced the whole grid with "Nothing
here yet" — no search field, no way back; LEFT from it went nowhere.
Both are scope rules of the issue: the screen keeps its field focused
on entry (retrying the request — the focus target attaches late in a
cold grid composition), LEFT from the field opens the sidebar like on
every other screen, and the results' auto-focus only runs when a search
is actually active (a persisted query re-entered shows its results
focused on the first card).

Search scope is a chip row below the field: Local and — when /config
allows the search index — Vidiverse. Vidiverse queries go through
search/videos with searchTarget=search-index; results may live on
other instances: their cards use the absolute thumbnailUrl, and opening
one plays it from THAT instance via an anonymous API (no history
reports for remote videos).

Closes #20
Closes #21
Closes #22
This commit is contained in:
Jeena 2026-10-14 01:35:31 +00:00
parent 52a0ba8fb9
commit d99c71a67a
8 changed files with 253 additions and 31 deletions

View file

@ -158,6 +158,14 @@ class PeerTubeRepository(
suspend fun localChannels(start: Long, count: Int, search: String? = null): Result<VideoChannelListResponse> = suspend fun localChannels(start: Long, count: Int, search: String? = null): Result<VideoChannelListResponse> =
runCatching { network.api.channels(start = start, count = count, search = search) } runCatching { network.api.channels(start = start, count = count, search = search) }
/** Full-text search via the instance's search index (Vidiverse/Sepia, issue #20). */
suspend fun searchIndexVideos(query: String, start: Long, count: Int): Result<VideoListResponse> =
runCatching { network.api.searchVideosServer(search = query, start = start, count = count) }
/** Video detail from ANOTHER instance (public vidiverse results, login-free). */
suspend fun remoteVideo(host: String, uuidOrShort: String): Result<VideoDetailDto> =
runCatching { unauthenticatedApiFor("https://" + host).video(uuidOrShort) }
suspend fun videoChannel(handle: String): Result<VideoChannelDto> = suspend fun videoChannel(handle: String): Result<VideoChannelDto> =
runCatching { network.api.videoChannel(handle) } runCatching { network.api.videoChannel(handle) }

View file

@ -53,6 +53,9 @@ data class VideoDto(
// Present on list responses (absent in detail) — lets the channels screen // Present on list responses (absent in detail) — lets the channels screen
// group local channels by owner without paging the channel listing. // group local channels by owner without paging the channel listing.
val account: ChannelOwnerAccount? = null, val account: ChannelOwnerAccount? = null,
// Absolute thumbnail URL when the video lives on another instance (search
// index results): thumbnailPath is relative to THAT instance, not ours.
val thumbnailUrl: String? = null,
val privacy: Labelled? = null, val privacy: Labelled? = null,
val nsfw: Boolean = false, val nsfw: Boolean = false,
val views: Long = 0, val views: Long = 0,
@ -154,6 +157,19 @@ data class VideoTokenFiles(
@Serializable @Serializable
data class ServerConfigDto( data class ServerConfigDto(
val instance: InstanceInfoDto, val instance: InstanceInfoDto,
// Search settings, for probing the allowed search scopes (issue #20).
val search: SearchConfigDto? = null,
)
@Serializable
data class SearchConfigDto(
@SerialName("searchIndex") val searchIndex: SearchIndexConfigDto? = null,
)
@Serializable
data class SearchIndexConfigDto(
val enabled: Boolean = false,
val url: String? = null,
) )
@Serializable @Serializable

View file

@ -65,6 +65,14 @@ interface PeerTubeApi {
@Query("sort") sort: String = "-publishedAt", @Query("sort") sort: String = "-publishedAt",
): VideoListResponse ): VideoListResponse
@GET("search/videos")
suspend fun searchVideosServer(
@Query("search") search: String,
@Query("searchTarget") searchTarget: String = "search-index",
@Query("start") start: Long,
@Query("count") count: Int,
): VideoListResponse
@GET("search/video-channels") @GET("search/video-channels")
suspend fun searchChannels( suspend fun searchChannels(
@Query("search") search: String, @Query("search") search: String,

View file

@ -36,6 +36,13 @@ class VideoListViewModel(
val query = MutableStateFlow("") val query = MutableStateFlow("")
private var activeSearch: String? = null private var activeSearch: String? = null
/** "local" or "vidiverse" (the instance's search index, if it allows it). */
var searchScope by androidx.compose.runtime.mutableStateOf("local")
private set
/** Probed from /config on the Search screen: Vidiverse selectable? */
var vidiverseAvailable by androidx.compose.runtime.mutableStateOf(false)
private set
/** For channel pages: the channel itself (banner + display name) as page header. */ /** For channel pages: the channel itself (banner + display name) as page header. */
var channelHeader by androidx.compose.runtime.mutableStateOf<net.jeena.jtubetv.data.api.VideoChannelDto?>( var channelHeader by androidx.compose.runtime.mutableStateOf<net.jeena.jtubetv.data.api.VideoChannelDto?>(
null, null,
@ -60,6 +67,13 @@ class VideoListViewModel(
} }
init { init {
// Probe whether the instance allows the search-index ("Vidiverse") scope.
if (source is ListSource.Search) {
viewModelScope.launch {
vidiverseAvailable =
repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true
}
}
val context = AppGraph.appContext val context = AppGraph.appContext
if (context != null && !shouldSkipSnapshot) { if (context != null && !shouldSkipSnapshot) {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
@ -117,7 +131,13 @@ class VideoListViewModel(
repository.history(start, count).map { it.map { e -> VideoListItem.HistoryEntry(e) } } repository.history(start, count).map { it.map { e -> VideoListItem.HistoryEntry(e) } }
is ListSource.Search -> { is ListSource.Search -> {
val q = activeSearch ?: return Result.success(emptyList()) val q = activeSearch ?: return Result.success(emptyList())
repository.localVideos(start, count, search = q).map { it.data.map { v -> VideoListItem.Video(v) } } if (searchScope == "vidiverse") {
repository.searchIndexVideos(q, start, count)
.map { it.data.map { v -> VideoListItem.Video(v) } }
} else {
repository.localVideos(start, count, search = q)
.map { it.data.map { v -> VideoListItem.Video(v) } }
}
} }
is ListSource.Channel -> is ListSource.Channel ->
repository.channelVideos(s.handle, start, count).map { resp -> repository.channelVideos(s.handle, start, count).map { resp ->
@ -157,6 +177,17 @@ class VideoListViewModel(
retryInitial() retryInitial()
} }
/** Whether a search term is active (drives the search screen's empty states). */
val searchActive: Boolean get() = activeSearch != null
/** Switch search scope and re-run the active query (issue #20). */
fun changeSearchScope(scope: String) {
if (scope == searchScope) return
searchScope = scope
activeSearch?.let { triggerSearch(it) }
retryInitial()
}
fun loadInitial() { fun loadInitial() {
if (items.isEmpty() && loadingInitial) loadMore(force = true) if (items.isEmpty() && loadingInitial) loadMore(force = true)
} }

View file

@ -89,6 +89,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name 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
// Non-null while the playing video lives on ANOTHER instance (vidiverse results).
var playingVideoHost by rememberSaveable { mutableStateOf<String?>(null) }
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
@ -160,6 +162,15 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
profile = userProfile, profile = userProfile,
) )
androidx.compose.runtime.key(epoch) { androidx.compose.runtime.key(epoch) {
// A card from ANY list opens here; foreign instances (vidiverse search
// results) keep their host so the player fetches from THERE — public
// videos, login-free, never reported into the local watch history.
val openCardVideo: (VideoDto) -> Unit = { video ->
val localHost = AppGraph.tokenStore.baseUrl?.substringAfter("://")?.substringBefore('/')
val host = video.channel.host
playingVideoHost = if (host != null && host != localHost) host else null
playingVideo = video.shortUUID
}
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Box( Box(
modifier = Modifier modifier = Modifier
@ -173,7 +184,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
channelDetails != null -> channelDetails != null ->
VideoListScreen( VideoListScreen(
source = ListSource.Channel(channelDetails!!, channelName ?: ""), source = ListSource.Channel(channelDetails!!, channelName ?: ""),
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = openCardVideo,
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
@ -182,7 +193,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
else -> when (destination) { else -> when (destination) {
Destination.Home -> Destination.Home ->
HomeRowsScreen( HomeRowsScreen(
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = openCardVideo,
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
@ -192,7 +203,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
Destination.MyVideos -> Destination.MyVideos ->
VideoListScreen( VideoListScreen(
source = ListSource.MyVideos, source = ListSource.MyVideos,
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = openCardVideo,
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
@ -207,7 +218,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
Destination.Search -> Destination.Search ->
VideoListScreen( VideoListScreen(
source = ListSource.Search, source = ListSource.Search,
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = openCardVideo,
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
@ -216,7 +227,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
Destination.Subscriptions -> Destination.Subscriptions ->
VideoListScreen( VideoListScreen(
source = ListSource.Subscriptions, source = ListSource.Subscriptions,
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = openCardVideo,
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
@ -225,7 +236,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
Destination.History -> Destination.History ->
VideoListScreen( VideoListScreen(
source = ListSource.History, source = ListSource.History,
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = openCardVideo,
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
@ -255,13 +266,14 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
if (playingVideo != null) { if (playingVideo != null) {
PlayerScreen( PlayerScreen(
shortUuid = playingVideo!!, shortUuid = playingVideo!!,
sourceHost = playingVideoHost,
onBack = { playingVideo = null }, onBack = { playingVideo = null },
onOpenChannel = { handle, displayName -> onOpenChannel = { handle, displayName ->
playingVideo = null playingVideo = null
channelDetails = handle channelDetails = handle
channelName = displayName channelName = displayName
}, },
onOpenVideo = { playingVideo = it }, onOpenVideo = { playingVideo = it; playingVideoHost = null },
) )
} }
} }

View file

@ -115,6 +115,9 @@ sealed interface PlayerUiState {
class PlayerViewModel( class PlayerViewModel(
private val repository: PeerTubeRepository, private val repository: PeerTubeRepository,
private val tokenStore: TokenStore, private val tokenStore: TokenStore,
/** Non-null when the video lives on another instance (vidiverse/search results):
* it is fetched login-free from that instance and never reported to local history. */
private val sourceHost: String? = null,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow<PlayerUiState>(PlayerUiState.Loading) private val _state = MutableStateFlow<PlayerUiState>(PlayerUiState.Loading)
@ -126,9 +129,13 @@ class PlayerViewModel(
fun load(shortUuid: String) { fun load(shortUuid: String) {
currentShortUuid = shortUuid currentShortUuid = shortUuid
viewModelScope.launch { viewModelScope.launch {
// Freshen the token before media requests hit it (refresh on 401 via authenticator). if (sourceHost == null) {
repository.me() // Freshen the token before media requests hit it (refresh on 401 via authenticator).
val result = repository.video(shortUuid, userHistory = true) repository.me()
}
val result = sourceHost
?.let { repository.remoteVideo(it, shortUuid) }
?: repository.video(shortUuid, userHistory = true)
result.fold( result.fold(
onSuccess = { detail -> onSuccess = { detail ->
val play = repository.playoutUrl(detail) val play = repository.playoutUrl(detail)
@ -153,8 +160,10 @@ class PlayerViewModel(
} }
} }
/** Sends a final watching report (on exit / periodic), so positions never go stale. */ /** Sends a final watching report (on exit / periodic), so positions never go stale.
* Remote-instance videos are not in this instance's history — never reported. */
fun persistExit(shortUuid: String, currentTimeSeconds: Int) { fun persistExit(shortUuid: String, currentTimeSeconds: Int) {
if (sourceHost != null) return
viewModelScope.launch { repository.reportWatching(shortUuid, currentTimeSeconds) } viewModelScope.launch { repository.reportWatching(shortUuid, currentTimeSeconds) }
} }
@ -171,11 +180,15 @@ class PlayerViewModel(
} }
companion object { companion object {
fun factory(repository: PeerTubeRepository, tokenStore: TokenStore): ViewModelProvider.Factory = fun factory(
repository: PeerTubeRepository,
tokenStore: TokenStore,
sourceHost: String? = null,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = override fun <T : ViewModel> create(modelClass: Class<T>): T =
PlayerViewModel(repository, tokenStore) as T PlayerViewModel(repository, tokenStore, sourceHost) as T
} }
} }
} }
@ -192,14 +205,16 @@ class PlayerViewModel(
fun PlayerScreen( fun PlayerScreen(
shortUuid: String, shortUuid: String,
onBack: () -> Unit, onBack: () -> Unit,
/** Set when the video lives on another instance (vidiverse search results). */
sourceHost: String? = null,
// From the info sidebar's local PeerTube video links: switches the player over.
onOpenVideo: (String) -> Unit = {},
// From the paused/controls overlay's channel icon: (handle, displayName), // From the paused/controls overlay's channel icon: (handle, displayName),
// must also exit the player screen first. // must also exit the player screen first.
onOpenChannel: (String, String) -> Unit = { _, _ -> }, onOpenChannel: (String, String) -> Unit = { _, _ -> },
// From the info sidebar's PeerTube video links: switches the player over.
onOpenVideo: (String) -> Unit = {},
viewModel: PlayerViewModel = viewModel( viewModel: PlayerViewModel = viewModel(
key = "player$shortUuid", key = "player$shortUuid$sourceHost",
factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore), factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore, sourceHost),
), ),
) { ) {
BackHandler { onBack() } BackHandler { onBack() }

View file

@ -125,7 +125,8 @@ fun VideoCard(
) { ) {
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
.data(AppGraph.repository.absoluteUrl(path)) // search-index results carry an absolute URL for their own instance
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs .size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
.build() .build()
AsyncImage( AsyncImage(

View file

@ -16,6 +16,9 @@ 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
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.focusable
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -32,6 +35,9 @@ 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.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures
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
@ -73,7 +79,11 @@ fun VideoListScreen(
// Which card was opened last — re-focus it when playback ends (UX report BUG-3). // Which card was opened last — re-focus it when playback ends (UX report BUG-3).
var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable(epoch) { mutableStateOf(-1) } var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable(epoch) { mutableStateOf(-1) }
LaunchedEffect(items.isNotEmpty(), source) { LaunchedEffect(items.isNotEmpty(), source) {
if (items.isNotEmpty()) runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() } // With a running search the first RESULT gets focus; without one the
// field does (the SearchHeader's own effect) — never both (issue #20).
if (items.isNotEmpty() && (source !is ListSource.Search || viewModel.searchActive)) {
runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() }
}
} }
LaunchedEffect(restoreToken) { LaunchedEffect(restoreToken) {
if (restoreToken > 0) { if (restoreToken > 0) {
@ -120,7 +130,8 @@ fun VideoListScreen(
if (items.isEmpty()) { if (items.isEmpty() && !viewModel.loadingInitial && source !is ListSource.Search) {
// Loaded-but-empty non-search sources: full-page boxes (error has its own).
Box( Box(
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@ -158,7 +169,7 @@ fun VideoListScreen(
// they scroll with the content and never stack under the cards. // they scroll with the content and never stack under the cards.
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
when (source) { when (source) {
is ListSource.Search -> SearchHeader(viewModel) is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer)
is ListSource.Channel -> ChannelHeader( is ListSource.Channel -> ChannelHeader(
channelHeader = viewModel.channelHeader, channelHeader = viewModel.channelHeader,
videoTotal = viewModel.videoTotal, videoTotal = viewModel.videoTotal,
@ -166,6 +177,24 @@ fun VideoListScreen(
else -> {} else -> {}
} }
} }
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
item(key = "search-empty", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
// Issue #20: an empty search stays usable — the field above keeps
// focus; a hint for no-term vs no-results so the page isn't blank.
Column(
Modifier.fillMaxWidth().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = if (viewModel.searchActive)
stringResource(R.string.empty_list)
else "Type a search term above, then OK",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
if (items.isEmpty() && viewModel.loadingInitial) { if (items.isEmpty() && viewModel.loadingInitial) {
// Placeholder cards while the page's first chunk loads (issue #19): // Placeholder cards while the page's first chunk loads (issue #19):
// shaped like the real cards, with the faint shimmer, on every // shaped like the real cards, with the faint shimmer, on every
@ -187,9 +216,6 @@ fun VideoListScreen(
} }
} }
} }
if (items.isEmpty() && !viewModel.loadingInitial && viewModel.initialError == null) {
return@LazyVerticalGrid
}
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
@ -231,9 +257,29 @@ fun VideoListScreen(
} }
@Composable @Composable
private fun SearchHeader(viewModel: VideoListViewModel) { private fun SearchHeader(viewModel: VideoListViewModel, onOpenDrawer: () -> Unit = {}) {
var text by remember { mutableStateOf(viewModel.query.value) } var text by remember { mutableStateOf(viewModel.query.value) }
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
// The field owns focus on entering the Search screen (issue #20) — with no
// results yet there is nothing else focusable, and LEFT from it opens the
// sidebar so the screen is never a dead end. DOWN reaches the scope chips,
// DOWN again the result cards.
val fieldFocus = remember { FocusRequester() }
val chipFocus = remember { FocusRequester() }
val vidiverseFocus = remember { FocusRequester() }
// Focus the field on entry only when NO search is running (then the
// screen's auto-focus goes to the results instead — issue #20's rules).
LaunchedEffect(Unit) {
if (viewModel.searchActive) return@LaunchedEffect
// The focus target attaches a beat after composition; retry a few
// times — a silent abort leaves the whole screen with no focus.
repeat(8) {
kotlinx.coroutines.delay(120)
if (runCatching { fieldFocus.requestFocus() }.isSuccess) {
return@LaunchedEffect
}
}
}
Column(Modifier.fillMaxWidth().padding(16.dp)) { Column(Modifier.fillMaxWidth().padding(16.dp)) {
TextField( TextField(
value = text, value = text,
@ -248,14 +294,52 @@ private fun SearchHeader(viewModel: VideoListViewModel) {
), ),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(fieldFocus)
.onPreviewKeyEvent { ke -> .onPreviewKeyEvent { ke ->
when (ke.key) { if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) {
Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } when (ke.nativeKeyEvent.keyCode) {
Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } android.view.KeyEvent.KEYCODE_DPAD_LEFT -> { onOpenDrawer(); true }
else -> false android.view.KeyEvent.KEYCODE_DPAD_DOWN -> {
} runCatching { chipFocus.requestFocus() }.onFailure {
android.util.Log.e("JTubeChip", "chip focus failed: " + it.message)
}
android.util.Log.w("JTubeChip", "field DOWN fired")
true
}
android.view.KeyEvent.KEYCODE_DPAD_UP -> {
focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true
}
else -> false
}
} else false
}, },
) )
// Scope chips: Local vs Vidiverse (the instance's search index — only
// selectable when /config allows it). The active scope re-runs the query.
androidx.compose.foundation.layout.Row(
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 8.dp),
) {
// DOWN from the field lands here explicitly (geometric focus search
// from inside a grid item skipped the chips).
ScopeChip(
label = "Local",
active = viewModel.searchScope == "local",
modifier = Modifier.focusRequester(chipFocus),
onLeft = onOpenDrawer,
onRight = {
runCatching { vidiverseFocus.requestFocus() }
},
) { viewModel.changeSearchScope("local"); viewModel.triggerSearch(text); focusManager.clearFocus() }
if (viewModel.vidiverseAvailable) {
ScopeChip(
label = "Vidiverse",
active = viewModel.searchScope == "vidiverse",
modifier = Modifier.focusRequester(vidiverseFocus),
onLeft = { runCatching { chipFocus.requestFocus() } },
) { viewModel.changeSearchScope("vidiverse"); viewModel.triggerSearch(text); focusManager.clearFocus() }
}
}
Button( Button(
onClick = { viewModel.triggerSearch(text) }, onClick = { viewModel.triggerSearch(text) },
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
@ -265,6 +349,53 @@ private fun SearchHeader(viewModel: VideoListViewModel) {
} }
} }
@Composable
private fun ScopeChip(
label: String,
active: Boolean,
modifier: Modifier = Modifier,
// Explicit neighbors: LEFT at the row's start opens the sidebar, like the
// field and the cards; RIGHT moves through the chips.
onLeft: (() -> Unit)? = null,
onRight: (() -> Unit)? = null,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.background(
when {
active -> Color(0xFFCE2B2B)
focused -> Color(0x55FFFFFF)
else -> Color(0x22222222)
},
RoundedCornerShape(16.dp),
)
.onFocusChanged { focused = it.isFocused }
.onPreviewKeyEvent { ke ->
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) {
when (ke.nativeKeyEvent.keyCode) {
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> {
onLeft?.invoke(); onLeft != null
}
android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> {
onRight?.invoke(); onRight != null
}
else -> false
}
} else false
}
.dpadCenterActivatable(onClick)
.focusable()
// mouse/tap fallback: no keyboard double-fire (keys are consumed in preview)
.pointerInput(Unit) { detectTapGestures { onClick() } }
.padding(horizontal = 14.dp, vertical = 7.dp)
) {
Text(label, color = Color.White, style = MaterialTheme.typography.bodySmall)
}
}
/** Channel page header: banner strip, then avatar + display name + handle, /** Channel page header: banner strip, then avatar + display name + handle,
* a metadata strip (subscribers/videos/created + owner, issue #14) and the * a metadata strip (subscribers/videos/created + owner, issue #14) and the
* channel description snippet above the videos. */ * channel description snippet above the videos. */