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) private val _sessionState = MutableStateFlow<SessionState>(SessionState.Loading)
val sessionState: StateFlow<SessionState> = _sessionState 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 loginError = MutableStateFlow<String?>(null)
val loggingIn = MutableStateFlow(false) val loggingIn = MutableStateFlow(false)
@ -35,6 +42,7 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit
loggingIn.value = false loggingIn.value = false
if (result.isSuccess) { if (result.isSuccess) {
_sessionState.value = SessionState.Ready _sessionState.value = SessionState.Ready
sessionEpoch.value += 1
} else { } else {
loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) { loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) {
"Invalid credentials" "Invalid credentials"
@ -60,5 +68,6 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit
fun logout() { fun logout() {
repository.signOut() repository.signOut()
_sessionState.value = SessionState.NeedsSetup _sessionState.value = SessionState.NeedsSetup
sessionEpoch.value += 1
} }
} }

View file

@ -65,7 +65,8 @@ class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel<Vide
@Composable @Composable
fun ChannelsScreen( fun ChannelsScreen(
onOpenChannel: (handle: String) -> Unit, 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) } LaunchedEffect(Unit) { viewModel.loadMore(force = true) }

View file

@ -124,7 +124,8 @@ class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedL
fun HomeRowsScreen( fun HomeRowsScreen(
onOpenVideo: (VideoDto) -> Unit, onOpenVideo: (VideoDto) -> Unit,
restoreToken: Int = 0, 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(Unit) { viewModel.load() }
LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() } 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.material.icons.filled.PersonalVideo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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). */ * focused card survive BACK (couch users don't want to re-navigate from the top). */
@Composable @Composable
fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) {
val epoch by viewModel.sessionEpoch.collectAsState()
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 playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID 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()) { Box(modifier = Modifier.fillMaxSize()) {
androidx.compose.runtime.key(epoch) {
if (destination == Destination.Channels && channelDetails != null) { if (destination == Destination.Channels && channelDetails != null) {
androidx.compose.foundation.layout.Box( androidx.compose.foundation.layout.Box(
modifier = Modifier modifier = Modifier
@ -75,6 +78,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
source = ListSource.Channel(channelDetails!!, channelDetails!!), source = ListSource.Channel(channelDetails!!, channelDetails!!),
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch,
) )
} }
} else { } else {
@ -133,33 +137,41 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
) { ) {
when (destination) { when (destination) {
Destination.Home -> Destination.Home ->
HomeRowsScreen(onOpenVideo = { playingVideo = it.shortUUID }) HomeRowsScreen(
onOpenVideo = { playingVideo = it.shortUUID },
epoch = epoch,
)
Destination.MyVideos -> Destination.MyVideos ->
VideoListScreen( VideoListScreen(
source = ListSource.MyVideos, source = ListSource.MyVideos,
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch,
) )
Destination.Channels -> ChannelsScreen( Destination.Channels -> ChannelsScreen(
onOpenChannel = { handle -> channelDetails = handle }, onOpenChannel = { handle -> channelDetails = handle },
epoch = epoch,
) )
Destination.Search -> Destination.Search ->
VideoListScreen( VideoListScreen(
source = ListSource.Search, source = ListSource.Search,
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch,
) )
Destination.Subscriptions -> Destination.Subscriptions ->
VideoListScreen( VideoListScreen(
source = ListSource.Subscriptions, source = ListSource.Subscriptions,
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch,
) )
Destination.History -> Destination.History ->
VideoListScreen( VideoListScreen(
source = ListSource.History, source = ListSource.History,
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch,
) )
} }
} }
@ -174,6 +186,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
} }
} }
} }
}
private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector = private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector =
when (this) { when (this) {

View file

@ -16,9 +16,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
@ -41,7 +44,12 @@ fun SetupScreen(
val password = rememberSaveable { mutableStateOf("") } val password = rememberSaveable { mutableStateOf("") }
val showPassword = rememberSaveable { mutableStateOf(false) } val showPassword = rememberSaveable { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current 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 busy = loggingIn.collectAsState()
val message = error.collectAsState() val message = error.collectAsState()
val localError = rememberSaveable { mutableStateOf<String?>(null) } val localError = rememberSaveable { mutableStateOf<String?>(null) }
@ -67,11 +75,17 @@ fun SetupScreen(
onValueChange = { url.value = it }, onValueChange = { url.value = it },
label = { Text("PeerTube instance URL") }, label = { Text("PeerTube instance URL") },
singleLine = true, 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 modifier = Modifier
.focusRequester(urlFocus)
.onPreviewKeyEvent { ke -> .onPreviewKeyEvent { ke ->
when (ke.key) { when (ke.key) {
Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } Key.DirectionDown -> { userFocus.requestFocus(); true }
Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true }
else -> false else -> false
} }
} }
@ -83,11 +97,18 @@ fun SetupScreen(
onValueChange = { username.value = it }, onValueChange = { username.value = it },
label = { Text("Username") }, label = { Text("Username") },
singleLine = true, 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 modifier = Modifier
.focusRequester(userFocus)
.onPreviewKeyEvent { ke -> .onPreviewKeyEvent { ke ->
when (ke.key) { when (ke.key) {
Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } Key.DirectionDown -> { passFocus.requestFocus(); true }
Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } Key.DirectionUp -> { urlFocus.requestFocus(); true }
else -> false else -> false
} }
} }
@ -102,11 +123,14 @@ fun SetupScreen(
visualTransformation = visualTransformation =
if (showPassword.value) androidx.compose.ui.text.input.VisualTransformation.None if (showPassword.value) androidx.compose.ui.text.input.VisualTransformation.None
else androidx.compose.ui.text.input.PasswordVisualTransformation(), else androidx.compose.ui.text.input.PasswordVisualTransformation(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Done,
),
modifier = Modifier modifier = Modifier
.focusRequester(passFocus)
.onPreviewKeyEvent { ke -> .onPreviewKeyEvent { ke ->
when (ke.key) { when (ke.key) {
Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } Key.DirectionUp -> { userFocus.requestFocus(); true }
Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true }
else -> false else -> false
} }
} }

View file

@ -42,20 +42,21 @@ fun VideoListScreen(
source: ListSource, source: ListSource,
onOpenVideo: (VideoDto) -> Unit, onOpenVideo: (VideoDto) -> Unit,
restoreToken: Int = 0, restoreToken: Int = 0,
epoch: Int = 0,
viewModel: VideoListViewModel = viewModel( viewModel: VideoListViewModel = viewModel(
key = sourceKey(source), key = "v$epoch:" + sourceKey(source),
factory = VideoListViewModel.factory(source), factory = VideoListViewModel.factory(source),
), ),
) { ) {
val items = viewModel.items val items = viewModel.items
// per-item focus requesters so BACK from the player can refocus the opened card // 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>() androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>()
} }
// Pull initial focus out of the sidebar rail and into the content grid. // Pull initial focus out of the sidebar rail and into the content grid.
val initialFocus = remember { FocusRequester() } val initialFocus = remember { FocusRequester() }
// 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 { 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 { initialFocus.requestFocus() } if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() }
} }
@ -72,7 +73,8 @@ fun VideoListScreen(
} }
// Scroll position survives destination switches (per source), not just player exits. // Scroll position survives destination switches (per source), not just player exits.
val gridState = androidx.compose.runtime.saveable.rememberSaveable( 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() } ) { androidx.compose.foundation.lazy.grid.LazyGridState() }
when (source) { when (source) {