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) implementation(libs.hilt.android)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
implementation(libs.timber) implementation(libs.timber)
implementation(libs.zxing.core)
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
baselineProfile(project(":baselineprofile")) baselineProfile(project(":baselineprofile"))
} }

View File

@@ -21,7 +21,8 @@
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|uiMode" android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
android:exported="true" android:exported="true"
android:screenOrientation="landscape"> android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <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.playback.api.StreamSelection
import ru.stenfredd.movies.ui.DetailScreen import ru.stenfredd.movies.ui.DetailScreen
import ru.stenfredd.movies.ui.ItemsScreen import ru.stenfredd.movies.ui.ItemsScreen
import ru.stenfredd.movies.ui.LoginScreen
import ru.stenfredd.movies.ui.PlayerScreen import ru.stenfredd.movies.ui.PlayerScreen
import ru.stenfredd.movies.ui.PrototypeScreen import ru.stenfredd.movies.ui.PrototypeScreen
import ru.stenfredd.movies.ui.Screen import ru.stenfredd.movies.ui.Screen
import ru.stenfredd.movies.ui.TourScreen import ru.stenfredd.movies.ui.TourScreen
import ru.stenfredd.movies.ui.home.HomeScreen 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.Shell
import ru.stenfredd.movies.ui.shell.Tab import ru.stenfredd.movies.ui.shell.Tab
import ru.stenfredd.movies.ui.theme.AppColors import ru.stenfredd.movies.ui.theme.AppColors
@@ -58,7 +58,7 @@ class MainActivity : ComponentActivity() {
var ready by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val loggedIn = session.restore() 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) if (loggedIn) intentTarget()?.let(stack::addAll)
ready = true ready = true
} }
@@ -94,8 +94,8 @@ class MainActivity : ComponentActivity() {
entryProvider = { screen -> entryProvider = { screen ->
NavEntry(screen) { NavEntry(screen) {
when (screen) { when (screen) {
Screen.Login -> Legacy { LoginScreen(onLoggedIn = { resetTo(Screen.Root(Tab.Home)) }) } Screen.Login -> LoginScreen(onDone = { resetTo(root(Tab.Home)) })
is Screen.Root -> Root(screen.tab, onTab = { resetTo(Screen.Root(it)) }, onOpen = ::open, onLogout = { resetTo(Screen.Login) }) 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) }) } Screen.Prototype -> Legacy { PrototypeScreen(onOpen = ::open, onLogout = { resetTo(Screen.Login) }) }
is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) } is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) }
is Screen.Detail -> Legacy { DetailScreen(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 @Composable
private fun Root(tab: Tab, onTab: (Tab) -> Unit, onOpen: (Screen) -> Unit, onLogout: () -> Unit) { private fun Root(tab: Tab, onTab: (Tab) -> Unit, onOpen: (Screen) -> Unit, onLogout: () -> Unit) {
val user by session.session.collectAsStateWithLifecycle() val user by session.session.collectAsStateWithLifecycle()
Shell( Shell(
current = tab, current = tab,
userName = user?.userName.orEmpty(), userName = user?.userName.orEmpty(),
userSeed = user?.userId?.toString().orEmpty(),
onTab = onTab, onTab = onTab,
// Profile switching comes with the new login screens (stage 3); until then the prototype has "Выйти". onProfile = { onOpen(Screen.Login) },
onProfile = { onOpen(Screen.Prototype) },
) { ) {
when (tab) { when (tab) {
Tab.Home -> HomeScreen(onOpen = onOpen) 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.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -40,6 +39,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import ru.stenfredd.movies.core.jellyfin.JellyfinSession 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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -53,6 +56,7 @@ fun PrototypeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseVi
var resume by remember { mutableStateOf(emptyList<BaseItemDto>()) } var resume by remember { mutableStateOf(emptyList<BaseItemDto>()) }
var results by remember { mutableStateOf(emptyList<BaseItemDto>()) } var results by remember { mutableStateOf(emptyList<BaseItemDto>()) }
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val searchInput = rememberTextInputState()
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
@@ -84,7 +88,7 @@ fun PrototypeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseVi
} }
item { item {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { 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()) } }) { Button(onClick = { scope.launch { results = runCatching { vm.library.search(query) }.getOrDefault(emptyList()) } }) {
Text("Найти") 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 import java.util.UUID
sealed interface Screen { sealed interface Screen {
/** Login flow; also "сменить профиль" from the menu (then Back returns to the app). */
data object Login : Screen 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. */ /** Old prototype home (libraries, text search, logout, debug tour) while the real screens are being built. */
data object Prototype : Screen 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) var lastKey: String? by mutableStateOf(initialKey)
internal set internal set
internal val requester = FocusRequester() 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>( 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -49,6 +48,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import ru.stenfredd.movies.ui.components.AppIcon 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.components.Icon
import ru.stenfredd.movies.ui.theme.AppColors import ru.stenfredd.movies.ui.theme.AppColors
import ru.stenfredd.movies.ui.theme.AppDimens import ru.stenfredd.movies.ui.theme.AppDimens
@@ -73,6 +73,8 @@ enum class Tab(val label: String, val icon: AppIcon) {
fun Shell( fun Shell(
current: Tab, current: Tab,
userName: String, userName: String,
/** Avatar colour seed (user id), see [Avatar]. */
userSeed: String,
onTab: (Tab) -> Unit, onTab: (Tab) -> Unit,
onProfile: () -> Unit, onProfile: () -> Unit,
content: @Composable () -> Unit, content: @Composable () -> Unit,
@@ -86,6 +88,7 @@ fun Shell(
Rail( Rail(
current = current, current = current,
userName = userName, userName = userName,
userSeed = userSeed,
entry = railEntry, entry = railEntry,
onFocusChange = { railFocused = it }, onFocusChange = { railFocused = it },
onTab = onTab, onTab = onTab,
@@ -104,6 +107,7 @@ fun Shell(
private fun Rail( private fun Rail(
current: Tab, current: Tab,
userName: String, userName: String,
userSeed: String,
entry: FocusRequester, entry: FocusRequester,
onFocusChange: (Boolean) -> Unit, onFocusChange: (Boolean) -> Unit,
onTab: (Tab) -> Unit, onTab: (Tab) -> Unit,
@@ -152,7 +156,7 @@ private fun Rail(
) )
} }
Spacer(Modifier.weight(1f)) 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 @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) } var focused by remember { mutableStateOf(false) }
Row( Row(
Modifier Modifier
@@ -245,9 +249,7 @@ private fun ProfileItem(userName: String, progress: State<Float>, onClick: () ->
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp), horizontalArrangement = Arrangement.spacedBy(9.dp),
) { ) {
Box(Modifier.size(24.dp).background(AppColors.SelectedBorder, CircleShape), contentAlignment = Alignment.Center) { Avatar(userName, userSeed, 24.dp, AppType.CardTitle.copy(fontSize = AppType.PosterTitle.fontSize))
BasicText(userName.take(1).uppercase(), style = AppType.CardTitle.copy(fontSize = AppType.PosterTitle.fontSize))
}
Column(Modifier.graphicsLayer { alpha = progress.value }) { Column(Modifier.graphicsLayer { alpha = progress.value }) {
BasicText(userName, style = AppType.CardTitle.copy(fontSize = AppType.Rail.fontSize), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) 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) BasicText("сменить профиль", style = AppType.MonoCaption.copy(fontSize = AppType.MonoBadge.fontSize), maxLines = 1, softWrap = false)

View File

@@ -19,13 +19,13 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.client.extensions.authenticationApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.discovery.DiscoveryService
import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.AuthenticationResult
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -128,23 +128,30 @@ class JellyfinSession @Inject constructor(
true true
} }
suspend fun login(serverUrl: String, userName: String, password: String) { /** Saves the account of a successful login (see [ServerLogin]) and makes it current. */
val url = serverUrl.trim().trimEnd('/') internal suspend fun signedIn(server: Server, result: AuthenticationResult) = mutex.withLock {
mutex.withLock { val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" }
routing.set(null, null) val user = checkNotNull(result.user) { "Сервер не вернул пользователя" }
api.update(baseUrl = url, accessToken = null) val account = Account(
val result = call { authenticationApi.authenticateUserByName(userName, password).content } serverId = result.serverId ?: user.serverId ?: server.id,
val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" } serverName = server.name,
val user = checkNotNull(result.user) { "Сервер не вернул пользователя" } serverUrl = server.url,
api.update(accessToken = token) userId = user.id,
val serverId = result.serverId ?: user.serverId ?: url userName = user.name.orEmpty(),
val serverName = runCatching { call { systemApi.getPublicSystemInfo().content.serverName } }.getOrNull().orEmpty() accessToken = token,
val account = Account(serverId, serverName, url, user.id, user.name.orEmpty(), token, lastUsedAt = System.currentTimeMillis()) localUrl = server.localUrl,
save(account) lastUsedAt = System.currentTimeMillis(),
activate(account) )
} 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. */ /** Makes a saved account current (profile switch); its token is reused, no password needed. */
suspend fun switchTo(accountId: String) = mutex.withLock { suspend fun switchTo(accountId: String) = mutex.withLock {
val account = _accounts.value.first { it.id == accountId }.copy(lastUsedAt = System.currentTimeMillis()) 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 fun deviceInfo(id: String) = DeviceInfo(id = id, name = "${Build.MANUFACTURER} ${Build.MODEL}")
private companion object { private companion object {
const val CLIENT_NAME = "Movies (prototype)" const val CLIENT_NAME = "Movies"
const val CLIENT_VERSION = "0.1.0" const val CLIENT_VERSION = "0.1.0"
const val LOCAL_RECHECK_MS = 10 * 60_000L const val LOCAL_RECHECK_MS = 10 * 60_000L
} }

View File

@@ -12,7 +12,8 @@ import java.net.SocketTimeoutException
import java.net.URL 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 * 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 * 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 TIMEOUT_MS = 1500
private const val MESSAGE = "Who is JellyfinServer?" 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. */ /** Server id from `/System/Info/Public` of [baseUrl], or null. */
suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) { suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) {
runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } } runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } }
@@ -37,36 +41,43 @@ internal object LocalServerDiscovery {
/** `http://<ip>:8096` of the server with [serverId] on the local network, or null. */ /** `http://<ip>:8096` of the server with [serverId] on the local network, or null. */
suspend fun find(serverId: String): String? = withContext(Dispatchers.IO) { suspend fun find(serverId: String): String? = withContext(Dispatchers.IO) {
val candidates = runCatching { discover(serverId) }.onFailure { Timber.d(it, "Jellyfin discovery failed") }.getOrDefault(emptyList()) val reply = discover(stopAt = serverId).firstOrNull { it.id == serverId } ?: return@withContext null
candidates.firstOrNull { url -> serverId(url) == serverId } // 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<String> { /** Every server that answers within [TIMEOUT_MS], one reply per server id. */
val found = mutableListOf<String>() suspend fun discoverAll(): List<Reply> = withContext(Dispatchers.IO) { discover(stopAt = null) }
DatagramSocket().use { socket ->
socket.broadcast = true private fun discover(stopAt: String?): List<Reply> {
socket.soTimeout = TIMEOUT_MS val found = LinkedHashMap<String, Reply>()
val message = MESSAGE.toByteArray() runCatching {
socket.send(DatagramPacket(message, message.size, InetAddress.getByName("255.255.255.255"), DISCOVERY_PORT)) DatagramSocket().use { socket ->
val buffer = ByteArray(4096) socket.broadcast = true
val deadline = System.currentTimeMillis() + TIMEOUT_MS socket.soTimeout = TIMEOUT_MS
while (System.currentTimeMillis() < deadline) { val message = MESSAGE.toByteArray()
val packet = DatagramPacket(buffer, buffer.size) socket.send(DatagramPacket(message, message.size, InetAddress.getByName("255.255.255.255"), DISCOVERY_PORT))
try { val buffer = ByteArray(4096)
socket.receive(packet) val deadline = System.currentTimeMillis() + TIMEOUT_MS
} catch (_: SocketTimeoutException) { while (System.currentTimeMillis() < deadline) {
break 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
} }
} }.onFailure { Timber.d(it, "Jellyfin discovery failed") }
return found.distinct() return found.values.toList()
} }
private fun get(url: String): String { private fun get(url: String): String {

View File

@@ -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<Server> = 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<PublicUser> = 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")

View File

@@ -26,6 +26,7 @@ serialization = "1.11.0"
benchmark = "1.5.0" benchmark = "1.5.0"
uiautomator = "2.4.0" uiautomator = "2.4.0"
androidx-test-junit = "1.3.0" androidx-test-junit = "1.3.0"
zxing = "3.5.3"
[libraries] [libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } 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-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" }
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" }
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } 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" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
[plugins] [plugins]