diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt index 247057d..e6e542f 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -19,6 +19,13 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit private val _sessionState = MutableStateFlow(SessionState.Loading) val sessionState: StateFlow = _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(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 } } \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index c0a4d3f..dca0a69 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -65,7 +65,8 @@ class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel 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) } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index a884dc0..a4dc174 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -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() } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt index 18325e9..9bcf9a6 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -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(null) } // handle var playingVideo by rememberSaveable { mutableStateOf(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, ) } } @@ -172,6 +184,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onBack = { playingVideo = null }, ) } + } } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt index fc01343..3f4fc88 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -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(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 } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index 6271151..7adcbce 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -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() } // 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) {