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> =
|
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) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
if (sourceHost == null) {
|
||||||
// Freshen the token before media requests hit it (refresh on 401 via authenticator).
|
// Freshen the token before media requests hit it (refresh on 401 via authenticator).
|
||||||
repository.me()
|
repository.me()
|
||||||
val result = repository.video(shortUuid, userHistory = true)
|
}
|
||||||
|
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() }
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
} 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. */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue