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:
parent
52a0ba8fb9
commit
d99c71a67a
8 changed files with 253 additions and 31 deletions
|
|
@ -158,6 +158,14 @@ class PeerTubeRepository(
|
|||
suspend fun localChannels(start: Long, count: Int, search: String? = null): Result<VideoChannelListResponse> =
|
||||
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> =
|
||||
runCatching { network.api.videoChannel(handle) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<net.jeena.jtubetv.data.api.VideoChannelDto?>(
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name
|
||||
// NOT saveable: launching the app should never resume a half-watched player session.
|
||||
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
|
||||
// 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 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(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 <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(
|
||||
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() }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue