ui: Rebuild all screens when the session epoch changes

Signing out and logging in as another account kept showing the
previous account's videos: the list ViewModels live in the activity
store and are keyed by destination only. A session epoch now
increments on logout and on each successful login; every ViewModel
key, grid scroll state and per-item focus map is keyed by it, so an
account switch starts from genuinely fresh data.
This commit is contained in:
Jeena 2026-10-10 12:41:55 +00:00
parent 9d89c1c060
commit 14cd3d3976
6 changed files with 64 additions and 14 deletions

View file

@ -19,6 +19,13 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit
private val _sessionState = MutableStateFlow<SessionState>(SessionState.Loading)
val sessionState: StateFlow<SessionState> = _sessionState
/**
* Bumped on every logout and successful login: all list ViewModels and saved
* UI state are keyed by it, so switching accounts never shows the previous
* account's cached content.
*/
val sessionEpoch = MutableStateFlow(0)
val loginError = MutableStateFlow<String?>(null)
val loggingIn = MutableStateFlow(false)
@ -35,6 +42,7 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit
loggingIn.value = false
if (result.isSuccess) {
_sessionState.value = SessionState.Ready
sessionEpoch.value += 1
} else {
loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) {
"Invalid credentials"
@ -60,5 +68,6 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit
fun logout() {
repository.signOut()
_sessionState.value = SessionState.NeedsSetup
sessionEpoch.value += 1
}
}

View file

@ -65,7 +65,8 @@ class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel<Vide
@Composable
fun ChannelsScreen(
onOpenChannel: (handle: String) -> Unit,
viewModel: ChannelsViewModel = viewModel(factory = ChannelsViewModel.factory),
epoch: Int = 0,
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
) {
LaunchedEffect(Unit) { viewModel.loadMore(force = true) }

View file

@ -124,7 +124,8 @@ class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedL
fun HomeRowsScreen(
onOpenVideo: (VideoDto) -> Unit,
restoreToken: Int = 0,
viewModel: HomeRowsViewModel = viewModel(factory = HomeRowsViewModel.factory),
epoch: Int = 0,
viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory),
) {
LaunchedEffect(Unit) { viewModel.load() }
LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() }

View file

@ -15,6 +15,7 @@ import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.filled.PersonalVideo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -47,6 +48,7 @@ enum class Destination(val title: String) {
* focused card survive BACK (couch users don't want to re-navigate from the top). */
@Composable
fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) {
val epoch by viewModel.sessionEpoch.collectAsState()
var destination by rememberSaveable { mutableStateOf(Destination.Home) }
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
@ -64,6 +66,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
}
Box(modifier = Modifier.fillMaxSize()) {
androidx.compose.runtime.key(epoch) {
if (destination == Destination.Channels && channelDetails != null) {
androidx.compose.foundation.layout.Box(
modifier = Modifier
@ -75,6 +78,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
source = ListSource.Channel(channelDetails!!, channelDetails!!),
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch,
)
}
} else {
@ -133,33 +137,41 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
) {
when (destination) {
Destination.Home ->
HomeRowsScreen(onOpenVideo = { playingVideo = it.shortUUID })
HomeRowsScreen(
onOpenVideo = { playingVideo = it.shortUUID },
epoch = epoch,
)
Destination.MyVideos ->
VideoListScreen(
source = ListSource.MyVideos,
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch,
)
Destination.Channels -> ChannelsScreen(
onOpenChannel = { handle -> channelDetails = handle },
epoch = epoch,
)
Destination.Search ->
VideoListScreen(
source = ListSource.Search,
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch,
)
Destination.Subscriptions ->
VideoListScreen(
source = ListSource.Subscriptions,
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch,
)
Destination.History ->
VideoListScreen(
source = ListSource.History,
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch,
)
}
}
@ -173,6 +185,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
)
}
}
}
}
private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector =

View file

@ -16,9 +16,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.onPreviewKeyEvent
@ -41,7 +44,12 @@ fun SetupScreen(
val password = rememberSaveable { mutableStateOf("") }
val showPassword = rememberSaveable { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
// Deterministic D-pad chain: DOWN from URL goes to Username, not Password
// (geometric focus search misfired on device).
val urlFocus = remember { FocusRequester() }
val userFocus = remember { FocusRequester() }
val passFocus = remember { FocusRequester() }
val signInFocus = remember { FocusRequester() }
val busy = loggingIn.collectAsState()
val message = error.collectAsState()
val localError = rememberSaveable { mutableStateOf<String?>(null) }
@ -67,11 +75,17 @@ fun SetupScreen(
onValueChange = { url.value = it },
label = { Text("PeerTube instance URL") },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Next,
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onNext = { userFocus.requestFocus() },
),
modifier = Modifier
.focusRequester(urlFocus)
.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 }
Key.DirectionDown -> { userFocus.requestFocus(); true }
else -> false
}
}
@ -83,11 +97,18 @@ fun SetupScreen(
onValueChange = { username.value = it },
label = { Text("Username") },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Next,
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onNext = { passFocus.requestFocus() },
),
modifier = Modifier
.focusRequester(userFocus)
.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 }
Key.DirectionDown -> { passFocus.requestFocus(); true }
Key.DirectionUp -> { urlFocus.requestFocus(); true }
else -> false
}
}
@ -102,11 +123,14 @@ fun SetupScreen(
visualTransformation =
if (showPassword.value) androidx.compose.ui.text.input.VisualTransformation.None
else androidx.compose.ui.text.input.PasswordVisualTransformation(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Done,
),
modifier = Modifier
.focusRequester(passFocus)
.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 }
Key.DirectionUp -> { userFocus.requestFocus(); true }
else -> false
}
}

View file

@ -42,20 +42,21 @@ fun VideoListScreen(
source: ListSource,
onOpenVideo: (VideoDto) -> Unit,
restoreToken: Int = 0,
epoch: Int = 0,
viewModel: VideoListViewModel = viewModel(
key = sourceKey(source),
key = "v$epoch:" + sourceKey(source),
factory = VideoListViewModel.factory(source),
),
) {
val items = viewModel.items
// per-item focus requesters so BACK from the player can refocus the opened card
val requesters = remember(items.size) {
val requesters = androidx.compose.runtime.remember(items.size, epoch) {
androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>()
}
// Pull initial focus out of the sidebar rail and into the content grid.
val initialFocus = remember { FocusRequester() }
// Which card was opened last — re-focus it when playback ends (UX report BUG-3).
var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(-1) }
var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable(epoch) { mutableStateOf(-1) }
LaunchedEffect(items.isNotEmpty(), source) {
if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() }
}
@ -72,7 +73,8 @@ fun VideoListScreen(
}
// Scroll position survives destination switches (per source), not just player exits.
val gridState = androidx.compose.runtime.saveable.rememberSaveable(
key = sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver,
inputs = arrayOf(epoch),
key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver,
) { androidx.compose.foundation.lazy.grid.LazyGridState() }
when (source) {