Login screens
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("Найти")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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))),
|
||||
)
|
||||
@@ -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>(
|
||||
|
||||
517
app/src/main/kotlin/ru/stenfredd/movies/ui/login/LoginScreen.kt
Normal file
517
app/src/main/kotlin/ru/stenfredd/movies/ui/login/LoginScreen.kt
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
25
app/src/main/kotlin/ru/stenfredd/movies/ui/login/QrCode.kt
Normal file
25
app/src/main/kotlin/ru/stenfredd/movies/ui/login/QrCode.kt
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -19,13 +19,13 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticationApi
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.discovery.DiscoveryService
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -128,23 +128,30 @@ class JellyfinSession @Inject constructor(
|
||||
true
|
||||
}
|
||||
|
||||
suspend fun login(serverUrl: String, userName: String, password: String) {
|
||||
val url = serverUrl.trim().trimEnd('/')
|
||||
mutex.withLock {
|
||||
routing.set(null, null)
|
||||
api.update(baseUrl = url, accessToken = null)
|
||||
val result = call { authenticationApi.authenticateUserByName(userName, password).content }
|
||||
val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" }
|
||||
val user = checkNotNull(result.user) { "Сервер не вернул пользователя" }
|
||||
api.update(accessToken = token)
|
||||
val serverId = result.serverId ?: user.serverId ?: url
|
||||
val serverName = runCatching { call { systemApi.getPublicSystemInfo().content.serverName } }.getOrNull().orEmpty()
|
||||
val account = Account(serverId, serverName, url, user.id, user.name.orEmpty(), token, lastUsedAt = System.currentTimeMillis())
|
||||
save(account)
|
||||
activate(account)
|
||||
}
|
||||
/** Saves the account of a successful login (see [ServerLogin]) and makes it current. */
|
||||
internal suspend fun signedIn(server: Server, result: AuthenticationResult) = mutex.withLock {
|
||||
val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" }
|
||||
val user = checkNotNull(result.user) { "Сервер не вернул пользователя" }
|
||||
val account = Account(
|
||||
serverId = result.serverId ?: user.serverId ?: server.id,
|
||||
serverName = server.name,
|
||||
serverUrl = server.url,
|
||||
userId = user.id,
|
||||
userName = user.name.orEmpty(),
|
||||
accessToken = token,
|
||||
localUrl = server.localUrl,
|
||||
lastUsedAt = System.currentTimeMillis(),
|
||||
)
|
||||
save(account)
|
||||
activate(account)
|
||||
}
|
||||
|
||||
internal val discovery: DiscoveryService get() = jellyfin.discovery
|
||||
|
||||
/** A client for the login screens: no token, this device's identity, its own timeouts. */
|
||||
internal fun loginClient(baseUrl: String, options: HttpClientOptions): ApiClient =
|
||||
jellyfin.createApi(baseUrl = baseUrl, deviceInfo = api.deviceInfo, httpClientOptions = options)
|
||||
|
||||
/** Makes a saved account current (profile switch); its token is reused, no password needed. */
|
||||
suspend fun switchTo(accountId: String) = mutex.withLock {
|
||||
val account = _accounts.value.first { it.id == accountId }.copy(lastUsedAt = System.currentTimeMillis())
|
||||
@@ -223,7 +230,7 @@ class JellyfinSession @Inject constructor(
|
||||
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
|
||||
private companion object {
|
||||
const val CLIENT_NAME = "Movies (prototype)"
|
||||
const val CLIENT_NAME = "Movies"
|
||||
const val CLIENT_VERSION = "0.1.0"
|
||||
const val LOCAL_RECHECK_MS = 10 * 60_000L
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import java.net.SocketTimeoutException
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Finds a direct LAN address of the logged-in server, for streaming.
|
||||
* Finds Jellyfin servers on the local network: for the login screen, and the direct LAN address of the logged-in
|
||||
* server for streaming.
|
||||
*
|
||||
* At home the TV reached the server through its public HTTPS name: router hairpin, reverse proxy, and TLS decrypted
|
||||
* by the TV's CPU. That capped Exo at 40–115 Mbit/s and a 66 Mbit/s 4K remux rebuffered every
|
||||
@@ -28,6 +29,9 @@ internal object LocalServerDiscovery {
|
||||
private const val TIMEOUT_MS = 1500
|
||||
private const val MESSAGE = "Who is JellyfinServer?"
|
||||
|
||||
/** A reply to the discovery broadcast. */
|
||||
class Reply(val id: String, val name: String, val lanUrl: String, val advertisedUrl: String?)
|
||||
|
||||
/** Server id from `/System/Info/Public` of [baseUrl], or null. */
|
||||
suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) {
|
||||
runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } }
|
||||
@@ -37,36 +41,43 @@ internal object LocalServerDiscovery {
|
||||
|
||||
/** `http://<ip>:8096` of the server with [serverId] on the local network, or null. */
|
||||
suspend fun find(serverId: String): String? = withContext(Dispatchers.IO) {
|
||||
val candidates = runCatching { discover(serverId) }.onFailure { Timber.d(it, "Jellyfin discovery failed") }.getOrDefault(emptyList())
|
||||
candidates.firstOrNull { url -> serverId(url) == serverId }
|
||||
val reply = discover(stopAt = serverId).firstOrNull { it.id == serverId } ?: return@withContext null
|
||||
// The advertised address, if it happens to be a plain LAN URL, is worth a try as well.
|
||||
listOfNotNull(reply.lanUrl, reply.advertisedUrl?.takeIf { it.startsWith("http://") }).distinct()
|
||||
.firstOrNull { url -> serverId(url) == serverId }
|
||||
}
|
||||
|
||||
private fun discover(serverId: String): List<String> {
|
||||
val found = mutableListOf<String>()
|
||||
DatagramSocket().use { socket ->
|
||||
socket.broadcast = true
|
||||
socket.soTimeout = TIMEOUT_MS
|
||||
val message = MESSAGE.toByteArray()
|
||||
socket.send(DatagramPacket(message, message.size, InetAddress.getByName("255.255.255.255"), DISCOVERY_PORT))
|
||||
val buffer = ByteArray(4096)
|
||||
val deadline = System.currentTimeMillis() + TIMEOUT_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val packet = DatagramPacket(buffer, buffer.size)
|
||||
try {
|
||||
socket.receive(packet)
|
||||
} catch (_: SocketTimeoutException) {
|
||||
break
|
||||
/** Every server that answers within [TIMEOUT_MS], one reply per server id. */
|
||||
suspend fun discoverAll(): List<Reply> = withContext(Dispatchers.IO) { discover(stopAt = null) }
|
||||
|
||||
private fun discover(stopAt: String?): List<Reply> {
|
||||
val found = LinkedHashMap<String, Reply>()
|
||||
runCatching {
|
||||
DatagramSocket().use { socket ->
|
||||
socket.broadcast = true
|
||||
socket.soTimeout = TIMEOUT_MS
|
||||
val message = MESSAGE.toByteArray()
|
||||
socket.send(DatagramPacket(message, message.size, InetAddress.getByName("255.255.255.255"), DISCOVERY_PORT))
|
||||
val buffer = ByteArray(4096)
|
||||
val deadline = System.currentTimeMillis() + TIMEOUT_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val packet = DatagramPacket(buffer, buffer.size)
|
||||
try {
|
||||
socket.receive(packet)
|
||||
} catch (_: SocketTimeoutException) {
|
||||
break
|
||||
}
|
||||
val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
|
||||
val id = reply.optString("Id").takeIf { it.isNotEmpty() } ?: continue
|
||||
val host = packet.address.hostAddress ?: continue
|
||||
found.getOrPut(id) {
|
||||
Reply(id, reply.optString("Name"), "http://$host:$HTTP_PORT", reply.optString("Address").trimEnd('/').takeIf { it.startsWith("http") })
|
||||
}
|
||||
if (id == stopAt) break
|
||||
}
|
||||
val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
|
||||
if (reply.optString("Id") != serverId) continue
|
||||
val host = packet.address.hostAddress ?: continue
|
||||
found += "http://$host:$HTTP_PORT"
|
||||
// The advertised address, if it happens to be a plain LAN URL, is worth a try as well.
|
||||
reply.optString("Address").takeIf { it.startsWith("http://") }?.let { found += it.trimEnd('/') }
|
||||
break
|
||||
}
|
||||
}
|
||||
return found.distinct()
|
||||
}.onFailure { Timber.d(it, "Jellyfin discovery failed") }
|
||||
return found.values.toList()
|
||||
}
|
||||
|
||||
private fun get(url: String): String {
|
||||
|
||||
@@ -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")
|
||||
@@ -26,6 +26,7 @@ serialization = "1.11.0"
|
||||
benchmark = "1.5.0"
|
||||
uiautomator = "2.4.0"
|
||||
androidx-test-junit = "1.3.0"
|
||||
zxing = "3.5.3"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
|
||||
@@ -63,6 +64,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
|
||||
androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" }
|
||||
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" }
|
||||
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
||||
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
|
||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
||||
|
||||
[plugins]
|
||||
|
||||
Reference in New Issue
Block a user