From d99c71a67a7b67495cf9f595e456240d193e9ba8 Mon Sep 17 00:00:00 2001 From: Jeena Date: Wed, 14 Oct 2026 01:35:31 +0000 Subject: [PATCH] search: usable empty state, left-out rule, vidiverse scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 8 + .../java/net/jeena/jtubetv/data/api/Dtos.kt | 16 ++ .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 8 + .../jtubetv/ui/list/VideoListViewModel.kt | 33 +++- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 26 ++- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 35 ++-- .../jtubetv/ui/screens/list/VideoCard.kt | 3 +- .../ui/screens/list/VideoListScreen.kt | 155 ++++++++++++++++-- 8 files changed, 253 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index b143d8e..d780885 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -158,6 +158,14 @@ class PeerTubeRepository( suspend fun localChannels(start: Long, count: Int, search: String? = null): Result = 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 = + 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 = + runCatching { unauthenticatedApiFor("https://" + host).video(uuidOrShort) } + suspend fun videoChannel(handle: String): Result = runCatching { network.api.videoChannel(handle) } diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 62e6979..2231b82 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -53,6 +53,9 @@ data class VideoDto( // Present on list responses (absent in detail) — lets the channels screen // group local channels by owner without paging the channel listing. 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 nsfw: Boolean = false, val views: Long = 0, @@ -154,6 +157,19 @@ data class VideoTokenFiles( @Serializable data class ServerConfigDto( 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 diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt index 5301b22..e98e4f4 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -65,6 +65,14 @@ interface PeerTubeApi { @Query("sort") sort: String = "-publishedAt", ): 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") suspend fun searchChannels( @Query("search") search: String, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt index 71e4d5f..3a10e30 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -36,6 +36,13 @@ class VideoListViewModel( val query = MutableStateFlow("") 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. */ var channelHeader by androidx.compose.runtime.mutableStateOf( null, @@ -60,6 +67,13 @@ class VideoListViewModel( } 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 if (context != null && !shouldSkipSnapshot) { viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { @@ -117,7 +131,13 @@ class VideoListViewModel( repository.history(start, count).map { it.map { e -> VideoListItem.HistoryEntry(e) } } is ListSource.Search -> { 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 -> repository.channelVideos(s.handle, start, count).map { resp -> @@ -157,6 +177,17 @@ class VideoListViewModel( 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() { if (items.isEmpty() && loadingInitial) loadMore(force = true) } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt index a83fbd2..e14554d 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -89,6 +89,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi var channelName by rememberSaveable { mutableStateOf(null) } // display name // NOT saveable: launching the app should never resume a half-watched player session. var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID + // Non-null while the playing video lives on ANOTHER instance (vidiverse results). + var playingVideoHost by rememberSaveable { mutableStateOf(null) } var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends var drawerOpen by rememberSaveable { mutableStateOf(false) } val context = androidx.compose.ui.platform.LocalContext.current @@ -160,6 +162,15 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi profile = userProfile, ) 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 @@ -173,7 +184,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi channelDetails != null -> VideoListScreen( source = ListSource.Channel(channelDetails!!, channelName ?: ""), - onOpenVideo = { playingVideo = it.shortUUID }, + onOpenVideo = openCardVideo, restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, @@ -182,7 +193,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi else -> when (destination) { Destination.Home -> HomeRowsScreen( - onOpenVideo = { playingVideo = it.shortUUID }, + onOpenVideo = openCardVideo, restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, @@ -192,7 +203,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi Destination.MyVideos -> VideoListScreen( source = ListSource.MyVideos, - onOpenVideo = { playingVideo = it.shortUUID }, + onOpenVideo = openCardVideo, restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, @@ -207,7 +218,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi Destination.Search -> VideoListScreen( source = ListSource.Search, - onOpenVideo = { playingVideo = it.shortUUID }, + onOpenVideo = openCardVideo, restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, @@ -216,7 +227,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi Destination.Subscriptions -> VideoListScreen( source = ListSource.Subscriptions, - onOpenVideo = { playingVideo = it.shortUUID }, + onOpenVideo = openCardVideo, restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, @@ -225,7 +236,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi Destination.History -> VideoListScreen( source = ListSource.History, - onOpenVideo = { playingVideo = it.shortUUID }, + onOpenVideo = openCardVideo, restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, @@ -255,13 +266,14 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi if (playingVideo != null) { PlayerScreen( shortUuid = playingVideo!!, + sourceHost = playingVideoHost, onBack = { playingVideo = null }, onOpenChannel = { handle, displayName -> playingVideo = null channelDetails = handle channelName = displayName }, - onOpenVideo = { playingVideo = it }, + onOpenVideo = { playingVideo = it; playingVideoHost = null }, ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index a8e6593..3ba847b 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -115,6 +115,9 @@ sealed interface PlayerUiState { class PlayerViewModel( private val repository: PeerTubeRepository, 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() { private val _state = MutableStateFlow(PlayerUiState.Loading) @@ -126,9 +129,13 @@ class PlayerViewModel( fun load(shortUuid: String) { currentShortUuid = shortUuid viewModelScope.launch { - // Freshen the token before media requests hit it (refresh on 401 via authenticator). - repository.me() - val result = repository.video(shortUuid, userHistory = true) + if (sourceHost == null) { + // Freshen the token before media requests hit it (refresh on 401 via authenticator). + repository.me() + } + val result = sourceHost + ?.let { repository.remoteVideo(it, shortUuid) } + ?: repository.video(shortUuid, userHistory = true) result.fold( onSuccess = { 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) { + if (sourceHost != null) return viewModelScope.launch { repository.reportWatching(shortUuid, currentTimeSeconds) } } @@ -171,11 +180,15 @@ class PlayerViewModel( } companion object { - fun factory(repository: PeerTubeRepository, tokenStore: TokenStore): ViewModelProvider.Factory = + fun factory( + repository: PeerTubeRepository, + tokenStore: TokenStore, + sourceHost: String? = null, + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = - PlayerViewModel(repository, tokenStore) as T + PlayerViewModel(repository, tokenStore, sourceHost) as T } } } @@ -192,14 +205,16 @@ class PlayerViewModel( fun PlayerScreen( shortUuid: String, 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), // must also exit the player screen first. onOpenChannel: (String, String) -> Unit = { _, _ -> }, - // From the info sidebar's PeerTube video links: switches the player over. - onOpenVideo: (String) -> Unit = {}, viewModel: PlayerViewModel = viewModel( - key = "player$shortUuid", - factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore), + key = "player$shortUuid$sourceHost", + factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore, sourceHost), ), ) { BackHandler { onBack() } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt index ccd3802..d80fa37 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -125,7 +125,8 @@ fun VideoCard( ) { video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> 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 .build() AsyncImage( diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index 28ce36e..571e02f 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed +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.CircularProgressIndicator import androidx.compose.material3.MaterialTheme @@ -32,6 +35,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.ui.focus.focusRequester 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). var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable(epoch) { mutableStateOf(-1) } 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) { 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( Modifier.fillMaxSize(), contentAlignment = Alignment.Center, @@ -158,7 +169,7 @@ fun VideoListScreen( // 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.Search -> SearchHeader(viewModel, onOpenDrawer) is ListSource.Channel -> ChannelHeader( channelHeader = viewModel.channelHeader, videoTotal = viewModel.videoTotal, @@ -166,6 +177,24 @@ fun VideoListScreen( 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) { // Placeholder cards while the page's first chunk loads (issue #19): // 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 -> val video: VideoDto = when (item) { is VideoListItem.HistoryEntry -> item.history.video @@ -231,9 +257,29 @@ fun VideoListScreen( } @Composable -private fun SearchHeader(viewModel: VideoListViewModel) { +private fun SearchHeader(viewModel: VideoListViewModel, onOpenDrawer: () -> Unit = {}) { var text by remember { mutableStateOf(viewModel.query.value) } 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)) { TextField( value = text, @@ -248,14 +294,52 @@ private fun SearchHeader(viewModel: VideoListViewModel) { ), modifier = Modifier .fillMaxWidth() + .focusRequester(fieldFocus) .onPreviewKeyEvent { ke -> - when (ke.key) { - Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } - Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } - else -> false - } + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) { + when (ke.nativeKeyEvent.keyCode) { + android.view.KeyEvent.KEYCODE_DPAD_LEFT -> { onOpenDrawer(); true } + 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( onClick = { viewModel.triggerSearch(text) }, 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, * a metadata strip (subscribers/videos/created + owner, issue #14) and the * channel description snippet above the videos. */