Login screens

This commit is contained in:
2026-10-14 12:20:48 +03:00
parent c7d29e94ea
commit c56a74a3a0
18 changed files with 1595 additions and 183 deletions

View File

@@ -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"))
}

View File

@@ -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">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View File

@@ -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)

View File

@@ -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<BaseItemDto>()) }
var results by remember { mutableStateOf(emptyList<BaseItemDto>()) }
var query by remember { mutableStateOf("") }
val searchInput = rememberTextInputState()
var error by remember { mutableStateOf<String?>(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("Найти")
}

View File

@@ -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<String?>(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),
)
}
}

View File

@@ -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

View File

@@ -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<Color> {
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()
}

View File

@@ -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

View File

@@ -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<KeyboardLift?> { 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<LayoutCoordinates?>(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))),
)

View File

@@ -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<ScreenFocus, String>(

View File

@@ -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

View File

@@ -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<ServerCardModel> = 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<ProfileModel> = 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<LoginStep>()
private val _state: MutableStateFlow<LoginState>
val state: StateFlow<LoginState>
/** Remembered focus per step, so Back returns to the card that was chosen. */
val focus: Map<LoginStep, ScreenFocus> = LoginStep.entries.associateWith { ScreenFocus(null) }
private var discovered: List<Server> = emptyList()
private var probed: Map<String, Server> = emptyMap()
private var typed: Server? = null
private var server: Server? = null
private var publicUsers: List<PublicUser> = 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<Server> {
val byId = LinkedHashMap<String, Server>()
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<Server> = 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<ProfileModel> {
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<InvalidStatusException>().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
}
}

View File

@@ -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()

View File

@@ -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<Float>, onClic
}
@Composable
private fun ProfileItem(userName: String, progress: State<Float>, onClick: () -> Unit) {
private fun ProfileItem(userName: String, userSeed: String, progress: State<Float>, onClick: () -> Unit) {
var focused by remember { mutableStateOf(false) }
Row(
Modifier
@@ -245,9 +249,7 @@ private fun ProfileItem(userName: String, progress: State<Float>, 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)