From c56a74a3a02c6d69dc1f1bd6b69daa92665a6562 Mon Sep 17 00:00:00 2001 From: StenFredd Date: Wed, 14 Oct 2026 12:20:48 +0300 Subject: [PATCH] Login screens --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 3 +- .../ru/stenfredd/movies/MainActivity.kt | 14 +- .../ru/stenfredd/movies/ui/BrowseScreens.kt | 8 +- .../ru/stenfredd/movies/ui/LoginScreen.kt | 120 ---- .../kotlin/ru/stenfredd/movies/ui/Screen.kt | 8 +- .../stenfredd/movies/ui/components/Avatar.kt | 67 +++ .../stenfredd/movies/ui/components/Decor.kt | 71 +++ .../movies/ui/components/TextInput.kt | 218 ++++++++ .../stenfredd/movies/ui/focus/ScreenFocus.kt | 5 + .../stenfredd/movies/ui/login/LoginScreen.kt | 517 ++++++++++++++++++ .../movies/ui/login/LoginViewModel.kt | 433 +++++++++++++++ .../ru/stenfredd/movies/ui/login/QrCode.kt | 25 + .../ru/stenfredd/movies/ui/shell/Shell.kt | 14 +- .../movies/core/jellyfin/JellyfinSession.kt | 45 +- .../core/jellyfin/LocalServerDiscovery.kt | 65 ++- .../movies/core/jellyfin/ServerLogin.kt | 162 ++++++ gradle/libs.versions.toml | 2 + 18 files changed, 1595 insertions(+), 183 deletions(-) delete mode 100644 app/src/main/kotlin/ru/stenfredd/movies/ui/LoginScreen.kt create mode 100644 app/src/main/kotlin/ru/stenfredd/movies/ui/components/Avatar.kt create mode 100644 app/src/main/kotlin/ru/stenfredd/movies/ui/components/Decor.kt create mode 100644 app/src/main/kotlin/ru/stenfredd/movies/ui/components/TextInput.kt create mode 100644 app/src/main/kotlin/ru/stenfredd/movies/ui/login/LoginScreen.kt create mode 100644 app/src/main/kotlin/ru/stenfredd/movies/ui/login/LoginViewModel.kt create mode 100644 app/src/main/kotlin/ru/stenfredd/movies/ui/login/QrCode.kt create mode 100644 core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/ServerLogin.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 032f2d8..8205af6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -74,6 +74,7 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.timber) + implementation(libs.zxing.core) implementation(libs.androidx.profileinstaller) baselineProfile(project(":baselineprofile")) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d49b72..ba12aa4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,7 +21,8 @@ android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|uiMode" android:exported="true" - android:screenOrientation="landscape"> + android:screenOrientation="landscape" + android:windowSoftInputMode="adjustNothing"> diff --git a/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt b/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt index a2d8d02..cc7ff05 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt @@ -32,12 +32,12 @@ import ru.stenfredd.movies.playback.api.EngineId import ru.stenfredd.movies.playback.api.StreamSelection import ru.stenfredd.movies.ui.DetailScreen import ru.stenfredd.movies.ui.ItemsScreen -import ru.stenfredd.movies.ui.LoginScreen import ru.stenfredd.movies.ui.PlayerScreen import ru.stenfredd.movies.ui.PrototypeScreen import ru.stenfredd.movies.ui.Screen import ru.stenfredd.movies.ui.TourScreen import ru.stenfredd.movies.ui.home.HomeScreen +import ru.stenfredd.movies.ui.login.LoginScreen import ru.stenfredd.movies.ui.shell.Shell import ru.stenfredd.movies.ui.shell.Tab import ru.stenfredd.movies.ui.theme.AppColors @@ -58,7 +58,7 @@ class MainActivity : ComponentActivity() { var ready by remember { mutableStateOf(false) } LaunchedEffect(Unit) { val loggedIn = session.restore() - stack += if (loggedIn) Screen.Root(Tab.Home) else Screen.Login + stack += if (loggedIn) root(Tab.Home) else Screen.Login if (loggedIn) intentTarget()?.let(stack::addAll) ready = true } @@ -94,8 +94,8 @@ class MainActivity : ComponentActivity() { entryProvider = { screen -> NavEntry(screen) { when (screen) { - Screen.Login -> Legacy { LoginScreen(onLoggedIn = { resetTo(Screen.Root(Tab.Home)) }) } - is Screen.Root -> Root(screen.tab, onTab = { resetTo(Screen.Root(it)) }, onOpen = ::open, onLogout = { resetTo(Screen.Login) }) + Screen.Login -> LoginScreen(onDone = { resetTo(root(Tab.Home)) }) + is Screen.Root -> Root(screen.tab, onTab = { resetTo(root(it)) }, onOpen = ::open, onLogout = { resetTo(Screen.Login) }) Screen.Prototype -> Legacy { PrototypeScreen(onOpen = ::open, onLogout = { resetTo(Screen.Login) }) } is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) } is Screen.Detail -> Legacy { DetailScreen(screen, onOpen = ::open) } @@ -107,15 +107,17 @@ class MainActivity : ComponentActivity() { ) } + private fun root(tab: Tab) = Screen.Root(tab, session.session.value?.accountId.orEmpty()) + @Composable private fun Root(tab: Tab, onTab: (Tab) -> Unit, onOpen: (Screen) -> Unit, onLogout: () -> Unit) { val user by session.session.collectAsStateWithLifecycle() Shell( current = tab, userName = user?.userName.orEmpty(), + userSeed = user?.userId?.toString().orEmpty(), onTab = onTab, - // Profile switching comes with the new login screens (stage 3); until then the prototype has "Выйти". - onProfile = { onOpen(Screen.Prototype) }, + onProfile = { onOpen(Screen.Login) }, ) { when (tab) { Tab.Home -> HomeScreen(onOpen = onOpen) diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt index 7314d3e..23cd385 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -40,6 +39,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto import ru.stenfredd.movies.core.jellyfin.JellyfinSession +import ru.stenfredd.movies.ui.components.TextInput +import ru.stenfredd.movies.ui.components.rememberTextInputState +import ru.stenfredd.movies.ui.theme.AppColors +import ru.stenfredd.movies.ui.theme.AppType import javax.inject.Inject @HiltViewModel @@ -53,6 +56,7 @@ fun PrototypeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseVi var resume by remember { mutableStateOf(emptyList()) } var results by remember { mutableStateOf(emptyList()) } var query by remember { mutableStateOf("") } + val searchInput = rememberTextInputState() var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val context = LocalContext.current @@ -84,7 +88,7 @@ fun PrototypeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseVi } item { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Field("Поиск", query, KeyboardType.Text) { query = it } + TextInput(query, { query = it }, searchInput, Modifier.width(400.dp), placeholder = "Поиск", textStyle = AppType.BodyLarge.copy(color = AppColors.Text)) Button(onClick = { scope.launch { results = runCatching { vm.library.search(query) }.getOrDefault(emptyList()) } }) { Text("Найти") } diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/LoginScreen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/LoginScreen.kt deleted file mode 100644 index f0af216..0000000 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/LoginScreen.kt +++ /dev/null @@ -1,120 +0,0 @@ -package ru.stenfredd.movies.ui - -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.runtime.remember -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.tv.material3.Button -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch -import ru.stenfredd.movies.core.jellyfin.JellyfinSession -import javax.inject.Inject - -@HiltViewModel -class LoginViewModel @Inject constructor(val session: JellyfinSession) : ViewModel() - -@Composable -fun LoginScreen(onLoggedIn: () -> Unit, vm: LoginViewModel = hiltViewModel()) { - var url by rememberSaveable { mutableStateOf("https://") } - var user by rememberSaveable { mutableStateOf("") } - var password by rememberSaveable { mutableStateOf("") } - var error by rememberSaveable { mutableStateOf(null) } - var busy by rememberSaveable { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - Column( - modifier = Modifier.fillMaxSize().padding(48.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("Вход на сервер Jellyfin", style = MaterialTheme.typography.headlineMedium) - Field("Адрес сервера", url, KeyboardType.Uri) { url = it } - Field("Пользователь", user, KeyboardType.Text) { user = it } - Field("Пароль", password, KeyboardType.Password, PasswordVisualTransformation()) { password = it } - Button(enabled = !busy, onClick = { - busy = true - error = null - scope.launch { - runCatching { vm.session.login(url, user, password) } - .onSuccess { onLoggedIn() } - .onFailure { error = it.message ?: it.toString() } - busy = false - } - }) { Text(if (busy) "Вход…" else "Войти") } - error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - } -} - -@Composable -fun Field( - label: String, - value: String, - type: KeyboardType, - transformation: VisualTransformation = VisualTransformation.None, - onChange: (String) -> Unit, -) { - val focusManager = LocalFocusManager.current - var focused by remember { mutableStateOf(false) } - Column(Modifier.width(560.dp)) { - Text(label, style = MaterialTheme.typography.labelLarge) - BasicTextField( - value = value, - onValueChange = onChange, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = type, imeAction = ImeAction.Next), - keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), - visualTransformation = transformation, - textStyle = TextStyle(color = Color.White, fontSize = 20.sp), - cursorBrush = SolidColor(Color.White), - modifier = Modifier - .width(560.dp) - .onFocusChanged { focused = it.isFocused } - // A text field swallows D-pad keys; on a remote up/down must still move between fields. - .onPreviewKeyEvent { e -> - if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (e.key) { - Key.DirectionDown -> focusManager.moveFocus(FocusDirection.Down) - Key.DirectionUp -> focusManager.moveFocus(FocusDirection.Up) - else -> false - } - } - .border(if (focused) 3.dp else 1.dp, if (focused) Color.White else Color.Gray) - .padding(12.dp), - ) - } -} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt index ce4aeb4..d603436 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt @@ -5,10 +5,14 @@ import ru.stenfredd.movies.playback.api.StreamSelection import java.util.UUID sealed interface Screen { + /** Login flow; also "сменить профиль" from the menu (then Back returns to the app). */ data object Login : Screen - /** Root screens with the side rail. */ - data class Root(val tab: ru.stenfredd.movies.ui.shell.Tab) : Screen + /** + * Root screens with the side rail. [account] keeps each account's screens (and their view models) apart: + * after a profile switch the stack is rebuilt, and an equal entry would be kept with the old account's state. + */ + data class Root(val tab: ru.stenfredd.movies.ui.shell.Tab, val account: String) : Screen /** Old prototype home (libraries, text search, logout, debug tour) while the real screens are being built. */ data object Prototype : Screen diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Avatar.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Avatar.kt new file mode 100644 index 0000000..549570e --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Avatar.kt @@ -0,0 +1,67 @@ +package ru.stenfredd.movies.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin + +/** + * User avatar from the mockup: a gradient circle with the first letter of the name, + * `linear-gradient(145deg, oklch(62% .13 h), oklch(40% .12 h+40))`; the hue comes from [seed] (the user id), + * so a user keeps the same colour everywhere. + */ +@Composable +fun Avatar(name: String, seed: String, size: Dp, textStyle: TextStyle, modifier: Modifier = Modifier) { + val colors = remember(seed) { avatarColors(seed) } + Box( + modifier + .size(size) + .drawWithCache { + val brush = Brush.linearGradient(colors, start = Offset(this.size.width * 0.2f, 0f), end = Offset(this.size.width * 0.8f, this.size.height)) + onDrawBehind { drawCircle(brush) } + }, + contentAlignment = Alignment.Center, + ) { + BasicText(name.trim().take(1).uppercase(), style = textStyle) + } +} + +fun avatarColors(seed: String): List { + val hue = Math.floorMod(seed.hashCode(), 360).toDouble() + return listOf(oklch(0.62, 0.13, hue), oklch(0.40, 0.12, hue + 40)) +} + +/** CSS `oklch()` → sRGB. */ +private fun oklch(l: Double, c: Double, hueDeg: Double): Color { + val h = hueDeg * PI / 180 + val a = c * cos(h) + val b = c * sin(h) + val l1 = cube(l + 0.3963377774 * a + 0.2158037573 * b) + val m1 = cube(l - 0.1055613458 * a - 0.0638541728 * b) + val s1 = cube(l - 0.0894841775 * a - 1.2914855480 * b) + val r = 4.0767416621 * l1 - 3.3077115913 * m1 + 0.2309699292 * s1 + val g = -1.2684380046 * l1 + 2.6097574011 * m1 - 0.3413193965 * s1 + val bl = -0.0041960863 * l1 - 0.7034186147 * m1 + 1.7076147010 * s1 + return Color(gamma(r), gamma(g), gamma(bl)) +} + +private fun cube(x: Double) = x * x * x + +private fun gamma(x: Double): Float { + val v = x.coerceIn(0.0, 1.0) + return (if (v <= 0.0031308) 12.92 * v else 1.055 * v.pow(1 / 2.4) - 0.055).toFloat() +} + diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Decor.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Decor.kt new file mode 100644 index 0000000..5dc6b55 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Decor.kt @@ -0,0 +1,71 @@ +package ru.stenfredd.movies.ui.components + +import android.graphics.DashPathEffect +import android.graphics.Paint +import android.graphics.RectF +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlin.math.PI +import kotlin.math.ceil +import kotlin.math.cos + +/** + * Dashed rounded frame (`border: 2px dashed`), rasterised once per size into a bitmap: a dashed path is + * re-tessellated every frame by the GPU, and the test TV redraws the whole screen on every frame. + */ +fun Modifier.dashedBorder(color: Color, width: Dp, corner: Dp, dash: Dp = 6.dp, gap: Dp = 4.dp): Modifier = drawWithCache { + val w = ceil(size.width).toInt().coerceAtLeast(1) + val h = ceil(size.height).toInt().coerceAtLeast(1) + val bitmap = ImageBitmap(w, h) + val stroke = width.toPx() + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = stroke + this.color = color.toArgb() + pathEffect = DashPathEffect(floatArrayOf(dash.toPx(), gap.toPx()), 0f) + } + val r = corner.toPx() + android.graphics.Canvas(bitmap.asAndroidBitmap()).drawRoundRect(RectF(stroke / 2, stroke / 2, w - stroke / 2, h - stroke / 2), r, r, paint) + onDrawBehind { drawImage(bitmap) } +} + +/** + * Status dot; [pulsing] fades it in and out while something is in progress. The fade is stepped at ~8 frames + * a second: the test TV redraws the whole screen for any change (~17 ms of GPU on the login screen), so a smooth + * 60 fps pulse would keep its GPU busy for nothing. + */ +@Composable +fun StatusDot(color: Color, pulsing: Boolean, modifier: Modifier = Modifier, size: Dp = 6.dp) { + val alpha = remember { mutableFloatStateOf(1f) } + if (pulsing) { + LaunchedEffect(Unit) { + try { + var t = 0 + while (true) { + alpha.floatValue = 0.625f + 0.375f * cos(t * PI / 6).toFloat() + t++ + delay(PULSE_STEP_MS) + } + } finally { + alpha.floatValue = 1f + } + } + } + Spacer(modifier.size(size).drawBehind { drawCircle(color, alpha = alpha.floatValue) }) +} + +private const val PULSE_STEP_MS = 120L diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/TextInput.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/TextInput.kt new file mode 100644 index 0000000..ef01a14 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/TextInput.kt @@ -0,0 +1,218 @@ +package ru.stenfredd.movies.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import ru.stenfredd.movies.ui.theme.AppColors + +/** + * Remote-friendly text field state. D-pad focus lands on the field's frame only; OK (or [edit]) puts the cursor in + * and opens the on-screen keyboard. The keyboard's action button, Back, or Up/Down leave editing, so the remote can + * always move on (a focused text field otherwise swallows the D-pad, and a keyboard popping up on mere focus is + * annoying when passing through a form). + */ +@Stable +class TextInputState { + internal var editing by mutableStateOf(false) + internal val frame = FocusRequester() + internal val field = FocusRequester() + + /** Focuses the field and starts typing. */ + fun edit() { + editing = true + } + + /** Focuses the field's frame without opening the keyboard. */ + fun focus() { + runCatching { frame.requestFocus() } + } +} + +@Composable +fun rememberTextInputState() = remember { TextInputState() } + +/** + * How far a screen must move up so that the field being edited stays above the on-screen keyboard. + * + * Gboard for TV floats over the bottom ~45 % of the screen and reports no insets (its content insets start at the + * screen bottom), so the system neither resizes nor pans the window. The keyboard top is taken from the IME insets + * when a keyboard reports them, else assumed at [FLOATING_KEYBOARD_TOP] of the screen height. + * The screen applies [offset] as a layer translation ([Modifier.liftForKeyboard]); fields report themselves. + */ +@Stable +class KeyboardLift { + var offset by mutableFloatStateOf(0f) + internal set +} + +val LocalKeyboardLift = staticCompositionLocalOf { null } + +fun Modifier.liftForKeyboard(lift: KeyboardLift) = graphicsLayer { translationY = -lift.offset } + +private const val FLOATING_KEYBOARD_TOP = 0.54f + +@Composable +fun TextInput( + value: String, + onValueChange: (String) -> Unit, + state: TextInputState, + modifier: Modifier = Modifier, + placeholder: String = "", + textStyle: TextStyle, + password: Boolean = false, + keyboardType: KeyboardType = KeyboardType.Text, + imeAction: ImeAction = ImeAction.Done, + /** The keyboard's action button: next field, submit … */ + onAction: () -> Unit = {}, +) { + val keyboard = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val lift = LocalKeyboardLift.current + val density = LocalDensity.current + val imeBottom = WindowInsets.ime.getBottom(density) + var focused by remember { mutableStateOf(false) } + var swallowBackUp by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(8.dp) + + fun stopEditing() { + keyboard?.hide() + state.editing = false + state.focus() + } + + var coordinates by remember { mutableStateOf(null) } + if (lift != null) DisposableEffect(state.editing, imeBottom) { + val editing = state.editing + val c = coordinates + if (editing && c != null && c.isAttached) { + val screenHeight = c.findRootCoordinates().size.height + val keyboardTop = if (imeBottom > 0) screenHeight - imeBottom else (screenHeight * FLOATING_KEYBOARD_TOP).toInt() + // Position without the current lift, so that applying it doesn't feed back. + val bottom = c.positionInRoot().y + lift.offset + c.size.height + lift.offset = (bottom + with(density) { 12.dp.toPx() } - keyboardTop).coerceAtLeast(0f) + } + onDispose { if (editing) lift.offset = 0f } + } + + LaunchedEffect(state.editing) { + if (state.editing) { + runCatching { state.field.requestFocus() } + keyboard?.show() + } + } + + Box( + modifier + .onGloballyPositioned { coordinates = it } + .focusRequester(state.frame) + .focusCard(InputFocus) + .height(42.dp) + .background(Color(0xFF0D0F16), shape) + .border(0.5.dp, if (focused) AppColors.Accent else Color(0xFF2C3044), shape) + .onFocusChanged { focused = it.hasFocus } + .clickable(interactionSource = null, indication = null) { state.edit() } + .padding(horizontal = 12.dp), + contentAlignment = Alignment.CenterStart, + ) { + if (value.isEmpty() && placeholder.isNotEmpty()) BasicText(placeholder, style = textStyle.copy(color = AppColors.TextFaint)) + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = textStyle, + cursorBrush = SolidColor(AppColors.Accent), + visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions( + keyboardType = if (password) KeyboardType.Password else keyboardType, + imeAction = imeAction, + autoCorrectEnabled = false, + ), + keyboardActions = KeyboardActions(onAny = { + stopEditing() + onAction() + }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(state.field) + .focusProperties { canFocus = state.editing } + .onFocusChanged { if (!it.isFocused && state.editing) state.editing = false } + .onPreviewKeyEvent { e -> + when { + e.key == Key.Back && e.type == KeyEventType.KeyUp && swallowBackUp -> { + swallowBackUp = false + true + } + e.type != KeyEventType.KeyDown -> false + e.key == Key.Back -> { + swallowBackUp = true + stopEditing() + true + } + e.key == Key.DirectionUp || e.key == Key.DirectionDown -> { + stopEditing() + focusManager.moveFocus(if (e.key == Key.DirectionUp) FocusDirection.Up else FocusDirection.Down) + true + } + else -> false + } + }, + ) + } +} + +private val InputFocus = FocusStyle( + scale = 1f, + corner = 8.dp, + ring = 1.5.dp, + ringColor = Color(0x59A594FF), + glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x597C5CFF))), +) diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/focus/ScreenFocus.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/focus/ScreenFocus.kt index 2aa5578..099a015 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/focus/ScreenFocus.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/focus/ScreenFocus.kt @@ -27,6 +27,11 @@ class ScreenFocus(initialKey: String?) { var lastKey: String? by mutableStateOf(initialKey) internal set internal val requester = FocusRequester() + + /** Focuses the element with [lastKey] (after it has been composed with that key). */ + fun requestFocus() { + runCatching { requester.requestFocus() } + } } private val ScreenFocusSaver = Saver( diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/login/LoginScreen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/login/LoginScreen.kt new file mode 100644 index 0000000..3c147cc --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/login/LoginScreen.kt @@ -0,0 +1,517 @@ +package ru.stenfredd.movies.ui.login + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay +import ru.stenfredd.movies.ui.components.AppIcon +import ru.stenfredd.movies.ui.components.Avatar +import ru.stenfredd.movies.ui.components.FocusStyle +import ru.stenfredd.movies.ui.components.Icon +import ru.stenfredd.movies.ui.components.KeyboardLift +import ru.stenfredd.movies.ui.components.LocalKeyboardLift +import ru.stenfredd.movies.ui.components.liftForKeyboard +import ru.stenfredd.movies.ui.components.StatusDot +import ru.stenfredd.movies.ui.components.TextInput +import ru.stenfredd.movies.ui.components.dashedBorder +import ru.stenfredd.movies.ui.components.focusCard +import ru.stenfredd.movies.ui.components.rememberTextInputState +import ru.stenfredd.movies.ui.focus.Restore +import ru.stenfredd.movies.ui.focus.ScreenFocus +import ru.stenfredd.movies.ui.focus.remembered +import ru.stenfredd.movies.ui.theme.AppColors +import ru.stenfredd.movies.ui.theme.AppType + +/** Login flow: server → profile → login, see [LoginViewModel]. Sizes are the mockup's px / 2. */ +@Composable +fun LoginScreen(onDone: () -> Unit, vm: LoginViewModel = hiltViewModel()) { + val state by vm.state.collectAsStateWithLifecycle() + LaunchedEffect(state.done) { if (state.done) onDone() } + // Back walks the steps; on the first one it leaves the screen (closes the app, or returns to it from the menu). + BackHandler(enabled = state.step != LoginStep.Server) { vm.back() } + + val lift = remember { KeyboardLift() } + Box(Modifier.fillMaxSize().liftForKeyboard(lift)) { + CompositionLocalProvider(LocalKeyboardLift provides lift) { + Logo(Modifier.padding(start = 60.dp, top = 32.dp)) + StepChain(state.step, Modifier.align(Alignment.TopEnd).padding(end = 60.dp, top = 35.dp)) + val focus = vm.focus.getValue(state.step) + key(state.step) { + when (state.step) { + LoginStep.Server -> ServerStep(state, vm, focus) + LoginStep.Profile -> ProfileStep(state, vm, focus) + LoginStep.Login -> LoginStepContent(state, vm, focus) + } + focus.Restore(ready = true) + } + BasicText( + hint(state), + Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp), + style = AppType.MonoCount.copy(fontSize = 10.sp), + ) + } + } +} + +private fun hint(state: LoginState) = when (state.step) { + LoginStep.Server -> "OK — подключиться · серверы находятся автоматически" + LoginStep.Profile -> "Назад — сменить сервер" + LoginStep.Login -> "Вход с телефона подтвердится автоматически · Назад — вернуться" +} + +// ---- Header ---- + +/** Neutral placeholder logo until the app has a name (same as the side rail's). */ +@Composable +private fun Logo(modifier: Modifier = Modifier) { + Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Spacer( + Modifier.size(9.dp).drawBehind { + drawCircle(AppColors.AccentStrong.copy(alpha = 0.35f), radius = size.minDimension) + drawCircle(AppColors.AccentStrong) + }, + ) + BasicText("movies", style = AppType.Display.copy(fontSize = 18.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.em)) + } +} + +@Composable +private fun StepChain(step: LoginStep, modifier: Modifier = Modifier) { + val current = step.ordinal + Row(modifier, verticalAlignment = Alignment.CenterVertically) { + listOf("Сервер", "Профиль", "Вход").forEachIndexed { i, label -> + if (i > 0) Spacer(Modifier.width(20.dp).height(1.dp).background(if (i <= current) AppColors.Accent else Color(0xFF262A3A))) + Row( + Modifier + .background(AppColors.Surface, RoundedCornerShape(5.dp)) + .border(0.5.dp, if (i == current) AppColors.SelectedBorder else Color(0xFF262A3A), RoundedCornerShape(5.dp)) + .padding(horizontal = 8.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + val dot = when { + i < current -> AppColors.Green + i == current -> AppColors.Accent + else -> Color(0xFF33384D) + } + Spacer(Modifier.size(5.dp).background(dot, CircleShape)) + BasicText( + label, + style = AppType.MonoMeta.copy(fontSize = 9.sp, letterSpacing = 0.08.em, color = if (i <= current) AppColors.Text else Color(0xFF6F7592)), + ) + } + } + } +} + +@Composable +private fun Title(text: String, subtitle: @Composable () -> Unit = {}, top: Int = 110) { + Column(Modifier.fillMaxWidth().padding(top = top.dp), horizontalAlignment = Alignment.CenterHorizontally) { + BasicText(text, style = AppType.Display.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold)) + Spacer(Modifier.height(10.dp)) + subtitle() + } +} + +// ---- Step 1: server ---- + +@Composable +private fun ServerStep(state: LoginState, vm: LoginViewModel, focus: ScreenFocus) { + var address by rememberSaveable { mutableStateOf("") } + val input = rememberTextInputState() + LaunchedEffect(state.manualOpen) { + if (state.manualOpen) { + withFrameNanos { } + input.edit() + } + } + // With nothing found yet focus starts on "Ввести адрес"; the first server found (~1.5 s) takes it over, + // unless the user is already typing an address. + val firstServer = state.servers.firstOrNull()?.key + var waitingForServer by remember { mutableStateOf(firstServer == null) } + LaunchedEffect(firstServer) { + if (firstServer == null || !waitingForServer) return@LaunchedEffect + waitingForServer = false + if (focus.lastKey == MANUAL_KEY && !state.manualOpen) { + focus.lastKey = firstServer + withFrameNanos { } + focus.requestFocus() + } + } + + Title("Выберите сервер", subtitle = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) { + StatusDot(AppColors.Green, pulsing = state.searching) + val text = when { + state.searching || !state.searched -> "Ищем Jellyfin в локальной сети" + state.servers.isEmpty() -> "В локальной сети серверов нет — введите адрес" + else -> "Серверы Jellyfin" + } + BasicText(text, style = AppType.BodyLarge.copy(color = AppColors.TextMuted)) + } + }) + Column(Modifier.fillMaxWidth().padding(top = 210.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + state.servers.take(MAX_SERVER_CARDS).forEachIndexed { i, card -> + key(card.key) { + ServerCard(card, { vm.pickServer(card.key) }, Modifier.remembered(focus, card.key, isDefault = i == 0)) + } + } + AddServerCard(vm::openManual, Modifier.remembered(focus, MANUAL_KEY, isDefault = state.servers.isEmpty())) + } + if (state.manualOpen) { + Row(Modifier.padding(top = 30.dp), horizontalArrangement = Arrangement.spacedBy(7.dp)) { + TextInput( + address, + { address = it }, + input, + Modifier.width(360.dp).remembered(focus, "address"), + placeholder = "http://192.168.1.10:8096", + textStyle = AppType.MonoMeta.copy(fontSize = 14.sp, color = AppColors.Text), + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + onAction = { vm.connectManual(address) }, + ) + GradientButton(if (state.manualBusy) "Подключение…" else "Подключиться", { vm.connectManual(address) }, Modifier.remembered(focus, "connect")) + } + state.manualError?.let { ErrorText(it, Modifier.padding(top = 12.dp)) } + } + } +} + +@Composable +private fun ServerCard(card: ServerCardModel, onClick: () -> Unit, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(10.dp) + Column( + modifier + .focusCard(FocusStyle(scale = 1.04f, corner = 10.dp, ring = 1.dp)) + .width(230.dp) + .height(140.dp) + .background(AppColors.Surface, shape) + .border(0.5.dp, Color(0xFF262A3A), shape) + .clickable(interactionSource = null, indication = null, onClick = onClick), + ) { + Row( + Modifier + .fillMaxWidth() + .height(26.dp) + .background(Color(0xFF181B27), RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp)) + .padding(horizontal = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + StatusDot(if (card.where == "LAN") AppColors.Blue else AppColors.Pink, pulsing = false) + BasicText("СЕРВЕР · ${card.where}", Modifier.weight(1f), style = AppType.MonoCaption.copy(letterSpacing = 0.1.em, color = AppColors.TextMuted)) + when { + card.busy -> BasicText("подключение…", style = AppType.MonoCaption.copy(color = AppColors.Accent)) + card.ping != null -> BasicText(card.ping, style = AppType.MonoCaption.copy(color = AppColors.Green)) + } + } + Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637))) + Column(Modifier.padding(horizontal = 12.dp, vertical = 13.dp).fillMaxSize()) { + BasicText(card.name, style = AppType.Display.copy(fontSize = 17.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.em), maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.height(5.dp)) + BasicText(card.address, style = AppType.MonoMeta.copy(fontSize = 11.sp, color = AppColors.TextMuted), maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.weight(1f)) + card.version?.let { BasicText("Jellyfin $it", style = AppType.MonoCaption.copy(fontSize = 9.5.sp, color = Color(0xFF6F7592))) } + } + } +} + +@Composable +private fun AddServerCard(onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by rememberSaveable { mutableStateOf(false) } + Column( + modifier + .focusCard(FocusStyle(scale = 1.04f, corner = 10.dp, ring = 1.dp)) + .width(150.dp) + .height(140.dp) + .onFocusChanged { focused = it.isFocused } + .then(if (focused) Modifier.background(Color(0xFF171A2A), RoundedCornerShape(10.dp)) else Modifier.dashedBorder(Color(0xFF3A3F57), 1.dp, 10.dp)) + .clickable(interactionSource = null, indication = null, onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(9.dp, Alignment.CenterVertically), + ) { + Icon(AppIcon.Plus, AppColors.TextSecondary, Modifier.size(24.dp)) + BasicText("Ввести адрес", style = AppType.BodyLarge.copy(fontWeight = FontWeight.SemiBold, color = AppColors.TextSecondary)) + } +} + +// ---- Step 2: profile ---- + +@Composable +private fun ProfileStep(state: LoginState, vm: LoginViewModel, focus: ScreenFocus) { + Title("Кто смотрит?", subtitle = { + BasicText(state.serverLine, style = AppType.MonoMeta.copy(fontSize = 11.sp, color = AppColors.TextDim)) + }) + Column(Modifier.fillMaxWidth().padding(top = 210.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) { + state.profiles.forEachIndexed { i, p -> + key(p.key) { ProfileTile(p, { vm.pickProfile(p.key) }, Modifier.remembered(focus, p.key, isDefault = i == 0)) } + } + } + state.profileError?.let { ErrorText(it, Modifier.padding(top = 16.dp)) } + } +} + +@Composable +private fun ProfileTile(profile: ProfileModel, onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by rememberSaveable { mutableStateOf(false) } + Column( + modifier + .focusCard(FocusStyle(scale = 1.06f, corner = 12.dp, ring = 1.dp)) + .width(120.dp) + .onFocusChanged { focused = it.isFocused } + .then(if (focused) Modifier.background(Color(0xFF171A2A), RoundedCornerShape(12.dp)) else Modifier) + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (profile.key == LoginViewModel.OTHER_KEY) { + Box(Modifier.size(90.dp).dashedBorder(Color(0xFF4A4F68), 1.dp, 45.dp), contentAlignment = Alignment.Center) { + Icon(AppIcon.Plus, AppColors.TextMuted, Modifier.size(30.dp), strokeWidth = 1.8f) + } + } else { + Avatar(profile.name, profile.avatarSeed, 90.dp, AppType.Display.copy(fontSize = 38.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.em)) + } + Spacer(Modifier.height(9.dp)) + BasicText(profile.name, style = AppType.Display.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.em), maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.height(5.dp)) + BasicText(profile.note, style = AppType.MonoCaption.copy(fontSize = 9.5.sp), maxLines = 1) + } +} + +// ---- Step 3: login ---- + +@Composable +private fun LoginStepContent(state: LoginState, vm: LoginViewModel, focus: ScreenFocus) { + var user by rememberSaveable(state.prefillUser) { mutableStateOf(state.prefillUser) } + var password by rememberSaveable { mutableStateOf("") } + val userInput = rememberTextInputState() + val passwordInput = rememberTextInputState() + val submit = { vm.loginWithPassword(user, password) } + + Title("Вход в аккаунт", top = 85) + Row(Modifier.padding(start = 60.dp, top = 150.dp), verticalAlignment = Alignment.CenterVertically) { + // As in the mockup, Right from the QR panel goes to the first field, not to the nearest one. + QuickConnectPanel(state.quickConnect, Modifier.remembered(focus, "qc", isDefault = true).focusProperties { right = userInput.frame }) + BasicText("или", Modifier.width(60.dp), style = AppType.MonoMeta.copy(fontSize = 11.sp, color = Color(0xFF6F7592), textAlign = TextAlign.Center)) + Panel("ПАРОЛЬ", Color(0xFFB39DDB), focused = false) { + Column(Modifier.padding(20.dp)) { + FieldLabel("Имя пользователя") + TextInput( + user, + { user = it }, + userInput, + Modifier.fillMaxWidth().remembered(focus, "user"), + placeholder = "например, alexey", + textStyle = AppType.BodyLarge.copy(fontSize = 14.sp, color = AppColors.Text, fontWeight = FontWeight.Medium), + imeAction = ImeAction.Next, + onAction = { passwordInput.edit() }, + ) + FieldLabel("Пароль", Modifier.padding(top = 12.dp)) + TextInput( + password, + { password = it }, + passwordInput, + Modifier.fillMaxWidth().remembered(focus, LoginViewModel.PASSWORD_KEY), + placeholder = "••••••••", + textStyle = AppType.BodyLarge.copy(fontSize = 14.sp, color = AppColors.Text, fontWeight = FontWeight.Medium), + password = true, + imeAction = ImeAction.Done, + onAction = submit, + ) + GradientButton( + if (state.loginBusy) "Вход…" else "Войти", + submit, + Modifier.padding(top = 18.dp).fillMaxWidth().remembered(focus, "login"), + big = true, + ) + state.loginError?.let { ErrorText(it, Modifier.padding(top = 10.dp)) } + } + } + } +} + +@Composable +private fun QuickConnectPanel(qc: QuickConnectState, modifier: Modifier = Modifier) { + var focused by rememberSaveable { mutableStateOf(false) } + // The panel itself takes focus (nothing to press in it): it shows where the remote is. + Box( + modifier + .focusCard(FocusStyle(scale = 1f, corner = 12.dp, ring = 1.5.dp, glows = listOf(FocusStyle.Glow(40.dp, 0.dp, Color(0x737C5CFF))))) + .onFocusChanged { focused = it.isFocused } + .clickable(interactionSource = null, indication = null) {}, + ) { + Panel("БЫСТРЫЙ ВХОД С ТЕЛЕФОНА", AppColors.Pink, focused) { + when (qc) { + QuickConnectState.Loading -> PanelMessage("Получаем код…") + QuickConnectState.Disabled -> PanelMessage("Quick Connect выключен на сервере.\nВойдите по паролю.") + is QuickConnectState.Failed -> PanelMessage(qc.message) + is QuickConnectState.Code -> QuickConnectCode(qc) + } + } + } +} + +@Composable +private fun QuickConnectCode(qc: QuickConnectState.Code) { + Box(Modifier.fillMaxSize()) { + Row(Modifier.padding(20.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) { + Box(Modifier.size(160.dp).background(Color(0xFFF3F1FF), RoundedCornerShape(10.dp)).padding(11.dp)) { + Image(BitmapPainter(qc.qr, filterQuality = FilterQuality.None), contentDescription = "QR-код для входа", Modifier.fillMaxSize()) + } + Column(verticalArrangement = Arrangement.spacedBy(11.dp)) { + Instruction(1, "Наведите камеру телефона на QR-код") + Instruction(2, "Или откройте Jellyfin → Quick Connect и введите код") + BasicText( + qc.code, + Modifier.padding(top = 3.dp), + style = AppType.MonoMeta.copy(fontSize = 30.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.14.em, color = Color.White), + maxLines = 1, + softWrap = false, + ) + } + } + Row( + Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(horizontal = 20.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + StatusDot(AppColors.Green, pulsing = true) + BasicText("Ждём подтверждения", Modifier.weight(1f), style = AppType.Body.copy(fontSize = 11.sp, color = AppColors.TextMuted)) + Countdown(qc.expiresAt) + } + } +} + +/** "код действует 9:58", recomposes once a second and only itself. */ +@Composable +private fun Countdown(expiresAt: Long) { + var left by rememberSaveable(expiresAt) { mutableStateOf(0L) } + LaunchedEffect(expiresAt) { + while (true) { + left = ((expiresAt - android.os.SystemClock.elapsedRealtime()) / 1000).coerceAtLeast(0) + delay(1000) + } + } + BasicText("код действует %d:%02d".format(left / 60, left % 60), style = AppType.MonoCaption.copy(fontSize = 10.sp, color = Color(0xFF6F7592))) +} + +@Composable +private fun Instruction(n: Int, text: String) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + Modifier.size(20.dp).background(AppColors.Selected, CircleShape).border(0.5.dp, AppColors.SelectedBorder, CircleShape), + contentAlignment = Alignment.Center, + ) { BasicText("$n", style = AppType.MonoMeta.copy(fontSize = 10.sp, color = AppColors.Text)) } + BasicText(text, style = AppType.BodyLarge.copy(fontSize = 12.sp, lineHeight = 17.sp, color = AppColors.TextSecondary)) + } +} + +@Composable +private fun Panel(title: String, dot: Color, focused: Boolean, content: @Composable () -> Unit) { + val shape = RoundedCornerShape(12.dp) + Column( + Modifier + .width(390.dp) + .height(320.dp) + .background(AppColors.Surface, shape) + .border(0.5.dp, if (focused) AppColors.SelectedBorder else Color(0xFF262A3A), shape), + ) { + Row( + Modifier + .fillMaxWidth() + .height(28.dp) + .background(Color(0xFF181B27), RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .padding(horizontal = 13.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + StatusDot(dot, pulsing = false) + BasicText(title, style = AppType.MonoMeta.copy(fontSize = 9.5.sp, letterSpacing = 0.1.em)) + } + Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637))) + content() + } +} + +@Composable +private fun PanelMessage(text: String) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + BasicText(text, style = AppType.BodyLarge.copy(color = AppColors.TextMuted, textAlign = TextAlign.Center, lineHeight = 19.sp)) + } +} + +@Composable +private fun FieldLabel(text: String, modifier: Modifier = Modifier) { + BasicText(text, modifier.padding(bottom = 7.dp), style = AppType.Body.copy(fontSize = 11.sp, color = AppColors.TextDim)) +} + +@Composable +private fun ErrorText(text: String, modifier: Modifier = Modifier) { + BasicText(text, modifier, style = AppType.Body.copy(fontSize = 12.sp, color = AppColors.Red)) +} + +/** Gradient action button of the login forms (rounded rectangle, unlike the pill [PrimaryButton]). */ +@Composable +private fun GradientButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier, big: Boolean = false) { + Box( + modifier + .focusCard(FocusStyle(scale = 1.02f, corner = 8.dp, ring = 1.5.dp, ringColor = Color(0xFFD9D2FF), glows = FocusStyle.ButtonGlows)) + .height(42.dp) + .background(AppColors.PrimaryGradient, RoundedCornerShape(8.dp)) + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(horizontal = 17.dp), + contentAlignment = Alignment.Center, + ) { + BasicText(label, style = AppType.Button.copy(fontSize = if (big) 14.sp else 13.sp, color = Color.White)) + } +} + +private const val MANUAL_KEY = "manual" +private const val MAX_SERVER_CARDS = 3 diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/login/LoginViewModel.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..28515dd --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/login/LoginViewModel.kt @@ -0,0 +1,433 @@ +package ru.stenfredd.movies.ui.login + +import android.os.SystemClock +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import ru.stenfredd.movies.core.jellyfin.Account +import ru.stenfredd.movies.core.jellyfin.JellyfinSession +import ru.stenfredd.movies.core.jellyfin.PublicUser +import ru.stenfredd.movies.core.jellyfin.QuickConnectExpiredException +import ru.stenfredd.movies.core.jellyfin.Server +import ru.stenfredd.movies.core.jellyfin.ServerLogin +import ru.stenfredd.movies.core.jellyfin.ServerNotFoundException +import ru.stenfredd.movies.ui.focus.ScreenFocus +import timber.log.Timber +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +enum class LoginStep { Server, Profile, Login } + +@Immutable +data class ServerCardModel( + val key: String, + val name: String, + val address: String, + /** "LAN" when found on the local network, "ИНТЕРНЕТ" for a saved or typed address. */ + val where: String, + val ping: String?, + val version: String?, + val busy: Boolean = false, +) + +@Immutable +data class ProfileModel(val key: String, val name: String, val note: String, val avatarSeed: String) + +@Immutable +sealed interface QuickConnectState { + data object Loading : QuickConnectState + data object Disabled : QuickConnectState + data class Failed(val message: String) : QuickConnectState + data class Code(val code: String, val qr: ImageBitmap, val expiresAt: Long) : QuickConnectState +} + +@Immutable +data class LoginState( + val step: LoginStep, + val servers: List = emptyList(), + val searching: Boolean = false, + /** A search has finished at least once. */ + val searched: Boolean = false, + val manualOpen: Boolean = false, + val manualBusy: Boolean = false, + val manualError: String? = null, + val serverLine: String = "", + val profiles: List = emptyList(), + val profileError: String? = null, + /** Name to put into the login form when a listed user needs a password. */ + val prefillUser: String = "", + val loginBusy: Boolean = false, + val loginError: String? = null, + val quickConnect: QuickConnectState = QuickConnectState.Loading, + /** Logged in: the screen leaves to the app. */ + val done: Boolean = false, +) + +/** + * Login flow from the mockup: server (found on the LAN, saved, or typed) → profile ("Кто смотрит?": accounts saved on + * this TV and the server's public users) → login (Quick Connect code with QR, or name and password). + * Opened from the app's "сменить профиль" it starts at the profile step of the current server. + */ +@HiltViewModel +class LoginViewModel @Inject constructor( + private val session: JellyfinSession, + private val login: ServerLogin, +) : ViewModel() { + private val history = ArrayDeque() + private val _state: MutableStateFlow + val state: StateFlow + + /** Remembered focus per step, so Back returns to the card that was chosen. */ + val focus: Map = LoginStep.entries.associateWith { ScreenFocus(null) } + + private var discovered: List = emptyList() + private var probed: Map = emptyMap() + private var typed: Server? = null + private var server: Server? = null + private var publicUsers: List = emptyList() + private var stepJob: Job? = null + + init { + val accounts = session.accounts.value + val current = session.session.value?.let { s -> accounts.firstOrNull { it.id == s.accountId } } ?: accounts.firstOrNull() + _state = MutableStateFlow(LoginState(step = LoginStep.Server)) + state = _state.asStateFlow() + history += LoginStep.Server + if (current != null) { + // Straight to "Кто смотрит?" for the server used last; Back goes to the server list. + val saved = current.toServer() + server = saved + history += LoginStep.Profile + _state.update { it.copy(step = LoginStep.Profile, serverLine = serverLine(saved), profiles = profiles(saved)) } + viewModelScope.launch { loadPublicUsers(saved) } + // Accounts moved over from the prototype have no server name yet. + if (saved.name.isEmpty()) viewModelScope.launch { + login.probe(saved.requestUrl)?.let { info -> + if (server == saved) _state.update { it.copy(serverLine = serverLine(saved.copy(name = info.name))) } + } + } + } + enterStep() + } + + /** Back inside the flow; false on the first step (the screen itself closes). */ + fun back(): Boolean { + if (history.size <= 1 || _state.value.loginBusy) return false + history.removeLast() + _state.update { it.copy(step = history.last(), loginError = null, manualError = null) } + enterStep() + return true + } + + // ---- Server step ---- + + fun openManual() { + _state.update { it.copy(manualOpen = true, manualError = null) } + } + + fun connectManual(address: String) { + if (_state.value.manualBusy) return + _state.update { it.copy(manualBusy = true, manualError = null) } + viewModelScope.launch { + try { + val found = login.find(address) + typed = found + publishServers() + pick(found) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Server not found at %s", address) + _state.update { + it.copy(manualError = if (e is ServerNotFoundException) "Сервер не найден. Проверьте адрес." else describe(e)) + } + } finally { + _state.update { it.copy(manualBusy = false) } + } + } + } + + fun pickServer(key: String) { + val target = servers().firstOrNull { cardKey(it) == key } ?: return + if (_state.value.servers.any { it.busy }) return + viewModelScope.launch { pick(target) } + } + + private suspend fun pick(target: Server) { + server = target + markBusy(cardKey(target)) + loadPublicUsers(target) + markBusy(null) + val profiles = profiles(target) + _state.update { it.copy(serverLine = serverLine(target), profiles = profiles, prefillUser = "") } + // Nothing to choose from (users hidden, none saved): straight to the login form. + if (profiles.size == 1) go(LoginStep.Login) else go(LoginStep.Profile) + } + + private fun markBusy(key: String?) { + _state.update { s -> s.copy(servers = s.servers.map { it.copy(busy = it.key == key) }) } + } + + // ---- Profile step ---- + + fun pickProfile(key: String) { + val target = server ?: return + when { + key == OTHER_KEY -> { + _state.update { it.copy(prefillUser = "") } + go(LoginStep.Login) + } + key.startsWith(ACCOUNT_PREFIX) -> viewModelScope.launch { + runCatching { session.switchTo(key.removePrefix(ACCOUNT_PREFIX)) } + .onSuccess { _state.update { it.copy(done = true) } } + .onFailure { e -> _state.update { it.copy(profileError = describe(e)) } } + } + key.startsWith(USER_PREFIX) -> { + val user = publicUsers.firstOrNull { it.id.toString() == key.removePrefix(USER_PREFIX) } ?: return + _state.update { it.copy(prefillUser = user.name) } + focus.getValue(LoginStep.Login).lastKey = if (user.hasPassword) PASSWORD_KEY else null + if (user.hasPassword) { + go(LoginStep.Login) + } else { + viewModelScope.launch { + runCatching { login.login(target, user.name, "") } + .onSuccess { _state.update { it.copy(done = true) } } + .onFailure { go(LoginStep.Login) } + } + } + } + } + } + + // ---- Login step ---- + + fun loginWithPassword(userName: String, password: String) { + val target = server ?: return + if (_state.value.loginBusy) return + if (userName.isBlank()) { + _state.update { it.copy(loginError = "Введите имя пользователя") } + return + } + _state.update { it.copy(loginBusy = true, loginError = null) } + viewModelScope.launch { + try { + login.login(target, userName, password) + _state.update { it.copy(done = true) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Login failed") + _state.update { it.copy(loginError = describe(e)) } + } finally { + _state.update { it.copy(loginBusy = false) } + } + } + } + + private suspend fun runQuickConnect(target: Server) { + while (true) { + _state.update { it.copy(quickConnect = QuickConnectState.Loading) } + val code = try { + login.startQuickConnect(target) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Quick Connect not started") + _state.update { it.copy(quickConnect = QuickConnectState.Failed(describe(e))) } + delay(RETRY_MS) + continue + } + if (code == null) { + _state.update { it.copy(quickConnect = QuickConnectState.Disabled) } + return + } + val qr = withContext(Dispatchers.Default) { qrBitmap(quickConnectUrl(target, code.code)) } + val expiresAt = SystemClock.elapsedRealtime() + QUICK_CONNECT_LIFETIME_MS + _state.update { it.copy(quickConnect = QuickConnectState.Code(formatCode(code.code), qr, expiresAt)) } + // Poll until approved; the server drops the request after its lifetime, then a new code is shown. + while (true) { + delay(POLL_MS) + val approved = try { + login.isQuickConnectApproved(target, code.secret) + } catch (e: QuickConnectExpiredException) { + break + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.d(e, "Quick Connect state not read") + if (SystemClock.elapsedRealtime() > expiresAt) break else continue + } + if (approved) { + try { + login.loginQuickConnect(target, code.secret) + _state.update { it.copy(done = true) } + return + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Quick Connect login failed") + _state.update { it.copy(loginError = describe(e)) } + break + } + } + } + } + } + + // ---- Steps ---- + + private fun go(step: LoginStep) { + if (history.last() == step) return + history += step + if (step == LoginStep.Login && _state.value.prefillUser.isEmpty()) focus.getValue(LoginStep.Login).lastKey = null + _state.update { it.copy(step = step, loginError = null, profileError = null) } + enterStep() + } + + /** Starts the background work of the current step: LAN search, or the Quick Connect code. */ + private fun enterStep() { + stepJob?.cancel() + stepJob = when (_state.value.step) { + LoginStep.Server -> viewModelScope.launch { searchLoop() } + LoginStep.Profile -> null + LoginStep.Login -> server?.let { target -> viewModelScope.launch { runQuickConnect(target) } } + } + } + + private suspend fun searchLoop() = coroutineScope { + publishServers() + // Saved servers that weren't found on the LAN: their ping and version through the saved address. + launch { + for (s in savedServers().filter { it.id !in probed }) { + login.probe(s.url)?.let { probed = probed + (s.id to it.copy(id = s.id, name = s.name.ifEmpty { it.name })) } + } + publishServers() + } + while (isActive) { + _state.update { it.copy(searching = true) } + discovered = login.discover() + _state.update { it.copy(searching = false, searched = true) } + publishServers() + delay(SEARCH_INTERVAL_MS) + } + } + + private suspend fun loadPublicUsers(target: Server) { + publicUsers = try { + login.publicUsers(target) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.d(e, "Public users not loaded") + emptyList() + } + if (server == target) _state.update { it.copy(profiles = profiles(target)) } + } + + // ---- Models ---- + + /** Discovered servers first, then saved ones that weren't found, then a typed one. */ + private fun servers(): List { + val byId = LinkedHashMap() + discovered.forEach { byId[it.id] = it } + savedServers().forEach { saved -> if (saved.id !in byId) byId[saved.id] = probed[saved.id]?.copy(url = saved.url) ?: saved } + typed?.let { if (it.id !in byId) byId[it.id] = it } + return byId.values.toList() + } + + private fun savedServers(): List = session.accounts.value.distinctBy { it.serverId }.map { it.toServer() } + + private fun publishServers() { + val busy = _state.value.servers.firstOrNull { it.busy }?.key + _state.update { s -> + s.copy(servers = servers().map { srv -> + val key = cardKey(srv) + ServerCardModel( + key = key, + name = srv.name.ifEmpty { "Jellyfin" }, + address = displayAddress(srv.localUrl ?: srv.url), + where = if (srv.localUrl != null && discovered.any { it.id == srv.id }) "LAN" else "ИНТЕРНЕТ", + ping = srv.pingMs?.let { "$it мс" }, + version = srv.version, + busy = key == busy, + ) + }) + } + } + + private fun profiles(target: Server): List { + val currentId = session.session.value?.accountId + val saved = session.accounts.value.filter { it.serverId == target.id } + val savedUsers = saved.map { it.userId }.toSet() + return saved.map { a -> + ProfileModel(ACCOUNT_PREFIX + a.id, a.userName, if (a.id == currentId) "сейчас" else lastUsed(a.lastUsedAt), a.userId.toString()) + } + publicUsers.filter { it.id !in savedUsers }.map { u -> + ProfileModel(USER_PREFIX + u.id, u.name, if (u.hasPassword) "пароль" else "без пароля", u.id.toString()) + } + ProfileModel(OTHER_KEY, "Другой", "QR или пароль", "") + } + + private fun Account.toServer() = Server(serverId, serverName, serverUrl, localUrl) + + private fun cardKey(s: Server) = "srv-" + s.id + + private fun serverLine(s: Server) = listOf(s.name, displayAddress(s.url)).filter { it.isNotEmpty() }.joinToString(" · ") + + private fun displayAddress(url: String) = url.removePrefix("http://").removePrefix("https://") + + private fun lastUsed(at: Long): String { + if (at <= 0) return "" + val zone = ZoneId.systemDefault() + val days = ChronoUnit.DAYS.between(java.time.Instant.ofEpochMilli(at).atZone(zone).toLocalDate(), LocalDate.now(zone)) + return when { + days <= 0 -> "сегодня" + days == 1L -> "вчера" + days in 2..4 -> "$days дня назад" + days < 30 -> "$days дней назад" + else -> "давно" + } + } + + /** The phone opens the server's web app on its Quick Connect page with the code filled in. */ + private fun quickConnectUrl(target: Server, code: String) = "${target.url}/web/#/quickconnect?code=$code" + + private fun formatCode(code: String) = if (code.length == 6) code.substring(0, 3) + " " + code.substring(3) else code + + private fun describe(e: Throwable): String { + val status = generateSequence(e) { it.cause }.filterIsInstance().firstOrNull()?.status + return when (status) { + 401 -> "Неверное имя пользователя или пароль" + 403 -> "Вход запрещён для этого пользователя" + null -> "Нет связи с сервером" + else -> "Сервер ответил ошибкой $status" + } + } + + companion object { + const val OTHER_KEY = "other" + const val PASSWORD_KEY = "password" + private const val ACCOUNT_PREFIX = "acc-" + private const val USER_PREFIX = "usr-" + private const val SEARCH_INTERVAL_MS = 8_000L + private const val POLL_MS = 3_000L + private const val RETRY_MS = 5_000L + /** Jellyfin forgets a Quick Connect request after 10 minutes. */ + private const val QUICK_CONNECT_LIFETIME_MS = 10 * 60_000L + } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/login/QrCode.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/login/QrCode.kt new file mode 100644 index 0000000..6ee1f7f --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/login/QrCode.kt @@ -0,0 +1,25 @@ +package ru.stenfredd.movies.ui.login + +import android.graphics.Bitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel + +/** + * QR code of [text], one pixel per module, no quiet zone (the white card around it is the margin). + * Drawn scaled up without filtering, so the edges stay sharp. + */ +fun qrBitmap(text: String): ImageBitmap { + val hints = mapOf(EncodeHintType.MARGIN to 0, EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M) + val matrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, 0, 0, hints) + val pixels = IntArray(matrix.width * matrix.height) { i -> + if (matrix.get(i % matrix.width, i / matrix.width)) QR_DARK else QR_LIGHT + } + return Bitmap.createBitmap(pixels, matrix.width, matrix.height, Bitmap.Config.ARGB_8888).asImageBitmap() +} + +private const val QR_DARK = 0xFF0B0C12.toInt() +private const val QR_LIGHT = 0xFFF3F1FF.toInt() diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt index 68502c1..3c4154f 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable @@ -49,6 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import kotlinx.coroutines.delay import ru.stenfredd.movies.ui.components.AppIcon +import ru.stenfredd.movies.ui.components.Avatar import ru.stenfredd.movies.ui.components.Icon import ru.stenfredd.movies.ui.theme.AppColors import ru.stenfredd.movies.ui.theme.AppDimens @@ -73,6 +73,8 @@ enum class Tab(val label: String, val icon: AppIcon) { fun Shell( current: Tab, userName: String, + /** Avatar colour seed (user id), see [Avatar]. */ + userSeed: String, onTab: (Tab) -> Unit, onProfile: () -> Unit, content: @Composable () -> Unit, @@ -86,6 +88,7 @@ fun Shell( Rail( current = current, userName = userName, + userSeed = userSeed, entry = railEntry, onFocusChange = { railFocused = it }, onTab = onTab, @@ -104,6 +107,7 @@ fun Shell( private fun Rail( current: Tab, userName: String, + userSeed: String, entry: FocusRequester, onFocusChange: (Boolean) -> Unit, onTab: (Tab) -> Unit, @@ -152,7 +156,7 @@ private fun Rail( ) } Spacer(Modifier.weight(1f)) - ProfileItem(userName, progress, onProfile) + ProfileItem(userName, userSeed, progress, onProfile) } } @@ -232,7 +236,7 @@ private fun RailItem(tab: Tab, selected: Boolean, progress: State, onClic } @Composable -private fun ProfileItem(userName: String, progress: State, onClick: () -> Unit) { +private fun ProfileItem(userName: String, userSeed: String, progress: State, onClick: () -> Unit) { var focused by remember { mutableStateOf(false) } Row( Modifier @@ -245,9 +249,7 @@ private fun ProfileItem(userName: String, progress: State, onClick: () -> verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp), ) { - Box(Modifier.size(24.dp).background(AppColors.SelectedBorder, CircleShape), contentAlignment = Alignment.Center) { - BasicText(userName.take(1).uppercase(), style = AppType.CardTitle.copy(fontSize = AppType.PosterTitle.fontSize)) - } + Avatar(userName, userSeed, 24.dp, AppType.CardTitle.copy(fontSize = AppType.PosterTitle.fontSize)) Column(Modifier.graphicsLayer { alpha = progress.value }) { BasicText(userName, style = AppType.CardTitle.copy(fontSize = AppType.Rail.fontSize), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) BasicText("сменить профиль", style = AppType.MonoCaption.copy(fontSize = AppType.MonoBadge.fontSize), maxLines = 1, softWrap = false) diff --git a/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/JellyfinSession.kt b/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/JellyfinSession.kt index a31272c..94c37af 100644 --- a/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/JellyfinSession.kt +++ b/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/JellyfinSession.kt @@ -19,13 +19,13 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.authenticateUserByName -import org.jellyfin.sdk.api.client.extensions.authenticationApi -import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.api.client.HttpClientOptions import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin +import org.jellyfin.sdk.discovery.DiscoveryService import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo +import org.jellyfin.sdk.model.api.AuthenticationResult import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -128,23 +128,30 @@ class JellyfinSession @Inject constructor( true } - suspend fun login(serverUrl: String, userName: String, password: String) { - val url = serverUrl.trim().trimEnd('/') - mutex.withLock { - routing.set(null, null) - api.update(baseUrl = url, accessToken = null) - val result = call { authenticationApi.authenticateUserByName(userName, password).content } - val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" } - val user = checkNotNull(result.user) { "Сервер не вернул пользователя" } - api.update(accessToken = token) - val serverId = result.serverId ?: user.serverId ?: url - val serverName = runCatching { call { systemApi.getPublicSystemInfo().content.serverName } }.getOrNull().orEmpty() - val account = Account(serverId, serverName, url, user.id, user.name.orEmpty(), token, lastUsedAt = System.currentTimeMillis()) - save(account) - activate(account) - } + /** Saves the account of a successful login (see [ServerLogin]) and makes it current. */ + internal suspend fun signedIn(server: Server, result: AuthenticationResult) = mutex.withLock { + val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" } + val user = checkNotNull(result.user) { "Сервер не вернул пользователя" } + val account = Account( + serverId = result.serverId ?: user.serverId ?: server.id, + serverName = server.name, + serverUrl = server.url, + userId = user.id, + userName = user.name.orEmpty(), + accessToken = token, + localUrl = server.localUrl, + lastUsedAt = System.currentTimeMillis(), + ) + save(account) + activate(account) } + internal val discovery: DiscoveryService get() = jellyfin.discovery + + /** A client for the login screens: no token, this device's identity, its own timeouts. */ + internal fun loginClient(baseUrl: String, options: HttpClientOptions): ApiClient = + jellyfin.createApi(baseUrl = baseUrl, deviceInfo = api.deviceInfo, httpClientOptions = options) + /** Makes a saved account current (profile switch); its token is reused, no password needed. */ suspend fun switchTo(accountId: String) = mutex.withLock { val account = _accounts.value.first { it.id == accountId }.copy(lastUsedAt = System.currentTimeMillis()) @@ -223,7 +230,7 @@ class JellyfinSession @Inject constructor( private fun deviceInfo(id: String) = DeviceInfo(id = id, name = "${Build.MANUFACTURER} ${Build.MODEL}") private companion object { - const val CLIENT_NAME = "Movies (prototype)" + const val CLIENT_NAME = "Movies" const val CLIENT_VERSION = "0.1.0" const val LOCAL_RECHECK_MS = 10 * 60_000L } diff --git a/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/LocalServerDiscovery.kt b/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/LocalServerDiscovery.kt index d1fa1d9..8c3c2d3 100644 --- a/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/LocalServerDiscovery.kt +++ b/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/LocalServerDiscovery.kt @@ -12,7 +12,8 @@ import java.net.SocketTimeoutException import java.net.URL /** - * Finds a direct LAN address of the logged-in server, for streaming. + * Finds Jellyfin servers on the local network: for the login screen, and the direct LAN address of the logged-in + * server for streaming. * * At home the TV reached the server through its public HTTPS name: router hairpin, reverse proxy, and TLS decrypted * by the TV's CPU. That capped Exo at 40–115 Mbit/s and a 66 Mbit/s 4K remux rebuffered every @@ -28,6 +29,9 @@ internal object LocalServerDiscovery { private const val TIMEOUT_MS = 1500 private const val MESSAGE = "Who is JellyfinServer?" + /** A reply to the discovery broadcast. */ + class Reply(val id: String, val name: String, val lanUrl: String, val advertisedUrl: String?) + /** Server id from `/System/Info/Public` of [baseUrl], or null. */ suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) { runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } } @@ -37,36 +41,43 @@ internal object LocalServerDiscovery { /** `http://:8096` of the server with [serverId] on the local network, or null. */ suspend fun find(serverId: String): String? = withContext(Dispatchers.IO) { - val candidates = runCatching { discover(serverId) }.onFailure { Timber.d(it, "Jellyfin discovery failed") }.getOrDefault(emptyList()) - candidates.firstOrNull { url -> serverId(url) == serverId } + val reply = discover(stopAt = serverId).firstOrNull { it.id == serverId } ?: return@withContext null + // The advertised address, if it happens to be a plain LAN URL, is worth a try as well. + listOfNotNull(reply.lanUrl, reply.advertisedUrl?.takeIf { it.startsWith("http://") }).distinct() + .firstOrNull { url -> serverId(url) == serverId } } - private fun discover(serverId: String): List { - val found = mutableListOf() - DatagramSocket().use { socket -> - socket.broadcast = true - socket.soTimeout = TIMEOUT_MS - val message = MESSAGE.toByteArray() - socket.send(DatagramPacket(message, message.size, InetAddress.getByName("255.255.255.255"), DISCOVERY_PORT)) - val buffer = ByteArray(4096) - val deadline = System.currentTimeMillis() + TIMEOUT_MS - while (System.currentTimeMillis() < deadline) { - val packet = DatagramPacket(buffer, buffer.size) - try { - socket.receive(packet) - } catch (_: SocketTimeoutException) { - break + /** Every server that answers within [TIMEOUT_MS], one reply per server id. */ + suspend fun discoverAll(): List = withContext(Dispatchers.IO) { discover(stopAt = null) } + + private fun discover(stopAt: String?): List { + val found = LinkedHashMap() + runCatching { + DatagramSocket().use { socket -> + socket.broadcast = true + socket.soTimeout = TIMEOUT_MS + val message = MESSAGE.toByteArray() + socket.send(DatagramPacket(message, message.size, InetAddress.getByName("255.255.255.255"), DISCOVERY_PORT)) + val buffer = ByteArray(4096) + val deadline = System.currentTimeMillis() + TIMEOUT_MS + while (System.currentTimeMillis() < deadline) { + val packet = DatagramPacket(buffer, buffer.size) + try { + socket.receive(packet) + } catch (_: SocketTimeoutException) { + break + } + val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue + val id = reply.optString("Id").takeIf { it.isNotEmpty() } ?: continue + val host = packet.address.hostAddress ?: continue + found.getOrPut(id) { + Reply(id, reply.optString("Name"), "http://$host:$HTTP_PORT", reply.optString("Address").trimEnd('/').takeIf { it.startsWith("http") }) + } + if (id == stopAt) break } - val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue - if (reply.optString("Id") != serverId) continue - val host = packet.address.hostAddress ?: continue - found += "http://$host:$HTTP_PORT" - // The advertised address, if it happens to be a plain LAN URL, is worth a try as well. - reply.optString("Address").takeIf { it.startsWith("http://") }?.let { found += it.trimEnd('/') } - break } - } - return found.distinct() + }.onFailure { Timber.d(it, "Jellyfin discovery failed") } + return found.values.toList() } private fun get(url: String): String { diff --git a/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/ServerLogin.kt b/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/ServerLogin.kt new file mode 100644 index 0000000..c836c04 --- /dev/null +++ b/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/ServerLogin.kt @@ -0,0 +1,162 @@ +package ru.stenfredd.movies.core.jellyfin + +import android.os.SystemClock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.HttpClientOptions +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.authenticateUserByName +import org.jellyfin.sdk.api.client.extensions.authenticationApi +import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore +import org.jellyfin.sdk.model.api.QuickConnectDto +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds + +/** A Jellyfin server to log in to. */ +data class Server( + val id: String, + val name: String, + /** Address the account is saved with: the public name when the server has one that works, else the LAN address. */ + val url: String, + /** Direct LAN address, when the server was found on the local network. */ + val localUrl: String? = null, + val version: String? = null, + /** Round trip of `/System/Info/Public`, ms; null when not measured. */ + val pingMs: Long? = null, +) { + /** Where login requests go: the LAN address at home (no hairpin, no TLS on the TV's CPU). */ + val requestUrl: String get() = localUrl ?: url +} + +/** A user the server shows on its login screen (`/Users/Public`). */ +data class PublicUser(val id: UUID, val name: String, val hasPassword: Boolean) + +/** A Quick Connect request: [code] is typed (or scanned) on a device where the user is logged in. */ +data class QuickConnectCode(val code: String, val secret: String) + +/** The server forgot a Quick Connect request (it expires after a few minutes); start a new one. */ +class QuickConnectExpiredException : Exception("Quick Connect request expired") + +/** + * Everything the login screens need before there is an account: servers on the local network, a server by its + * address, its public users, password and Quick Connect login. A successful login becomes the current account of + * [JellyfinSession]. + */ +@Singleton +class ServerLogin @Inject constructor(private val session: JellyfinSession) { + + /** Servers on the local network with version and ping; empty when there are none (or no network). */ + suspend fun discover(): List = coroutineScope { + LocalServerDiscovery.discoverAll().map { reply -> + async { + val lan = probe(reply.lanUrl) ?: return@async null + if (lan.id != reply.id) return@async null + // The address the server advertises (its public name, if configured) is saved with the account so that + // it also works away from home; the LAN address is used while at home. + val public = reply.advertisedUrl?.takeIf { it != reply.lanUrl && it.startsWith("https://") } + ?.let { url -> probe(url)?.takeIf { it.id == reply.id }?.url } + lan.copy(name = lan.name.ifEmpty { reply.name }, url = public ?: lan.url, localUrl = reply.lanUrl) + } + }.awaitAll().filterNotNull() + } + + /** Version and ping of the server at [url], or null when it doesn't answer as a Jellyfin server. */ + suspend fun probe(url: String): Server? = withContext(Dispatchers.IO) { + runCatching { + val api = client(url, quick = true).systemApi + val info = api.getPublicSystemInfo().content + // The first request also opened the connection (and TLS); the ping is a second one on it. + val started = SystemClock.elapsedRealtime() + api.getPublicSystemInfo() + Server( + id = checkNotNull(info.id), + name = info.serverName.orEmpty(), + url = url, + version = info.version, + pingMs = SystemClock.elapsedRealtime() - started, + ) + }.onFailure { Timber.d("No Jellyfin server at %s: %s", url, it.toString()) }.getOrNull() + } + + /** + * The server at an address typed by the user: `192.168.1.10`, `movies.example.com`, `https://…:8920/jellyfin`. + * Tries the usual schemes and ports (SDK address candidates) and picks the best answer. + * Throws [ServerNotFoundException] when nothing answers. + */ + suspend fun find(input: String): Server = withContext(Dispatchers.IO) { + val text = input.trim().trimEnd('/') + if (text.isEmpty()) throw ServerNotFoundException() + val best = session.discovery.getRecommendedServers(text) + .filter { it.score != RecommendedServerInfoScore.BAD && it.systemInfo.isSuccess } + .minWithOrNull(compareBy({ it.score.ordinal }, { it.responseTime })) + ?: throw ServerNotFoundException() + val info = best.systemInfo.getOrThrow() + Server( + id = info.id ?: best.address, + name = info.serverName.orEmpty(), + url = best.address.trimEnd('/'), + version = info.version, + pingMs = best.responseTime, + ) + } + + /** Users listed on the server's login screen; empty when the server hides them. */ + suspend fun publicUsers(server: Server): List = withContext(Dispatchers.IO) { + client(server.requestUrl).userApi.getPublicUsers().content.map { + PublicUser(it.id, it.name.orEmpty(), it.hasPassword ?: true) + } + } + + /** Logs in and makes the account current. Throws [InvalidStatusException] 401 on a wrong name or password. */ + suspend fun login(server: Server, userName: String, password: String) { + val result = withContext(Dispatchers.IO) { + client(server.requestUrl).authenticationApi.authenticateUserByName(userName.trim(), password).content + } + session.signedIn(server, result) + } + + /** Starts a Quick Connect request, or returns null when Quick Connect is turned off on the server. */ + suspend fun startQuickConnect(server: Server): QuickConnectCode? = withContext(Dispatchers.IO) { + val api = client(server.requestUrl).authenticationApi + if (!api.getQuickConnectEnabled().content) return@withContext null + val result = api.initiateQuickConnect().content + QuickConnectCode(result.code, result.secret) + } + + /** Whether the request was approved; throws [QuickConnectExpiredException] once the server has dropped it. */ + suspend fun isQuickConnectApproved(server: Server, secret: String): Boolean = withContext(Dispatchers.IO) { + try { + client(server.requestUrl).authenticationApi.getQuickConnectState(secret).content.authenticated + } catch (e: InvalidStatusException) { + if (e.status == 404) throw QuickConnectExpiredException() else throw e + } + } + + /** Finishes an approved Quick Connect request: logs in and makes the account current. */ + suspend fun loginQuickConnect(server: Server, secret: String) { + val result = withContext(Dispatchers.IO) { + client(server.requestUrl).authenticationApi.authenticateWithQuickConnect(QuickConnectDto(secret)).content + } + session.signedIn(server, result) + } + + /** A client without a token, with this device's identity (a Quick Connect token is bound to the device id). */ + private fun client(url: String, quick: Boolean = false): ApiClient = + session.loginClient(url, if (quick) PROBE_OPTIONS else LOGIN_OPTIONS) + + private companion object { + val PROBE_OPTIONS = HttpClientOptions(connectTimeout = 2.seconds, requestTimeout = 3.seconds, socketTimeout = 3.seconds) + val LOGIN_OPTIONS = HttpClientOptions(connectTimeout = 5.seconds, requestTimeout = 15.seconds, socketTimeout = 15.seconds) + } +} + +class ServerNotFoundException : Exception("Server not found") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc7856e..6c477b0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ serialization = "1.11.0" benchmark = "1.5.0" uiautomator = "2.4.0" androidx-test-junit = "1.3.0" +zxing = "3.5.3" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } @@ -63,6 +64,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } [plugins]