Login screens
This commit is contained in:
@@ -74,6 +74,7 @@ dependencies {
|
|||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
ksp(libs.hilt.compiler)
|
ksp(libs.hilt.compiler)
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
|
implementation(libs.zxing.core)
|
||||||
implementation(libs.androidx.profileinstaller)
|
implementation(libs.androidx.profileinstaller)
|
||||||
baselineProfile(project(":baselineprofile"))
|
baselineProfile(project(":baselineprofile"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
|
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="landscape">
|
android:screenOrientation="landscape"
|
||||||
|
android:windowSoftInputMode="adjustNothing">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ import ru.stenfredd.movies.playback.api.EngineId
|
|||||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||||
import ru.stenfredd.movies.ui.DetailScreen
|
import ru.stenfredd.movies.ui.DetailScreen
|
||||||
import ru.stenfredd.movies.ui.ItemsScreen
|
import ru.stenfredd.movies.ui.ItemsScreen
|
||||||
import ru.stenfredd.movies.ui.LoginScreen
|
|
||||||
import ru.stenfredd.movies.ui.PlayerScreen
|
import ru.stenfredd.movies.ui.PlayerScreen
|
||||||
import ru.stenfredd.movies.ui.PrototypeScreen
|
import ru.stenfredd.movies.ui.PrototypeScreen
|
||||||
import ru.stenfredd.movies.ui.Screen
|
import ru.stenfredd.movies.ui.Screen
|
||||||
import ru.stenfredd.movies.ui.TourScreen
|
import ru.stenfredd.movies.ui.TourScreen
|
||||||
import ru.stenfredd.movies.ui.home.HomeScreen
|
import ru.stenfredd.movies.ui.home.HomeScreen
|
||||||
|
import ru.stenfredd.movies.ui.login.LoginScreen
|
||||||
import ru.stenfredd.movies.ui.shell.Shell
|
import ru.stenfredd.movies.ui.shell.Shell
|
||||||
import ru.stenfredd.movies.ui.shell.Tab
|
import ru.stenfredd.movies.ui.shell.Tab
|
||||||
import ru.stenfredd.movies.ui.theme.AppColors
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
@@ -58,7 +58,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
var ready by remember { mutableStateOf(false) }
|
var ready by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val loggedIn = session.restore()
|
val loggedIn = session.restore()
|
||||||
stack += if (loggedIn) Screen.Root(Tab.Home) else Screen.Login
|
stack += if (loggedIn) root(Tab.Home) else Screen.Login
|
||||||
if (loggedIn) intentTarget()?.let(stack::addAll)
|
if (loggedIn) intentTarget()?.let(stack::addAll)
|
||||||
ready = true
|
ready = true
|
||||||
}
|
}
|
||||||
@@ -94,8 +94,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
entryProvider = { screen ->
|
entryProvider = { screen ->
|
||||||
NavEntry(screen) {
|
NavEntry(screen) {
|
||||||
when (screen) {
|
when (screen) {
|
||||||
Screen.Login -> Legacy { LoginScreen(onLoggedIn = { resetTo(Screen.Root(Tab.Home)) }) }
|
Screen.Login -> LoginScreen(onDone = { resetTo(root(Tab.Home)) })
|
||||||
is Screen.Root -> Root(screen.tab, onTab = { resetTo(Screen.Root(it)) }, onOpen = ::open, onLogout = { resetTo(Screen.Login) })
|
is Screen.Root -> Root(screen.tab, onTab = { resetTo(root(it)) }, onOpen = ::open, onLogout = { resetTo(Screen.Login) })
|
||||||
Screen.Prototype -> Legacy { PrototypeScreen(onOpen = ::open, onLogout = { resetTo(Screen.Login) }) }
|
Screen.Prototype -> Legacy { PrototypeScreen(onOpen = ::open, onLogout = { resetTo(Screen.Login) }) }
|
||||||
is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) }
|
is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) }
|
||||||
is Screen.Detail -> Legacy { DetailScreen(screen, onOpen = ::open) }
|
is Screen.Detail -> Legacy { DetailScreen(screen, onOpen = ::open) }
|
||||||
@@ -107,15 +107,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun root(tab: Tab) = Screen.Root(tab, session.session.value?.accountId.orEmpty())
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Root(tab: Tab, onTab: (Tab) -> Unit, onOpen: (Screen) -> Unit, onLogout: () -> Unit) {
|
private fun Root(tab: Tab, onTab: (Tab) -> Unit, onOpen: (Screen) -> Unit, onLogout: () -> Unit) {
|
||||||
val user by session.session.collectAsStateWithLifecycle()
|
val user by session.session.collectAsStateWithLifecycle()
|
||||||
Shell(
|
Shell(
|
||||||
current = tab,
|
current = tab,
|
||||||
userName = user?.userName.orEmpty(),
|
userName = user?.userName.orEmpty(),
|
||||||
|
userSeed = user?.userId?.toString().orEmpty(),
|
||||||
onTab = onTab,
|
onTab = onTab,
|
||||||
// Profile switching comes with the new login screens (stage 3); until then the prototype has "Выйти".
|
onProfile = { onOpen(Screen.Login) },
|
||||||
onProfile = { onOpen(Screen.Prototype) },
|
|
||||||
) {
|
) {
|
||||||
when (tab) {
|
when (tab) {
|
||||||
Tab.Home -> HomeScreen(onOpen = onOpen)
|
Tab.Home -> HomeScreen(onOpen = onOpen)
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
@@ -40,6 +39,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||||
|
import ru.stenfredd.movies.ui.components.TextInput
|
||||||
|
import ru.stenfredd.movies.ui.components.rememberTextInputState
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppType
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -53,6 +56,7 @@ fun PrototypeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseVi
|
|||||||
var resume by remember { mutableStateOf(emptyList<BaseItemDto>()) }
|
var resume by remember { mutableStateOf(emptyList<BaseItemDto>()) }
|
||||||
var results by remember { mutableStateOf(emptyList<BaseItemDto>()) }
|
var results by remember { mutableStateOf(emptyList<BaseItemDto>()) }
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
val searchInput = rememberTextInputState()
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -84,7 +88,7 @@ fun PrototypeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseVi
|
|||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
Field("Поиск", query, KeyboardType.Text) { query = it }
|
TextInput(query, { query = it }, searchInput, Modifier.width(400.dp), placeholder = "Поиск", textStyle = AppType.BodyLarge.copy(color = AppColors.Text))
|
||||||
Button(onClick = { scope.launch { results = runCatching { vm.library.search(query) }.getOrDefault(emptyList()) } }) {
|
Button(onClick = { scope.launch { results = runCatching { vm.library.search(query) }.getOrDefault(emptyList()) } }) {
|
||||||
Text("Найти")
|
Text("Найти")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
import java.util.UUID
|
||||||
|
|
||||||
sealed interface Screen {
|
sealed interface Screen {
|
||||||
|
/** Login flow; also "сменить профиль" from the menu (then Back returns to the app). */
|
||||||
data object Login : Screen
|
data object Login : Screen
|
||||||
|
|
||||||
/** Root screens with the side rail. */
|
/**
|
||||||
data class Root(val tab: ru.stenfredd.movies.ui.shell.Tab) : Screen
|
* Root screens with the side rail. [account] keeps each account's screens (and their view models) apart:
|
||||||
|
* after a profile switch the stack is rebuilt, and an equal entry would be kept with the old account's state.
|
||||||
|
*/
|
||||||
|
data class Root(val tab: ru.stenfredd.movies.ui.shell.Tab, val account: String) : Screen
|
||||||
|
|
||||||
/** Old prototype home (libraries, text search, logout, debug tour) while the real screens are being built. */
|
/** Old prototype home (libraries, text search, logout, debug tour) while the real screens are being built. */
|
||||||
data object Prototype : Screen
|
data object Prototype : Screen
|
||||||
|
|||||||
@@ -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)
|
var lastKey: String? by mutableStateOf(initialKey)
|
||||||
internal set
|
internal set
|
||||||
internal val requester = FocusRequester()
|
internal val requester = FocusRequester()
|
||||||
|
|
||||||
|
/** Focuses the element with [lastKey] (after it has been composed with that key). */
|
||||||
|
fun requestFocus() {
|
||||||
|
runCatching { requester.requestFocus() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val ScreenFocusSaver = Saver<ScreenFocus, String>(
|
private val ScreenFocusSaver = Saver<ScreenFocus, String>(
|
||||||
|
|||||||
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -49,6 +48,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import ru.stenfredd.movies.ui.components.AppIcon
|
import ru.stenfredd.movies.ui.components.AppIcon
|
||||||
|
import ru.stenfredd.movies.ui.components.Avatar
|
||||||
import ru.stenfredd.movies.ui.components.Icon
|
import ru.stenfredd.movies.ui.components.Icon
|
||||||
import ru.stenfredd.movies.ui.theme.AppColors
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
import ru.stenfredd.movies.ui.theme.AppDimens
|
import ru.stenfredd.movies.ui.theme.AppDimens
|
||||||
@@ -73,6 +73,8 @@ enum class Tab(val label: String, val icon: AppIcon) {
|
|||||||
fun Shell(
|
fun Shell(
|
||||||
current: Tab,
|
current: Tab,
|
||||||
userName: String,
|
userName: String,
|
||||||
|
/** Avatar colour seed (user id), see [Avatar]. */
|
||||||
|
userSeed: String,
|
||||||
onTab: (Tab) -> Unit,
|
onTab: (Tab) -> Unit,
|
||||||
onProfile: () -> Unit,
|
onProfile: () -> Unit,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
@@ -86,6 +88,7 @@ fun Shell(
|
|||||||
Rail(
|
Rail(
|
||||||
current = current,
|
current = current,
|
||||||
userName = userName,
|
userName = userName,
|
||||||
|
userSeed = userSeed,
|
||||||
entry = railEntry,
|
entry = railEntry,
|
||||||
onFocusChange = { railFocused = it },
|
onFocusChange = { railFocused = it },
|
||||||
onTab = onTab,
|
onTab = onTab,
|
||||||
@@ -104,6 +107,7 @@ fun Shell(
|
|||||||
private fun Rail(
|
private fun Rail(
|
||||||
current: Tab,
|
current: Tab,
|
||||||
userName: String,
|
userName: String,
|
||||||
|
userSeed: String,
|
||||||
entry: FocusRequester,
|
entry: FocusRequester,
|
||||||
onFocusChange: (Boolean) -> Unit,
|
onFocusChange: (Boolean) -> Unit,
|
||||||
onTab: (Tab) -> Unit,
|
onTab: (Tab) -> Unit,
|
||||||
@@ -152,7 +156,7 @@ private fun Rail(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
ProfileItem(userName, progress, onProfile)
|
ProfileItem(userName, userSeed, progress, onProfile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +236,7 @@ private fun RailItem(tab: Tab, selected: Boolean, progress: State<Float>, onClic
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProfileItem(userName: String, progress: State<Float>, onClick: () -> Unit) {
|
private fun ProfileItem(userName: String, userSeed: String, progress: State<Float>, onClick: () -> Unit) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -245,9 +249,7 @@ private fun ProfileItem(userName: String, progress: State<Float>, onClick: () ->
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||||
) {
|
) {
|
||||||
Box(Modifier.size(24.dp).background(AppColors.SelectedBorder, CircleShape), contentAlignment = Alignment.Center) {
|
Avatar(userName, userSeed, 24.dp, AppType.CardTitle.copy(fontSize = AppType.PosterTitle.fontSize))
|
||||||
BasicText(userName.take(1).uppercase(), style = AppType.CardTitle.copy(fontSize = AppType.PosterTitle.fontSize))
|
|
||||||
}
|
|
||||||
Column(Modifier.graphicsLayer { alpha = progress.value }) {
|
Column(Modifier.graphicsLayer { alpha = progress.value }) {
|
||||||
BasicText(userName, style = AppType.CardTitle.copy(fontSize = AppType.Rail.fontSize), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false)
|
BasicText(userName, style = AppType.CardTitle.copy(fontSize = AppType.Rail.fontSize), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false)
|
||||||
BasicText("сменить профиль", style = AppType.MonoCaption.copy(fontSize = AppType.MonoBadge.fontSize), maxLines = 1, softWrap = false)
|
BasicText("сменить профиль", style = AppType.MonoCaption.copy(fontSize = AppType.MonoBadge.fontSize), maxLines = 1, softWrap = false)
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ import kotlinx.coroutines.sync.withLock
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||||
import org.jellyfin.sdk.api.client.extensions.authenticationApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
|
||||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||||
import org.jellyfin.sdk.createJellyfin
|
import org.jellyfin.sdk.createJellyfin
|
||||||
|
import org.jellyfin.sdk.discovery.DiscoveryService
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -128,23 +128,30 @@ class JellyfinSession @Inject constructor(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun login(serverUrl: String, userName: String, password: String) {
|
/** Saves the account of a successful login (see [ServerLogin]) and makes it current. */
|
||||||
val url = serverUrl.trim().trimEnd('/')
|
internal suspend fun signedIn(server: Server, result: AuthenticationResult) = mutex.withLock {
|
||||||
mutex.withLock {
|
val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" }
|
||||||
routing.set(null, null)
|
val user = checkNotNull(result.user) { "Сервер не вернул пользователя" }
|
||||||
api.update(baseUrl = url, accessToken = null)
|
val account = Account(
|
||||||
val result = call { authenticationApi.authenticateUserByName(userName, password).content }
|
serverId = result.serverId ?: user.serverId ?: server.id,
|
||||||
val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" }
|
serverName = server.name,
|
||||||
val user = checkNotNull(result.user) { "Сервер не вернул пользователя" }
|
serverUrl = server.url,
|
||||||
api.update(accessToken = token)
|
userId = user.id,
|
||||||
val serverId = result.serverId ?: user.serverId ?: url
|
userName = user.name.orEmpty(),
|
||||||
val serverName = runCatching { call { systemApi.getPublicSystemInfo().content.serverName } }.getOrNull().orEmpty()
|
accessToken = token,
|
||||||
val account = Account(serverId, serverName, url, user.id, user.name.orEmpty(), token, lastUsedAt = System.currentTimeMillis())
|
localUrl = server.localUrl,
|
||||||
save(account)
|
lastUsedAt = System.currentTimeMillis(),
|
||||||
activate(account)
|
)
|
||||||
}
|
save(account)
|
||||||
|
activate(account)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal val discovery: DiscoveryService get() = jellyfin.discovery
|
||||||
|
|
||||||
|
/** A client for the login screens: no token, this device's identity, its own timeouts. */
|
||||||
|
internal fun loginClient(baseUrl: String, options: HttpClientOptions): ApiClient =
|
||||||
|
jellyfin.createApi(baseUrl = baseUrl, deviceInfo = api.deviceInfo, httpClientOptions = options)
|
||||||
|
|
||||||
/** Makes a saved account current (profile switch); its token is reused, no password needed. */
|
/** Makes a saved account current (profile switch); its token is reused, no password needed. */
|
||||||
suspend fun switchTo(accountId: String) = mutex.withLock {
|
suspend fun switchTo(accountId: String) = mutex.withLock {
|
||||||
val account = _accounts.value.first { it.id == accountId }.copy(lastUsedAt = System.currentTimeMillis())
|
val account = _accounts.value.first { it.id == accountId }.copy(lastUsedAt = System.currentTimeMillis())
|
||||||
@@ -223,7 +230,7 @@ class JellyfinSession @Inject constructor(
|
|||||||
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = "${Build.MANUFACTURER} ${Build.MODEL}")
|
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val CLIENT_NAME = "Movies (prototype)"
|
const val CLIENT_NAME = "Movies"
|
||||||
const val CLIENT_VERSION = "0.1.0"
|
const val CLIENT_VERSION = "0.1.0"
|
||||||
const val LOCAL_RECHECK_MS = 10 * 60_000L
|
const val LOCAL_RECHECK_MS = 10 * 60_000L
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import java.net.SocketTimeoutException
|
|||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds a direct LAN address of the logged-in server, for streaming.
|
* Finds Jellyfin servers on the local network: for the login screen, and the direct LAN address of the logged-in
|
||||||
|
* server for streaming.
|
||||||
*
|
*
|
||||||
* At home the TV reached the server through its public HTTPS name: router hairpin, reverse proxy, and TLS decrypted
|
* At home the TV reached the server through its public HTTPS name: router hairpin, reverse proxy, and TLS decrypted
|
||||||
* by the TV's CPU. That capped Exo at 40–115 Mbit/s and a 66 Mbit/s 4K remux rebuffered every
|
* by the TV's CPU. That capped Exo at 40–115 Mbit/s and a 66 Mbit/s 4K remux rebuffered every
|
||||||
@@ -28,6 +29,9 @@ internal object LocalServerDiscovery {
|
|||||||
private const val TIMEOUT_MS = 1500
|
private const val TIMEOUT_MS = 1500
|
||||||
private const val MESSAGE = "Who is JellyfinServer?"
|
private const val MESSAGE = "Who is JellyfinServer?"
|
||||||
|
|
||||||
|
/** A reply to the discovery broadcast. */
|
||||||
|
class Reply(val id: String, val name: String, val lanUrl: String, val advertisedUrl: String?)
|
||||||
|
|
||||||
/** Server id from `/System/Info/Public` of [baseUrl], or null. */
|
/** Server id from `/System/Info/Public` of [baseUrl], or null. */
|
||||||
suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) {
|
suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) {
|
||||||
runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } }
|
runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } }
|
||||||
@@ -37,36 +41,43 @@ internal object LocalServerDiscovery {
|
|||||||
|
|
||||||
/** `http://<ip>:8096` of the server with [serverId] on the local network, or null. */
|
/** `http://<ip>:8096` of the server with [serverId] on the local network, or null. */
|
||||||
suspend fun find(serverId: String): String? = withContext(Dispatchers.IO) {
|
suspend fun find(serverId: String): String? = withContext(Dispatchers.IO) {
|
||||||
val candidates = runCatching { discover(serverId) }.onFailure { Timber.d(it, "Jellyfin discovery failed") }.getOrDefault(emptyList())
|
val reply = discover(stopAt = serverId).firstOrNull { it.id == serverId } ?: return@withContext null
|
||||||
candidates.firstOrNull { url -> serverId(url) == serverId }
|
// The advertised address, if it happens to be a plain LAN URL, is worth a try as well.
|
||||||
|
listOfNotNull(reply.lanUrl, reply.advertisedUrl?.takeIf { it.startsWith("http://") }).distinct()
|
||||||
|
.firstOrNull { url -> serverId(url) == serverId }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun discover(serverId: String): List<String> {
|
/** Every server that answers within [TIMEOUT_MS], one reply per server id. */
|
||||||
val found = mutableListOf<String>()
|
suspend fun discoverAll(): List<Reply> = withContext(Dispatchers.IO) { discover(stopAt = null) }
|
||||||
DatagramSocket().use { socket ->
|
|
||||||
socket.broadcast = true
|
private fun discover(stopAt: String?): List<Reply> {
|
||||||
socket.soTimeout = TIMEOUT_MS
|
val found = LinkedHashMap<String, Reply>()
|
||||||
val message = MESSAGE.toByteArray()
|
runCatching {
|
||||||
socket.send(DatagramPacket(message, message.size, InetAddress.getByName("255.255.255.255"), DISCOVERY_PORT))
|
DatagramSocket().use { socket ->
|
||||||
val buffer = ByteArray(4096)
|
socket.broadcast = true
|
||||||
val deadline = System.currentTimeMillis() + TIMEOUT_MS
|
socket.soTimeout = TIMEOUT_MS
|
||||||
while (System.currentTimeMillis() < deadline) {
|
val message = MESSAGE.toByteArray()
|
||||||
val packet = DatagramPacket(buffer, buffer.size)
|
socket.send(DatagramPacket(message, message.size, InetAddress.getByName("255.255.255.255"), DISCOVERY_PORT))
|
||||||
try {
|
val buffer = ByteArray(4096)
|
||||||
socket.receive(packet)
|
val deadline = System.currentTimeMillis() + TIMEOUT_MS
|
||||||
} catch (_: SocketTimeoutException) {
|
while (System.currentTimeMillis() < deadline) {
|
||||||
break
|
val packet = DatagramPacket(buffer, buffer.size)
|
||||||
|
try {
|
||||||
|
socket.receive(packet)
|
||||||
|
} catch (_: SocketTimeoutException) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
|
||||||
|
val id = reply.optString("Id").takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
val host = packet.address.hostAddress ?: continue
|
||||||
|
found.getOrPut(id) {
|
||||||
|
Reply(id, reply.optString("Name"), "http://$host:$HTTP_PORT", reply.optString("Address").trimEnd('/').takeIf { it.startsWith("http") })
|
||||||
|
}
|
||||||
|
if (id == stopAt) break
|
||||||
}
|
}
|
||||||
val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
|
|
||||||
if (reply.optString("Id") != serverId) continue
|
|
||||||
val host = packet.address.hostAddress ?: continue
|
|
||||||
found += "http://$host:$HTTP_PORT"
|
|
||||||
// The advertised address, if it happens to be a plain LAN URL, is worth a try as well.
|
|
||||||
reply.optString("Address").takeIf { it.startsWith("http://") }?.let { found += it.trimEnd('/') }
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}.onFailure { Timber.d(it, "Jellyfin discovery failed") }
|
||||||
return found.distinct()
|
return found.values.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun get(url: String): String {
|
private fun get(url: String): String {
|
||||||
|
|||||||
@@ -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"
|
benchmark = "1.5.0"
|
||||||
uiautomator = "2.4.0"
|
uiautomator = "2.4.0"
|
||||||
androidx-test-junit = "1.3.0"
|
androidx-test-junit = "1.3.0"
|
||||||
|
zxing = "3.5.3"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
|
||||||
@@ -63,6 +64,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
|
|||||||
androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" }
|
androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" }
|
||||||
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" }
|
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" }
|
||||||
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
||||||
|
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
|
||||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
Reference in New Issue
Block a user