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:
parent
9d89c1c060
commit
14cd3d3976
6 changed files with 64 additions and 14 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -172,6 +184,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
onBack = { playingVideo = null },
|
onBack = { playingVideo = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue