Assembling the channel rows is slow, so it now starts when the app launches instead of when the view is first opened; opening the view usually finds the rows already there, and a second pass is skipped while one is in flight or the data is still fresh (#32). Coming back from a channel page re-selects the channel that was opened, scrolling its row back to it, instead of snapping to the first one (#34). Also drops the custom focusRestorer fallback added for #29: handing back a requester whose card had scrolled out of composition crashed the app with "FocusRequester is not initialized" when focus re-entered a scrolled row (hit on the Channels screen). The plain restorer keeps the #29 behaviour without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
105e1c82fe
commit
654963436f
3 changed files with 60 additions and 9 deletions
|
|
@ -127,11 +127,17 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
// its shimmer placeholder rows until the (fast-path, few-request) load lands,
|
// its shimmer placeholder rows until the (fast-path, few-request) load lands,
|
||||||
// instead of painting a stale snapshot that reshuffled as stats streamed in.
|
// instead of painting a stale snapshot that reshuffled as stats streamed in.
|
||||||
|
|
||||||
|
/** Guards against a second pass while the first is still running (issue #32:
|
||||||
|
* the load now starts at app launch, so opening the view can overlap it). */
|
||||||
|
private var inFlight = false
|
||||||
|
|
||||||
fun load(force: Boolean = false) {
|
fun load(force: Boolean = false) {
|
||||||
// Re-entry (destination switch away + back) inside the freshness window:
|
// Re-entry (destination switch away + back) inside the freshness window:
|
||||||
// repaint the in-memory rows and skip the network (issue #13) — rows are
|
// repaint the in-memory rows and skip the network (issue #13) — rows are
|
||||||
// never torn down on re-entry; an account switch builds a new ViewModel.
|
// never torn down on re-entry; an account switch builds a new ViewModel.
|
||||||
|
if (inFlight) return
|
||||||
if (!force && userRows.isNotEmpty() && fresh()) return
|
if (!force && userRows.isNotEmpty() && fresh()) return
|
||||||
|
inFlight = true
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -160,6 +166,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
error = e.message ?: "Could not load channels"
|
error = e.message ?: "Could not load channels"
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
|
inFlight = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -310,6 +317,8 @@ fun ChannelsScreen(
|
||||||
epoch: Int = 0,
|
epoch: Int = 0,
|
||||||
onOpenDrawer: () -> Unit = {},
|
onOpenDrawer: () -> Unit = {},
|
||||||
drawerPulse: Int = 0,
|
drawerPulse: Int = 0,
|
||||||
|
/** Channel opened last — gets the focus back on return (issue #34). */
|
||||||
|
focusHandle: String? = null,
|
||||||
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
|
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(epoch) { viewModel.load() }
|
LaunchedEffect(epoch) { viewModel.load() }
|
||||||
|
|
@ -358,13 +367,22 @@ fun ChannelsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item(key = "row-" + group.ownerName) {
|
item(key = "row-" + group.ownerName) {
|
||||||
|
// Coming back from a channel page, focus the card we opened
|
||||||
|
// instead of the very first one (issue #34).
|
||||||
|
val target = group.channels.indexOfFirst {
|
||||||
|
it.name + "@" + it.host == focusHandle
|
||||||
|
}
|
||||||
|
val isTargetRow = target >= 0
|
||||||
ChannelRow(
|
ChannelRow(
|
||||||
group.channels,
|
group.channels,
|
||||||
onOpenChannel,
|
onOpenChannel,
|
||||||
onOpenDrawer,
|
onOpenDrawer,
|
||||||
drawerPulse = drawerPulse,
|
drawerPulse = drawerPulse,
|
||||||
focused = !focusHandedOut.value &&
|
focused = !focusHandedOut.value && (
|
||||||
group == viewModel.userRows.firstOrNull(),
|
if (focusHandle != null) isTargetRow
|
||||||
|
else group == viewModel.userRows.firstOrNull()
|
||||||
|
),
|
||||||
|
focusIndex = if (isTargetRow) target else 0,
|
||||||
onFocused = { focusHandedOut.value = true },
|
onFocused = { focusHandedOut.value = true },
|
||||||
stats = { viewModel.stats[it.name + "@" + it.host] },
|
stats = { viewModel.stats[it.name + "@" + it.host] },
|
||||||
platformName = { viewModel.platformNames[it.host ?: ""] },
|
platformName = { viewModel.platformNames[it.host ?: ""] },
|
||||||
|
|
@ -419,6 +437,8 @@ private fun ChannelRow(
|
||||||
onOpenDrawer: () -> Unit = {},
|
onOpenDrawer: () -> Unit = {},
|
||||||
drawerPulse: Int = 0,
|
drawerPulse: Int = 0,
|
||||||
focused: Boolean,
|
focused: Boolean,
|
||||||
|
/** Which card to hand the initial focus to (issue #34). */
|
||||||
|
focusIndex: Int = 0,
|
||||||
onFocused: () -> Unit = {},
|
onFocused: () -> Unit = {},
|
||||||
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
|
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
|
||||||
platformName: (VideoChannelDto) -> String?,
|
platformName: (VideoChannelDto) -> String?,
|
||||||
|
|
@ -445,17 +465,30 @@ private fun ChannelRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (focused) {
|
if (focused) {
|
||||||
LaunchedEffect(channels.isNotEmpty()) {
|
LaunchedEffect(channels.isNotEmpty(), focusIndex) {
|
||||||
if (channels.isNotEmpty()) {
|
if (channels.isNotEmpty()) {
|
||||||
if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused()
|
val target = focusIndex.coerceIn(0, channels.size - 1)
|
||||||
|
// Bring it on screen first; a card further right isn't composed yet,
|
||||||
|
// so retry until its focus requester is attached (issue #34).
|
||||||
|
if (target > 0) runCatching { rowState.scrollToItem(target) }
|
||||||
|
repeat(8) {
|
||||||
|
delay(80)
|
||||||
|
val ok = runCatching { focusRequesters[target]?.requestFocus() }.getOrNull()
|
||||||
|
if (ok != null) {
|
||||||
|
onFocused()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = rowState,
|
state = rowState,
|
||||||
// Land on the last-focused card when moving between rows (issue #29).
|
// Land on the last-focused card when moving between rows (issue #29). No
|
||||||
modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default },
|
// custom fallback: a requester whose card has scrolled out of composition
|
||||||
|
// crashed with "FocusRequester is not initialized".
|
||||||
|
modifier = Modifier.focusRestorer(),
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -457,8 +457,10 @@ fun FocusScrollRow(
|
||||||
state = rowState,
|
state = rowState,
|
||||||
// Jumping between rows lands on the card this row last had focus on, not
|
// Jumping between rows lands on the card this row last had focus on, not
|
||||||
// the geometrically-nearest one (issue #29): the rows stop shifting
|
// the geometrically-nearest one (issue #29): the rows stop shifting
|
||||||
// left/right as you move up and down between them.
|
// left/right as you move up and down between them. No custom fallback —
|
||||||
modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default },
|
// handing back a requester whose card has scrolled out of composition
|
||||||
|
// crashed with "FocusRequester is not initialized".
|
||||||
|
modifier = Modifier.focusRestorer(),
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,16 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
var destination by rememberSaveable { mutableStateOf(Destination.Home) }
|
var destination by rememberSaveable { mutableStateOf(Destination.Home) }
|
||||||
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
|
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
|
||||||
var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name
|
var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name
|
||||||
|
// Last channel opened from the Channels screen — refocused on return (#34).
|
||||||
|
var lastOpenedChannel by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
// Channels are expensive to assemble, so start that work at app launch rather
|
||||||
|
// than when the view is first opened (issue #32); the view then usually finds
|
||||||
|
// the rows already there, and load() no-ops while fresh.
|
||||||
|
val channelsViewModel: ChannelsViewModel = androidx.lifecycle.viewmodel.compose.viewModel(
|
||||||
|
key = "channels$epoch",
|
||||||
|
factory = ChannelsViewModel.factory,
|
||||||
|
)
|
||||||
|
LaunchedEffect(epoch) { channelsViewModel.load() }
|
||||||
// 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).
|
// Non-null while the playing video lives on ANOTHER instance (vidiverse results).
|
||||||
|
|
@ -220,10 +230,16 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
drawerPulse = drawerPulse,
|
drawerPulse = drawerPulse,
|
||||||
)
|
)
|
||||||
Destination.Channels -> ChannelsScreen(
|
Destination.Channels -> ChannelsScreen(
|
||||||
onOpenChannel = { handle -> channelDetails = handle },
|
onOpenChannel = { handle ->
|
||||||
|
channelDetails = handle
|
||||||
|
lastOpenedChannel = handle
|
||||||
|
},
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
onOpenDrawer = { drawerOpen = true },
|
onOpenDrawer = { drawerOpen = true },
|
||||||
drawerPulse = drawerPulse,
|
drawerPulse = drawerPulse,
|
||||||
|
// Back from a channel page re-selects it (issue #34).
|
||||||
|
focusHandle = lastOpenedChannel,
|
||||||
|
viewModel = channelsViewModel,
|
||||||
)
|
)
|
||||||
Destination.Search ->
|
Destination.Search ->
|
||||||
VideoListScreen(
|
VideoListScreen(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue