feat(app-phone): полноценный UI телефона — каталог Jellyfin, детали, пульт очков, реальное скачивание с S3

- 4 экрана по макету sketches/phone-ui: Каталог (табы Фильмы/Сериалы/Папки, поиск, бейджи зеркала), Детали (озвучки, статус конвертации, чипы скачивания), Очки (пульт, статус), Чат-заглушка
- S3 SigV4 presign (S3Presigner): mirror-client read-only креды; watch() и DownloadManager подписывают URL (бакет приватный)
- DownloadManager: реальное скачивание video+audio с прогрессом, readTimeout=0 (длинные файлы)
- PhoneActions.watch: параметр presign (app-host работает без изменений)
- TESTING.md: блок телефонных кейсов TP-1..6
This commit is contained in:
Hermes Agent
2026-08-19 09:13:56 +03:00
parent 4458678698
commit 6069400b88
19 changed files with 2145 additions and 68 deletions
+84
View File
@@ -187,3 +187,87 @@ APK `pw.binom.viewmate.glasses` установлен.
**Ожидаемо:**
- В логе периодически (раз в ~5 секунд): `glasses статус: батарея 80%, память 10.5/32.0 ГБ`
- И `glasses позиция: NNN мс, играет/пауза` с реально растущей позицией во время воспроизведения
---
## TC-11 Конвертация зеркала (media-mirror конвейер)
**Предусловия:** TC-01 пройден. Стенд достаёт `mirror.binom.pw` (проверено 19.08: отвечает 404 на корне = жив).
**Шаги:**
1. Через консоль хоста пошли: `open a3ca9fbf926fe9f60448f591ea945ac8` (фильм «Акира») — посмотри текущий статус зеркала
2. Через консоль пошли: `mirror a3ca9fbf926fe9f60448f591ea945ac8`
3. Подожди 30–60 секунд
4. Через консоль пошли: `mirror-status a3ca9fbf926fe9f60448f591ea945ac8`
5. Повторяй шаг 4 раз в 30–60 секунд, пока статус не станет `done` (может занять до 10–15 минут — это NVENC-транскодинг на сервере)
**Ожидаемо:**
- `mirror` вернул id джоба и статус (created/queued/processing)
- `mirror-status` показывает прогресс: queued → processing → done
- По завершении: статус `done` и ссылка на готовое зеркало (480p H.264)
---
## TC-12 Скачивание на устройство (эмуляция) и удаление
**Предусловия:** TC-01 пройден.
**Шаги:**
1. Через консоль хоста пошли: `dl a3ca9fbf926fe9f60448f591ea945ac8 телефон`
2. Через консоль пошли: `status` — найди в выводе отметку о скачанном
3. Через консоль пошли: `dl a3ca9fbf926fe9f60448f591ea945ac8 очки`
4. Через консоль пошли: `status` — проверь, что отметка изменилась (очки)
5. Через консоль пошли: `rm a3ca9fbf926fe9f60448f591ea945ac8`
6. Через консоль пошли: `status` — проверь, что скачанное удалено
**Ожидаемо:**
- `dl` отвечает «скачано … → телефон (эмуляция, отмечено в стейте)»
- `status` показывает скачанное с целевым устройством
- `rm` удаляет отметку, `status` больше её не показывает
- Ошибок и крашей нет
---
## TC-13 Воспроизведение зеркала (watch, после done)
**Предусловия:** TC-11 пройден (зеркало `done`), TC-01 пройден.
**Шаги:**
1. Через консоль хоста пошли: `watch a3ca9fbf926fe9f60448f591ea945ac8`
2. Подожди 10–15 секунд
3. Сними скрин экрана очков
4. Проверь лог хоста (позиция растёт)
**Ожидаемо:**
- `watch` отвечает «включил «Акира» на очках (озвучка #0): <url зеркала>»
- На экране кадр фильма (из зеркала 480p), одинаковый в обеих половинах
- Позиция растёт, крашей нет
---
# Телефон (app-phone) — тест-кейсы
Стенд: телефон = emulator-5556 (AVD test35, 1080x2400, app pw.binom.viewmate.phone), очки = emulator-5554 (glasses35), связь: adb forward tcp:8080 (хост → телефон), очки ходят на ws://10.0.2.2:8080/ws/glasses. app-host на стенде остановлен.
## TP-1 Каталог
- Вкладки Фильмы/Сериалы/Папки, сетка карточек (постер + название + бейдж зеркала: ✓/⟳%/нет/↓часть), нижняя навигация Каталог/Очки/Чат. Переключение вкладок работает.
- ПРОЙДЕН (QA 19.08: P1)
## TP-2 Поиск
- Ввод «акира» → найден фильм «Акира». Кириллица через ADBKeyboard (input text кириллицу не принимает).
- ПРОЙДЕН (QA: P2)
## TP-3 Детали
- Клик по карточке: постер, название, статус конвертации (нет/processing %/done/failed+перезаказ), секция озвучки (если done — дорожки radio), 4 чипа скачивания (очки-видео/звук, телефон-видео/звук), кнопки «Смотреть на очках», «Скачать на телефон», «Удалить скачанное».
- ПРОЙДЕН частично (QA: P3 — до done зеркала; скачивание — после done)
## TP-4 Пульт (экран Очки)
- Карточка: связь (подключено), батарея, память, «сейчас на очках» + позиция. Пульт: −30с/+30с/Плей/Пауза/режимы. Команды доходят до очков (лог очков MediaCommand).
- ПРОЙДЕН (QA: P4; замечание: PLAY на «зависшей» старой сессии не возобновляет — перепроверить на свежей после watch)
## TP-5 Чат
- Заглушка «в разработке (следующий этап)».
- ПРОЙДЕН (QA: P5)
## TP-6 Скачивание на телефон (реальное)
- Зеркало done → детали → «Скачать на телефон»: прогресс %, файлы video.mkv + audio-{i}.ogg в filesDir, чипы «скачано». Удаление через «Удалить скачанное».
+4
View File
@@ -44,6 +44,10 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.android.material3)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.foundation:foundation")
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation(libs.exoplayer)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
@@ -0,0 +1,169 @@
package pw.binom.viewmate.phone
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import pw.binom.viewmate.core.media.MirrorClient
import java.io.File
import java.util.concurrent.ConcurrentHashMap
/** Стадия загрузки файла на телефон. */
enum class DownloadPhase {
DOWNLOADING,
DONE,
FAILED,
CANCELED,
}
/** Прогресс реального скачивания зеркала на телефон. */
data class DownloadProgress(
val itemId: String,
val phase: DownloadPhase,
val percent: Int,
val stage: String = "",
)
/**
* Реальное скачивание зеркала (видео + выбранная озвучка) с прогрессом.
* Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg.
* Прогресс живёт в StateFlow и переживает повороты (загрузка в scope приложения).
*/
class DownloadManager(
context: Context,
private val mirror: MirrorClient,
) {
private val baseDir = File(context.filesDir, "viewmate")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val jobs = ConcurrentHashMap<String, Job>()
private val okHttp = OkHttpClient.Builder()
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(0, java.util.concurrent.TimeUnit.MILLISECONDS) // длинные файлы, паузы в потоке
.build()
private val _downloads = MutableStateFlow<Map<String, DownloadProgress>>(emptyMap())
val downloads: StateFlow<Map<String, DownloadProgress>> = _downloads.asStateFlow()
private val _downloadedIds = MutableStateFlow(scanExistingDownloads())
/** itemId, у которых видео уже скачано на телефон (переживает рестарт приложения). */
val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow()
private fun scanExistingDownloads(): Set<String> =
baseDir.listFiles()?.filter { it.isDirectory && File(it, "video.mkv").exists() }?.map { it.name }?.toSet()
?: emptySet()
/** Папка скачанного для itemId. */
fun dirFor(itemId: String): File = File(baseDir, itemId)
/** Файл видео на телефоне. */
fun videoFile(itemId: String): File = File(dirFor(itemId), "video.mkv")
/** Файлы озвучки на телефоне (audio-*.ogg). */
fun audioFiles(itemId: String): List<File> =
dirFor(itemId).listFiles()?.filter { it.isFile && it.name.startsWith("audio-") }?.sortedBy { it.name }
?: emptyList()
/** Видео уже скачано на телефон. */
fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists()
/**
* Начать загрузку зеркала [itemId] (видео + озвучка [audioIndex]).
* Если уже качается — повторный вызов игнорируется.
*/
fun startDownload(itemId: String, audioIndex: Int) {
if (jobs[itemId]?.isActive == true) return
jobs[itemId] = scope.launch {
try {
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, 0, "получаю зеркало"))
val status = mirror.mirrorByItem(itemId)
val files = status?.files
if (status == null || status.status != "done" || files == null) {
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
return@launch
}
val dir = dirFor(itemId)
dir.mkdirs()
downloadFile(
S3Presigner.presignGet(files.video.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
videoFile(itemId),
) { percent ->
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "видео"))
}
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull()
if (audio != null) {
val audioFile = File(dir, "audio-${audio.index}.ogg")
downloadFile(
S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
audioFile,
) { percent ->
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "звук"))
}
}
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
_downloadedIds.value = _downloadedIds.value + itemId
log("download", "скачано $itemId → $dir")
} catch (e: kotlinx.coroutines.CancellationException) {
update(itemId, DownloadProgress(itemId, DownloadPhase.CANCELED, 0, "отменено"))
throw e
} catch (e: Exception) {
log("download", "ошибка скачивания $itemId: ${e.message}")
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
}
}
}
/** Отменить текущую загрузку. */
fun cancel(itemId: String) {
jobs.remove(itemId)?.cancel()
}
/** Удалить скачанные файлы с телефона и сбросить прогресс. */
fun delete(itemId: String) {
cancel(itemId)
dirFor(itemId).deleteRecursively()
_downloads.value = _downloads.value - itemId
_downloadedIds.value = _downloadedIds.value - itemId
}
private suspend fun downloadFile(url: String, dest: File, onProgress: (Int) -> Unit) = withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
okHttp.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code} для $url")
val body = response.body ?: error("пустое тело ответа для $url")
val total = body.contentLength()
val input = body.byteStream()
dest.outputStream().use { out ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var done = 0L
while (true) {
currentCoroutineContext().ensureActive()
val read = input.read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
done += read
if (total > 0) {
onProgress(((done.toDouble() / total) * 100).toInt().coerceIn(0, 100))
}
}
}
}
}
private fun update(itemId: String, progress: DownloadProgress) {
_downloads.value = _downloads.value + (itemId to progress)
}
}
@@ -49,6 +49,18 @@ class GlassesHub(
private val _gesturesReceived = MutableStateFlow(0)
val gesturesReceived: StateFlow<Int> = _gesturesReceived.asStateFlow()
private val _batteryPercent = MutableStateFlow<Int?>(null)
val batteryPercent: StateFlow<Int?> = _batteryPercent.asStateFlow()
private val _storageUsedGb = MutableStateFlow<Double?>(null)
val storageUsedGb: StateFlow<Double?> = _storageUsedGb.asStateFlow()
private val _storageTotalGb = MutableStateFlow<Double?>(null)
val storageTotalGb: StateFlow<Double?> = _storageTotalGb.asStateFlow()
private val _positionMs = MutableStateFlow<Long?>(null)
val positionMs: StateFlow<Long?> = _positionMs.asStateFlow()
fun add(session: DefaultWebSocketServerSession) {
sessions[session] = Unit
_connected.value = sessions.size
@@ -88,10 +100,15 @@ class GlassesHub(
}
is GlassesStatus -> {
_batteryPercent.value = msg.batteryPercent
_storageUsedGb.value = msg.storageUsedGb
_storageTotalGb.value = msg.storageTotalGb
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
}
is PlaybackPosition -> {
_positionMs.value = msg.positionMs
this.state.playing = msg.playing
val state = if (msg.playing) "играет" else "пауза"
log("glasses", "позиция: ${msg.positionMs} мс, $state")
}
@@ -3,42 +3,13 @@ package pw.binom.viewmate.phone
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import pw.binom.viewmate.phone.ui.ViewMateApp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
ServerStatusScreen()
}
ViewMateApp()
}
}
}
@Composable
fun ServerStatusScreen() {
val app = PhoneApp.instance
val connected by app.server.hub.connected.collectAsState()
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("ViewMate Phone", style = MaterialTheme.typography.headlineMedium)
Text("WS-сервер запущен")
Text("Порт: ${PhoneConfig.SERVER_PORT}")
Text("Путь: $GLASSES_WS_PATH")
Text("Подключено очков: $connected")
}
}
@@ -4,6 +4,9 @@ import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient
@@ -12,15 +15,27 @@ import pw.binom.viewmate.core.phone.PhoneState
/**
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
* WS-сервер (GlassesServer) и PhoneActions. Синглтон-доступ через companion.
* WS-сервер (GlassesServer), DownloadManager и PhoneActions. Синглтон-доступ через companion.
*/
class PhoneApp : Application() {
lateinit var state: PhoneState
private set
lateinit var server: GlassesServer
private set
lateinit var actions: PhoneActions
val state: PhoneState = PhoneState()
/** WS-сервер для очков. */
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
/** HTTP-клиенты бэкендов (Jellyfin-каталог, media-mirror). */
val jellyfin: JellyfinClient = JellyfinClient(PhoneConfig.JELLYFIN_URL, PhoneConfig.JELLYFIN_API_KEY)
val mirror: MirrorClient = MirrorClient(PhoneConfig.MIRROR_URL, PhoneConfig.MIRROR_API_KEY)
private val _userId = MutableStateFlow("")
/** Первый пользователь Jellyfin (подтягивается один раз при старте). */
val userId: StateFlow<String> = _userId.asStateFlow()
/** Реальное скачивание зеркал на телефон (с прогрессом). */
val downloadManager: DownloadManager by lazy { DownloadManager(this, mirror) }
var actions: PhoneActions? = null
private set
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -29,18 +44,13 @@ class PhoneApp : Application() {
super.onCreate()
instance = this
state = PhoneState()
server = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
val jellyfin = JellyfinClient(PhoneConfig.JELLYFIN_URL, PhoneConfig.JELLYFIN_API_KEY)
val mirror = MirrorClient(PhoneConfig.MIRROR_URL, PhoneConfig.MIRROR_API_KEY)
server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
// первый пользователь Jellyfin — один раз при старте
scope.launch {
val userId = runCatching { jellyfin.users().firstOrNull()?.Id }.getOrNull()
?: ""
_userId.value = userId
if (userId.isBlank()) log("app", "не удалось получить пользователя Jellyfin")
actions = PhoneActions(
jellyfin = jellyfin,
@@ -11,4 +11,9 @@ object PhoneConfig {
const val MIRROR_URL = "https://mirror.binom.pw"
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
const val SERVER_PORT = 8080
// S3 SeaweedFS (bucket media, префикс mirror/): read-only identity mirror-client
const val S3_ACCESS_KEY = "MirrorClient01QwertyKey"
const val S3_SECRET_KEY = "MirrorClient01SecretKeyValueForGlassesOnly"
const val S3_REGION = "us-east-1"
}
@@ -0,0 +1,105 @@
package pw.binom.viewmate.phone
import java.net.URL
import java.security.MessageDigest
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Подпись GET-запросов к S3 SeaweedFS (bucket `media`, префикс `mirror/`) по AWS SigV4
* в виде presigned URL (query-авторизация). Бакет закрыт на анонимное чтение, поэтому
* клиент (очки/телефон) подписывает URL read-only ключом `mirror-client` из [PhoneConfig].
*
* Алгоритм (проверен 19.08: подписанный URL отдаёт 200):
* - канонический запрос: GET\n{path}\n{query}\nhost:{host}\n\nhost\nUNSIGNED-PAYLOAD
* - query-параметры X-Amz-* в алфавитном порядке, без URL-кодирования пробелов/`/`
* - ключ подписи: HMAC-SHA256 цепочкой dateKey → regionKey → serviceKey → signingKey
*/
object S3Presigner {
private const val SERVICE = "s3"
private const val HMAC_ALGORITHM = "HmacSHA256"
/** Подписать GET-запрос к [url] на [expiresSeconds] секунд (время берётся текущее UTC). */
fun presignGet(
url: String,
accessKey: String,
secretKey: String,
region: String = "us-east-1",
expiresSeconds: Int = 3600,
): String = presignGetAt(url, accessKey, secretKey, region, expiresSeconds, nowUtc())
/** То же, но с фиксированным временем X-Amz-Date (для unit-теста с эталонной сигнатурой). */
internal fun presignGetAt(
url: String,
accessKey: String,
secretKey: String,
region: String,
expiresSeconds: Int,
amzDate: String,
): String {
val parsed = URL(url)
val host = parsed.host + if (parsed.port != -1) ":${parsed.port}" else ""
val path = parsed.path.ifEmpty { "/" }
val date = amzDate.take(8)
val query = sortedMapOf(
"X-Amz-Algorithm" to "AWS4-HMAC-SHA256",
"X-Amz-Credential" to "$accessKey/$date/$region/$SERVICE/aws4_request",
"X-Amz-Date" to amzDate,
"X-Amz-Expires" to expiresSeconds.toString(),
"X-Amz-SignedHeaders" to "host",
)
val canonicalQuery = query.entries.joinToString("&") { (name, value) ->
"${uriEncode(name)}=${uriEncode(value)}"
}
val canonicalRequest = "GET\n$path\n$canonicalQuery\nhost:$host\n\nhost\nUNSIGNED-PAYLOAD"
val scope = "$date/$region/$SERVICE/aws4_request"
val stringToSign = "AWS4-HMAC-SHA256\n$amzDate\n$scope\n${sha256Hex(canonicalRequest)}"
val signingKey = deriveSigningKey(secretKey, date, region)
val signature = hmacHex(signingKey, stringToSign)
return "$url?$canonicalQuery&X-Amz-Signature=$signature"
}
private fun nowUtc(): String =
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC).format(Instant.now())
/** RFC3986 URI-encode (без safe-символов): буквы/цифры/-_.~ оставить, остальное в %XX. */
private fun uriEncode(value: String): String {
val sb = StringBuilder()
for (byte in value.toByteArray(Charsets.UTF_8)) {
val c = byte.toInt() and 0xFF
val ok = (c in 'A'.code..'Z'.code) || (c in 'a'.code..'z'.code) ||
(c in '0'.code..'9'.code) || c == '-'.code || c == '_'.code ||
c == '.'.code || c == '~'.code
if (ok) sb.append(c.toChar()) else sb.append('%').append("%02X".format(c))
}
return sb.toString()
}
private fun deriveSigningKey(secretKey: String, date: String, region: String): ByteArray {
val dateKey = hmac("AWS4$secretKey".toByteArray(Charsets.UTF_8), date)
val regionKey = hmac(dateKey, region)
val serviceKey = hmac(regionKey, SERVICE)
return hmac(serviceKey, "aws4_request")
}
private fun hmac(key: ByteArray, data: String): ByteArray {
val mac = Mac.getInstance(HMAC_ALGORITHM)
mac.init(SecretKeySpec(key, HMAC_ALGORITHM))
return mac.doFinal(data.toByteArray(Charsets.UTF_8))
}
private fun hmacHex(key: ByteArray, data: String): String = hmac(key, data).toHex()
private fun sha256Hex(data: String): String =
MessageDigest.getInstance("SHA-256").digest(data.toByteArray(Charsets.UTF_8)).toHex()
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
}
@@ -0,0 +1,370 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.background
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.PaddingValues
import androidx.compose.foundation.layout.Row
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.phone.PhoneApp
/** Вкладки каталога: Фильмы / Сериалы / Папки. */
enum class CatalogTab { MOVIES, SERIES, FOLDERS }
/**
* Стейт экрана каталога: вкладки, поиск, вложенность (сериал→сезоны→эпизоды,
* папки→папки) и бейджи зеркал. Живёт на уровне корня приложения, чтобы
* переживать переходы на детали и обратно.
*/
@Stable
class CatalogState(
private val app: PhoneApp,
private val scope: CoroutineScope,
) {
sealed interface Path {
data object Root : Path
data class Series(val series: JellyfinItem) : Path
data class Season(val season: JellyfinItem, val series: JellyfinItem) : Path
data class Folder(val folder: JellyfinItem) : Path
}
var tab by mutableStateOf(CatalogTab.MOVIES)
private set
var query by mutableStateOf("")
private set
var items by mutableStateOf<List<JellyfinItem>>(emptyList())
private set
var loading by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
private set
var mirrors by mutableStateOf<Map<String, MirrorJob>>(emptyMap())
private set
var path by mutableStateOf<Path>(Path.Root)
private set
private var searchJob: Job? = null
private var loadJob: Job? = null
fun selectTab(tab: CatalogTab) {
if (this.tab == tab) {
// повторное нажатие на вкладку — принудительное обновление
refresh()
return
}
this.tab = tab
this.path = Path.Root
refresh()
}
fun onQueryChange(q: String) {
query = q
searchJob?.cancel()
searchJob = scope.launch {
delay(300)
refresh()
}
}
fun openSeries(item: JellyfinItem) {
path = Path.Series(item)
refresh()
}
fun openSeason(season: JellyfinItem, series: JellyfinItem) {
path = Path.Season(season, series)
refresh()
}
fun openFolder(item: JellyfinItem) {
path = Path.Folder(item)
refresh()
}
fun back() {
path = when (val p = path) {
Path.Root -> Path.Root
is Path.Series -> Path.Root
is Path.Season -> Path.Series(p.series)
is Path.Folder -> Path.Root
}
refresh()
}
/** Заголовок под шапкой: текущая вкладка или хлебная крошка внутри сериала/папки. */
fun subtitle(): String = when (val p = path) {
Path.Root -> when (tab) {
CatalogTab.MOVIES -> "Фильмы"
CatalogTab.SERIES -> "Сериалы"
CatalogTab.FOLDERS -> "Папки"
}
is Path.Series -> p.series.Name ?: p.series.Id
is Path.Season -> "${p.series.Name ?: p.series.Id} — ${p.season.Name ?: p.season.Id}"
is Path.Folder -> p.folder.Name ?: p.folder.Id
}
fun canGoBack(): Boolean = path != Path.Root
fun refresh() {
loadJob?.cancel()
val userId = app.userId.value
if (userId.isBlank()) return
loadJob = scope.launch {
loading = true
error = null
try {
val loaded = if (query.isNotBlank()) {
app.jellyfin.search(userId, query.trim())
} else {
when (val p = path) {
Path.Root -> when (tab) {
CatalogTab.MOVIES -> app.jellyfin.allItems(userId, listOf("Movie"))
CatalogTab.SERIES -> app.jellyfin.allItems(userId, listOf("Series"))
CatalogTab.FOLDERS -> app.jellyfin.rootItems(userId, FOLDER_TYPES)
}
is Path.Series -> app.jellyfin.items(userId, p.series.Id, listOf("Season"))
is Path.Season -> app.jellyfin.items(userId, p.season.Id, listOf("Episode"))
is Path.Folder -> app.jellyfin.items(userId, p.folder.Id, FOLDER_TYPES)
}
}
items = loaded
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
error = e.message ?: "ошибка загрузки"
items = emptyList()
} finally {
loading = false
}
}
}
companion object {
/** Типы, показываемые при обходе папок Jellyfin (папки + медиа). */
val FOLDER_TYPES = listOf(
"CollectionFolder", "Folder", "Movie", "Series", "Season",
"Episode", "Video", "MusicVideo", "BoxSet", "Audio",
"PhotoAlbum", "Playlist", "Recording", "Book",
)
/** Типы-«контейнеры»: клик заходит внутрь (папки/библиотеки/сезоны-коробки). */
val CONTAINER_TYPES = listOf(
"CollectionFolder", "Folder", "BoxSet", "Playlist", "PhotoAlbum", "Recording",
)
}
}
/** Экран 1 — Каталог Jellyfin: поиск, вкладки, сетка карточек с бейджами. */
@Composable
fun CatalogScreen(
state: CatalogState,
onItemClick: (JellyfinItem) -> Unit,
) {
val app = PhoneApp.instance
val userId by app.userId.collectAsState()
val downloaded by app.state.downloadedFlow.collectAsState()
val localDownloaded by app.downloadManager.downloadedIds.collectAsState()
// подтянуть список, когда появился пользователь Jellyfin
LaunchedEffect(userId) {
if (userId.isNotBlank() && state.items.isEmpty()) state.refresh()
}
Column(modifier = Modifier.fillMaxSize()) {
CatalogTopBar(state = state)
if (state.loading && state.items.isEmpty()) {
ScreenPlaceholder("загружаю…")
} else if (state.error != null && state.items.isEmpty()) {
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
} else if (state.items.isEmpty()) {
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
items(state.items, key = { it.Id }) { item ->
ItemCard(
item = item,
posterUrl = posterUrlOrNull(app, item),
badge = mirrorBadge(
itemId = item.Id,
job = state.mirrors[item.Id],
downloaded = downloaded,
localDownloaded = localDownloaded,
),
onClick = {
when {
item.Type == "Series" -> state.openSeries(item)
item.Type == "Season" -> {
val series = when (val p = state.path) {
is CatalogState.Path.Series -> p.series
is CatalogState.Path.Season -> p.series
else -> item
}
state.openSeason(item, series)
}
item.Type in CatalogState.CONTAINER_TYPES -> state.openFolder(item)
else -> onItemClick(item)
}
},
)
}
}
}
}
}
@Composable
private fun CatalogTopBar(state: CatalogState) {
Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
OutlinedTextField(
value = state.query,
onValueChange = state::onQueryChange,
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp),
placeholder = { Text("🔍 поиск по каталогу…", fontSize = 13.sp, color = PhoneMuted) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (state.canGoBack()) {
TextButton(onClick = state::back) {
Text("← назад", color = PhoneAccent, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
Text(
text = state.subtitle(),
color = PhoneTextDim,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
CatalogTab.entries.forEach { tab ->
val active = state.tab == tab
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(if (active) PhoneAccent else PhoneSurface)
.clickable { state.selectTab(tab) }
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
text = tabLabel(tab),
color = if (active) PhoneBackground else PhoneMuted,
fontSize = 12.sp,
fontWeight = if (active) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
}
}
private fun tabLabel(tab: CatalogTab): String = when (tab) {
CatalogTab.MOVIES -> "Фильмы"
CatalogTab.SERIES -> "Сериалы"
CatalogTab.FOLDERS -> "Папки"
}
@Composable
private fun ItemCard(
item: JellyfinItem,
posterUrl: String?,
badge: MirrorBadge,
onClick: () -> Unit,
) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(PhoneCard)
.padding(8.dp)
.clickable(onClick = onClick),
) {
Box {
PosterImage(
item = item,
posterUrl = posterUrl,
modifier = Modifier
.fillMaxWidth()
.height(110.dp)
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(8.dp)),
)
Box(
modifier = Modifier
.padding(2.dp)
.clip(RoundedCornerShape(6.dp))
.background(badge.bg)
.padding(horizontal = 6.dp, vertical = 2.dp)
.align(Alignment.TopEnd),
) {
Text(
text = badge.text,
color = badge.fg,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
)
}
}
Text(
text = item.Name ?: item.Id,
color = PhoneTextDim,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 2.dp),
)
}
}
@@ -0,0 +1,35 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Экран 4 — Чат с ассистентом (заглушка: отдельный этап, Hermes-интеграция). */
@Composable
fun ChatScreen() {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
) {
Text(
text = "Чат с Порфирием",
color = PhoneText,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = "в разработке (следующий этап)",
color = PhoneMuted,
fontSize = 13.sp,
modifier = Modifier.padding(top = 8.dp),
)
}
}
@@ -0,0 +1,291 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
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.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
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.sp
import coil.compose.AsyncImage
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob
/** Экраны приложения (простая навигация без nav-библиотеки). */
enum class Screen {
CATALOG,
DETAILS,
GLASSES,
CHAT,
}
private data class BottomTab(val screen: Screen, val icon: String, val label: String)
private val bottomTabs = listOf(
BottomTab(Screen.CATALOG, "🎬", "Каталог"),
BottomTab(Screen.GLASSES, "🕶️", "Очки"),
BottomTab(Screen.CHAT, "💬", "Чат"),
)
/** Нижняя навигация (Каталог / Очки / Чат). */
@Composable
fun BottomNavBar(current: Screen, onSelect: (Screen) -> Unit) {
NavigationBar(containerColor = PhoneCard) {
bottomTabs.forEach { tab ->
NavigationBarItem(
selected = current == tab.screen,
onClick = { onSelect(tab.screen) },
icon = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(tab.icon, fontSize = 16.sp)
Text(
tab.label,
fontSize = 10.sp,
color = if (current == tab.screen) PhoneAccent else PhoneMuted,
)
}
},
)
}
}
}
/** Бейдж статуса зеркала на карточке каталога. */
data class MirrorBadge(val text: String, val bg: Color, val fg: Color)
/**
* Статус зеркала элемента:
* - ⟳ N% — конвертируется;
* - ↓ часть — что-то скачано локально (телефон) или на очки;
* - ✓ — зеркало готово;
* - ✗ — ошибка;
* - нет — ничего нет.
*/
fun mirrorBadge(
itemId: String,
job: MirrorJob?,
downloaded: Map<String, String>,
localDownloaded: Set<String>,
): MirrorBadge {
val status = job?.status
val hasLocal = localDownloaded.contains(itemId)
val hasAny = downloaded.containsKey(itemId)
return when {
status == "processing" || status == "queued" -> MirrorBadge(
"⟳ ${job.progress ?: 0}%",
PhoneYellow,
PhoneBackground,
)
status == "failed" || status == "error" -> MirrorBadge("✗", PhoneRed, Color.White)
hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText)
status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText)
else -> MirrorBadge("нет", Color(0xFF555555), PhoneTextDim)
}
}
/** Постер элемента: Coil по ImageTags.Primary, иначе градиент-заглушка с названием. */
@Composable
fun PosterImage(
item: JellyfinItem,
posterUrl: String?,
modifier: Modifier = Modifier,
) {
if (posterUrl != null) {
AsyncImage(
model = posterUrl,
contentDescription = item.Name,
contentScale = ContentScale.Crop,
modifier = modifier,
)
} else {
Box(
modifier = modifier.background(
Brush.linearGradient(listOf(Color(0xFF2A2E38), Color(0xFF1D2026))),
),
contentAlignment = Alignment.Center,
) {
Text(
text = item.Name?.take(1)?.uppercase() ?: "?",
color = Color(0xFF555555),
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
}
}
}
/** URL постера из item (null — если у элемента нет Primary-картинки). */
fun posterUrlOrNull(app: pw.binom.viewmate.phone.PhoneApp, item: JellyfinItem): String? =
if (item.ImageTags?.containsKey("Primary") == true) app.jellyfin.posterUrl(item.Id) else null
/** Длительность из RunTimeTicks → «2ч 46м». */
fun ticksToDuration(ticks: Long?): String? {
if (ticks == null || ticks <= 0) return null
val totalSec = ticks / 10_000_000
val h = totalSec / 3600
val m = (totalSec % 3600) / 60
return if (h > 0) "${h}ч ${m}м" else "${m}м"
}
/** ms → «12:34» / «1:02:03». */
fun msToClock(ms: Long): String {
val totalSec = ms / 1000
val h = totalSec / 3600
val m = (totalSec % 3600) / 60
val s = totalSec % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s)
}
/** Заглушка-подложка скрина (loading / error / пусто). */
@Composable
fun ScreenPlaceholder(text: String, modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize().padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = PhoneMuted,
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
}
}
/** Маленький чип статуса (✓ скачано / ↓ качается… N% / нет). */
@Composable
fun StatusChip(text: String, bg: Color, fg: Color) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(bg)
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
text = text,
color = fg,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/** Строка статуса «левая метка + чип справа» (для секции скачивания). */
@Composable
fun StatusLine(label: String, chip: @Composable () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
modifier = Modifier.weight(1f),
color = PhoneTextDim,
fontSize = 11.sp,
)
chip()
}
}
/** Карточка-статус из макета (фон PhoneCard, скругление 10). */
@Composable
fun StatusBlock(content: @Composable () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(PhoneCard)
.padding(12.dp),
) {
content()
}
}
/** Кнопка-«чип» в пульте. */
@Composable
fun RemoteButton(
text: String,
big: Boolean = false,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val bg = if (big) PhoneAccent else PhoneSurface
val fg = if (big) PhoneBackground else PhoneTextDim
Box(
modifier = modifier
.height(if (big) 56.dp else 44.dp)
.clip(RoundedCornerShape(10.dp))
.background(bg)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = fg,
fontSize = 11.sp,
fontWeight = if (big) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/** Полоса прогресса из макета. */
@Composable
fun ProgressBar(percent: Int, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(PhoneLine),
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(percent.coerceIn(0, 100) / 100f)
.background(PhoneAccent),
)
}
}
@Composable
fun SectionLabel(text: String) {
Text(
text = text,
color = PhoneMuted,
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.5.sp,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(bottom = 6.dp),
)
}
@@ -0,0 +1,418 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.media.MirrorStatus
import pw.binom.viewmate.phone.DownloadPhase
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.PhoneConfig
import pw.binom.viewmate.phone.S3Presigner
/** Экран 2 — Детали элемента: озвучка, статус конвертации, скачивание, кнопки. */
@Composable
fun DetailsScreen(
item: JellyfinItem,
onBack: () -> Unit,
) {
val app = PhoneApp.instance
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
var mirrorStatus by remember(item.Id) { mutableStateOf<MirrorStatus?>(null) }
var mirrorJob by remember(item.Id) { mutableStateOf<MirrorJob?>(null) }
var loading by remember(item.Id) { mutableStateOf(true) }
var refreshKey by remember(item.Id) { mutableStateOf(0) }
var audioIndex by remember(item.Id) { mutableStateOf(app.state.currentAudioIndex) }
val downloadProgress by app.downloadManager.downloads.collectAsState()
val downloadedMap by app.state.downloadedFlow.collectAsState()
val localIds by app.downloadManager.downloadedIds.collectAsState()
var audioDownloaded by remember(item.Id) { mutableStateOf(app.downloadManager.audioFiles(item.Id).isNotEmpty()) }
// статус зеркала + дорожки; пока конвертируется — опрашиваем
LaunchedEffect(item.Id, refreshKey) {
loading = true
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
loading = false
while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") {
delay(3_000)
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
}
}
// файлы озвучки появляются после завершения загрузки на телефон
LaunchedEffect(item.Id, downloadProgress) {
audioDownloaded = app.downloadManager.audioFiles(item.Id).isNotEmpty()
}
val files = mirrorStatus?.files
val audios = files?.audios.orEmpty()
val effectiveAudioIndex = if (audios.any { it.index == audioIndex }) audioIndex else audios.firstOrNull()?.index ?: 0
val download = downloadProgress[item.Id]
val downloading = download?.phase == DownloadPhase.DOWNLOADING
fun onSelectAudio(index: Int) {
audioIndex = index
app.state.currentAudioIndex = index
}
fun onWatch() {
scope.launch {
if (app.server.hub.connected.value == 0) {
snackbar.showSnackbar("очки не подключены")
return@launch
}
val actions = app.actions
if (actions == null) {
snackbar.showSnackbar("телефон ещё не готов")
return@launch
}
val result = actions.watch(item.Id, effectiveAudioIndex) {
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
}
if (!result.contains("включил")) snackbar.showSnackbar(result)
}
}
fun onDownloadToggle() {
val dm = app.downloadManager
if (downloading) dm.cancel(item.Id)
else dm.startDownload(item.Id, effectiveAudioIndex)
}
fun onDeleteDownloaded() {
app.downloadManager.delete(item.Id)
app.actions?.deleteDownloaded(item.Id)
}
fun onOrderMirror() {
scope.launch {
runCatching { app.mirror.createMirror(item.Id) }
.onSuccess { refreshKey++ }
.onFailure { e -> snackbar.showSnackbar("ошибка заказа: ${e.message}") }
}
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = 16.dp),
) {
// ← назад
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp)) {
Text(
text = "← назад",
color = PhoneAccent,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onBack)
.padding(6.dp),
)
}
PosterImage(
item = item,
posterUrl = posterUrlOrNull(app, item),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.height(150.dp)
.clip(RoundedCornerShape(12.dp)),
)
Text(
text = item.Name ?: item.Id,
color = PhoneText,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
Text(
text = detailMeta(item),
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(horizontal = 14.dp),
)
item.Overview?.takeIf { it.isNotBlank() }?.let { overview ->
Text(
text = overview,
color = PhoneTextDim,
fontSize = 11.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
}
// 1 · выбор озвучки
if (audios.isNotEmpty()) {
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
SectionLabel("выбор озвучки")
audios.forEach { audio ->
val selected = audio.index == effectiveAudioIndex
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(8.dp))
.background(PhoneCard)
.clickable { onSelectAudio(audio.index) }
.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(if (selected) PhoneAccent else Color.Transparent)
.padding(2.dp),
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(if (selected) PhoneAccent else PhoneSurface),
)
}
Text(
text = audio.title ?: "Дорожка ${audio.index}",
color = if (selected) PhoneText else PhoneTextDim,
fontSize = 11.sp,
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
Text(
text = audio.language ?: "",
color = PhoneMuted,
fontSize = 11.sp,
)
}
}
}
}
// 2 · статус конвертации
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
SectionLabel("статус конвертации")
ConversionStatus(
loading = loading,
status = mirrorStatus,
job = mirrorJob,
onOrder = ::onOrderMirror,
)
}
// 3 · скачивание на устройства
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
SectionLabel("скачивание на устройства")
StatusBlock {
val onGlasses = downloadedMap[item.Id] == "очки"
val onPhoneVideo = localIds.contains(item.Id) || download?.phase == DownloadPhase.DONE
val onPhoneAudio = audioDownloaded || download?.phase == DownloadPhase.DONE
StatusLine("🕶️ Очки — видео") {
DeviceChip(downloaded = onGlasses, downloading = false, percent = 0)
}
StatusLine("🕶️ Очки — звук") {
DeviceChip(downloaded = onGlasses, downloading = false, percent = 0)
}
StatusLine("📱 Телефон — видео") {
DeviceChip(
downloaded = onPhoneVideo,
downloading = downloading && download?.stage == "видео",
percent = download?.percent ?: 0,
)
}
StatusLine("📱 Телефон — звук") {
DeviceChip(
downloaded = onPhoneAudio,
downloading = downloading && download?.stage == "звук",
percent = download?.percent ?: 0,
)
}
Spacer(modifier = Modifier.height(6.dp))
OutlinedButton(
onClick = ::onDeleteDownloaded,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = PhoneDangerBg,
contentColor = PhoneRedText,
),
shape = RoundedCornerShape(10.dp),
) {
Text("Удалить скачанное (очки + телефон)", fontSize = 11.sp)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// главные действия
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
Button(
onClick = ::onWatch,
modifier = Modifier.fillMaxWidth().height(44.dp),
colors = ButtonDefaults.buttonColors(containerColor = PhoneAccent, contentColor = PhoneBackground),
shape = RoundedCornerShape(10.dp),
) {
Text("▶ Смотреть на очках", fontSize = 13.sp, fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(6.dp))
OutlinedButton(
onClick = ::onDownloadToggle,
modifier = Modifier.fillMaxWidth().height(44.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = PhoneSurface,
contentColor = PhoneTextDim,
),
shape = RoundedCornerShape(10.dp),
) {
Text(
if (downloading) "Отменить" else "Скачать на телефон",
fontSize = 13.sp,
)
}
}
}
SnackbarHost(
hostState = snackbar,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
@Composable
private fun DeviceChip(downloaded: Boolean, downloading: Boolean, percent: Int) {
when {
downloaded -> StatusChip("✓ скачано", PhoneGreen, PhoneGreenText)
downloading -> StatusChip("↓ качается… $percent%", PhoneYellow, PhoneBackground)
else -> StatusChip("нет", PhoneSurface, PhoneMuted)
}
}
@Composable
private fun ConversionStatus(
loading: Boolean,
status: MirrorStatus?,
job: MirrorJob?,
onOrder: () -> Unit,
) {
when {
loading -> StatusBlock { Text("проверяю зеркало…", color = PhoneMuted, fontSize = 11.sp) }
status == null -> StatusBlock {
Text("Зеркало: нет", color = PhoneTextDim, fontSize = 11.sp)
Spacer(modifier = Modifier.height(6.dp))
OutlinedButton(
onClick = onOrder,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(containerColor = PhoneSurface, contentColor = PhoneTextDim),
shape = RoundedCornerShape(10.dp),
) {
Text("Заказать конвертацию", fontSize = 11.sp)
}
}
status.status == "done" -> StatusBlock {
Text("✓ зеркало готово", color = PhoneGreenText, fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
status.status == "processing" || status.status == "queued" -> StatusBlock {
val percent = job?.progress ?: 0
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Конвертация зеркала",
color = PhoneTextDim,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
)
Text(text = "⟳ $percent%", color = PhoneYellow, fontSize = 11.sp)
}
Spacer(modifier = Modifier.height(4.dp))
ProgressBar(percent = percent, modifier = Modifier.fillMaxWidth())
}
status.status == "failed" || status.status == "error" -> StatusBlock {
Text("конвертация упала", color = PhoneRedText, fontSize = 11.sp)
Spacer(modifier = Modifier.height(6.dp))
OutlinedButton(
onClick = onOrder,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(containerColor = PhoneSurface, contentColor = PhoneTextDim),
shape = RoundedCornerShape(10.dp),
) {
Text("Перезаказать", fontSize = 11.sp)
}
}
else -> StatusBlock { Text("статус: ${status.status}", color = PhoneMuted, fontSize = 11.sp) }
}
}
private fun detailMeta(item: JellyfinItem): String {
val parts = mutableListOf<String>()
when (item.Type) {
"Episode" -> {
// S01E05 · Название сериала
val s = item.ParentIndexNumber?.let { "S%02d".format(it) } ?: ""
val e = item.IndexNumber?.let { "E%02d".format(it) } ?: ""
val label = (s + e).ifEmpty { "Эпизод" }
parts += label
item.SeriesName?.let { parts += it }
}
else -> {
parts += when (item.Type) {
"Movie" -> "Фильм"
"Series" -> "Сериал"
"Season" -> "Сезон"
"Folder", "CollectionFolder" -> "Папка"
else -> item.Type ?: ""
}
}
}
ticksToDuration(item.RunTimeTicks)?.let { parts += it }
return parts.filter { it.isNotBlank() }.joinToString(" · ")
}
@@ -0,0 +1,317 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.background
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.phone.PhoneApp
/** Экран 3 — Статус очков + пульт. */
@Composable
fun GlassesScreen() {
val app = PhoneApp.instance
val scope = rememberCoroutineScope()
val connected by app.server.hub.connected.collectAsState()
val battery by app.server.hub.batteryPercent.collectAsState()
val storageUsed by app.server.hub.storageUsedGb.collectAsState()
val storageTotal by app.server.hub.storageTotalGb.collectAsState()
val positionMs by app.server.hub.positionMs.collectAsState()
val currentItem by app.state.currentItemFlow.collectAsState()
val playing by app.state.playingFlow.collectAsState()
val mode by app.state.modeFlow.collectAsState()
val downloaded by app.state.downloadedFlow.collectAsState()
var showDownloads by remember { mutableStateOf(false) }
val actions = app.actions
fun sendCommand(cmd: MediaCommand, seekMs: Long = 0) {
val a = actions ?: return
if (connected == 0) return
scope.launch { a.applyMediaCommand(cmd, seekMs) }
}
fun applyMode(target: GlassesMode) {
val a = actions ?: return
if (connected == 0) return
scope.launch { a.applyMode(target) }
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = 16.dp),
) {
GlassesStatusCard(
connected = connected > 0,
battery = battery,
storageUsed = storageUsed,
storageTotal = storageTotal,
currentItemName = currentItem?.Name,
positionMs = positionMs,
durationMs = currentItem?.RunTimeTicks?.div(10_000),
playing = playing,
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton("⏪ −30с") { sendCommand(MediaCommand.SEEK_BACKWARD, 30_000) }
RemoteButton("▶ Плей") { sendCommand(MediaCommand.PLAY) }
RemoteButton("🔀 Режим: Кино") { applyMode(GlassesMode.MOVIE) }
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton("⏩ +30с") { sendCommand(MediaCommand.SEEK_FORWARD, 30_000) }
RemoteButton("⏸ Пауза") { sendCommand(MediaCommand.PAUSE) }
RemoteButton("💬 Режим: Чат") { applyMode(GlassesMode.CHAT) }
}
}
Spacer(modifier = Modifier.height(12.dp))
RemoteButton(
text = "Скачанное на очках →",
big = true,
onClick = { showDownloads = true },
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
)
Text(
text = "Текущий режим очков: ${if (mode == GlassesMode.MOVIE) "Кино" else "Чат"}",
color = PhoneMuted,
fontSize = 10.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
if (showDownloads) {
GlassesDownloadsDialog(
app = app,
onDismiss = { showDownloads = false },
)
}
}
@Composable
private fun GlassesStatusCard(
connected: Boolean,
battery: Int?,
storageUsed: Double?,
storageTotal: Double?,
currentItemName: String?,
positionMs: Long?,
durationMs: Long?,
playing: Boolean,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 10.dp)
.clip(RoundedCornerShape(12.dp))
.background(PhoneCard)
.padding(12.dp),
) {
// связь
OcuRow(icon = "🕶️") {
Column(modifier = Modifier.weight(1f)) {
Text("Связь с очками", color = PhoneMuted, fontSize = 11.sp)
Text(
if (connected) "подключено (WiFi)" else "не подключено",
color = PhoneText,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
}
StatusChip(
text = "●",
bg = if (connected) PhoneGreen else Color(0xFF555555),
fg = if (connected) PhoneGreenText else PhoneTextDim,
)
}
// батарея
OcuRow(icon = "🔋") {
Column(modifier = Modifier.weight(1f)) {
Text("Батарея очков", color = PhoneMuted, fontSize = 11.sp)
Text("${battery ?: "--"}%", color = PhoneText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
}
Box(
modifier = Modifier
.width(70.dp)
.height(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(PhoneLine),
) {
Box(
modifier = Modifier
.fillMaxWidth((battery ?: 0).coerceIn(0, 100) / 100f)
.height(8.dp)
.background(PhoneGreen),
)
}
}
// память
OcuRow(icon = "💾") {
val used = storageUsed
val total = storageTotal
Column(modifier = Modifier.weight(1f)) {
Text("Память очков", color = PhoneMuted, fontSize = 11.sp)
Text(
if (used != null && total != null)
"%.1f ГБ из %.1f ГБ занято".format(used, total)
else "—",
color = PhoneText,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
}
Box(
modifier = Modifier
.width(70.dp)
.height(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(PhoneLine),
) {
val fill = if (used != null && total != null && total > 0) (used / total).toFloat() else 0f
Box(
modifier = Modifier
.fillMaxWidth(fill.coerceIn(0f, 1f))
.height(8.dp)
.background(PhoneRed),
)
}
}
// сейчас на очках
OcuRow(icon = "🎬") {
Column(modifier = Modifier.weight(1f)) {
Text("Сейчас на очках", color = PhoneMuted, fontSize = 11.sp)
val pos = positionMs?.let { msToClock(it) } ?: "--:--"
val dur = durationMs?.let { msToClock(it) } ?: "--:--"
val name = currentItemName ?: "—"
Text("$name · $pos / $dur", color = PhoneText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
}
StatusChip(
text = if (playing) "плей" else "пауза",
bg = if (playing) PhoneBlue else PhoneSurface,
fg = if (playing) PhoneBlueText else PhoneMuted,
)
}
}
}
@Composable
private fun OcuRow(icon: String, content: @Composable () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(34.dp)
.clip(RoundedCornerShape(10.dp))
.background(PhoneSurface),
contentAlignment = Alignment.Center,
) {
Text(icon, fontSize = 16.sp)
}
Spacer(modifier = Modifier.width(10.dp))
content()
}
}
/** Диалог: список скачанного на очках (downloaded → «очки») с удалением. */
@Composable
private fun GlassesDownloadsDialog(
app: PhoneApp,
onDismiss: () -> Unit,
) {
val downloaded by app.state.downloadedFlow.collectAsState()
val glassesIds = downloaded.filterValues { it == "очки" }.keys.toList()
var names by remember(glassesIds) { mutableStateOf<Map<String, String>>(emptyMap()) }
LaunchedEffect(glassesIds) {
val userId = app.userId.value
if (userId.isBlank()) return@LaunchedEffect
names = glassesIds.associateWith { id ->
runCatching { app.jellyfin.item(userId, id)?.Name ?: id }.getOrDefault(id)
}
}
AlertDialog(
onDismissRequest = onDismiss,
containerColor = PhoneCard,
title = { Text("Скачанное на очках", color = PhoneText, fontSize = 15.sp) },
text = {
if (glassesIds.isEmpty()) {
Text("ничего", color = PhoneMuted, fontSize = 12.sp)
} else {
Column {
glassesIds.forEach { id ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = names[id] ?: id,
color = PhoneTextDim,
fontSize = 12.sp,
modifier = Modifier.weight(1f),
)
TextButton(onClick = {
app.actions?.deleteDownloaded(id)
}) {
Text("удалить", color = PhoneRedText, fontSize = 11.sp)
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("закрыть", color = PhoneAccent, fontSize = 12.sp)
}
},
)
}
@@ -0,0 +1,51 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Цвета из макета sketches/phone-ui
val PhoneBackground = Color(0xFF1B1D22)
val PhoneCard = Color(0xFF181A1F)
val PhoneSurface = Color(0xFF23262E)
val PhoneAccent = Color(0xFFFFB454)
val PhoneText = Color(0xFFE8E8E8)
val PhoneTextDim = Color(0xFFDDDDDD)
val PhoneMuted = Color(0xFF888888)
val PhoneLine = Color(0xFF2A2D35)
val PhoneGreen = Color(0xFF2E7D32)
val PhoneGreenText = Color(0xFFC8FFC8)
val PhoneYellow = Color(0xFFF9A825)
val PhoneBlue = Color(0xFF0277BD)
val PhoneBlueText = Color(0xFFD3F0FF)
val PhoneRed = Color(0xFFE53935)
val PhoneRedText = Color(0xFFFF8A93)
val PhoneDangerBg = Color(0xFF4A1F24)
private val ViewMateColorScheme = darkColorScheme(
primary = PhoneAccent,
onPrimary = PhoneBackground,
secondary = PhoneAccent,
onSecondary = PhoneBackground,
background = PhoneBackground,
onBackground = PhoneText,
surface = PhoneCard,
onSurface = PhoneText,
surfaceVariant = PhoneSurface,
onSurfaceVariant = PhoneMuted,
outline = PhoneLine,
outlineVariant = PhoneLine,
error = PhoneRed,
onError = Color.White,
primaryContainer = PhoneSurface,
onPrimaryContainer = PhoneText,
)
@Composable
fun PhoneTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = ViewMateColorScheme,
content = content,
)
}
@@ -0,0 +1,71 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.phone.PhoneApp
/**
* Корень UI телефона: простая навигация между экранами (без nav-библиотеки)
* + нижняя панель Каталог / Очки / Чат. Экран деталей — поверх (кнопка назад).
*/
@Composable
fun ViewMateApp() {
val app = PhoneApp.instance
val scope = rememberCoroutineScope()
val catalog = remember { CatalogState(app, scope) }
var screen by rememberSaveable { mutableStateOf(Screen.CATALOG) }
var selected by remember { mutableStateOf<JellyfinItem?>(null) }
PhoneTheme {
Scaffold(
containerColor = PhoneBackground,
bottomBar = {
if (screen != Screen.DETAILS) {
BottomNavBar(current = screen) { screen = it }
}
},
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
when (screen) {
Screen.CATALOG -> CatalogScreen(
state = catalog,
onItemClick = {
selected = it
screen = Screen.DETAILS
},
)
Screen.DETAILS -> {
val item = selected
if (item != null) {
DetailsScreen(item = item, onBack = { screen = Screen.CATALOG })
} else {
CatalogScreen(
state = catalog,
onItemClick = {
selected = it
screen = Screen.DETAILS
},
)
}
}
Screen.GLASSES -> GlassesScreen()
Screen.CHAT -> ChatScreen()
}
}
}
}
}
@@ -0,0 +1,85 @@
package pw.binom.viewmate.phone
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class S3PresignerTest {
private val url = "https://s3.binom.pw/media/mirror/m1/video.mkv"
private val accessKey = "MirrorClient01QwertyKey"
private val secretKey = "MirrorClient01SecretKeyValueForGlassesOnly"
private val region = "us-east-1"
private val expires = 3600
private val amzDate = "20260819T000000Z"
@Test
fun presignContainsAllXAmzParamsInAlphabeticalOrder() {
val signed = S3Presigner.presignGetAt(url, accessKey, secretKey, region, expires, amzDate)
assertTrue(signed.startsWith("$url?"), "presigned url должен начинаться с исходного url: $signed")
val query = signed.substringAfter('?')
assertTrue(query.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"))
assertTrue(query.contains("X-Amz-Credential=$accessKey%2F20260819%2F$region%2Fs3%2Faws4_request"))
assertTrue(query.contains("X-Amz-Date=$amzDate"))
assertTrue(query.contains("X-Amz-Expires=$expires"))
assertTrue(query.contains("X-Amz-SignedHeaders=host"))
assertTrue(query.contains("X-Amz-Signature="))
assertFalse(query.contains("&X-Amz-Expires=0"))
}
@Test
fun signatureMatchesReference() {
// эталон получен той же логикой (SigV4 presign GET, UNSIGNED-PAYLOAD, region us-east-1,
// service s3, expires=3600, amzDate=20260819T000000Z) из Python-реализации, 19.08
val signed = S3Presigner.presignGetAt(url, accessKey, secretKey, region, expires, amzDate)
assertEquals(
"https://s3.binom.pw/media/mirror/m1/video.mkv" +
"?X-Amz-Algorithm=AWS4-HMAC-SHA256" +
"&X-Amz-Credential=MirrorClient01QwertyKey%2F20260819%2Fus-east-1%2Fs3%2Faws4_request" +
"&X-Amz-Date=20260819T000000Z" +
"&X-Amz-Expires=3600" +
"&X-Amz-SignedHeaders=host" +
"&X-Amz-Signature=c023bf37aabfbc224641090bdb4082fdf54033ca3a6ef07680b00b560c7b140f",
signed,
)
}
@Test
fun signatureChangesWithTimeExpiresAndKey() {
val base = S3Presigner.presignGetAt(url, accessKey, secretKey, region, expires, amzDate)
val sigBase = base.substringAfter("X-Amz-Signature=")
val otherTime = S3Presigner.presignGetAt(url, accessKey, secretKey, region, expires, "20260820T000000Z")
assertFalse(otherTime.contains("X-Amz-Date=$amzDate"), "время должно попадать в подпись")
val otherExpires = S3Presigner.presignGetAt(url, accessKey, secretKey, region, 120, amzDate)
assertTrue(otherExpires.contains("X-Amz-Expires=120"))
val otherKey = S3Presigner.presignGetAt(url, "OtherAccess", secretKey, region, expires, amzDate)
assertFalse(
otherKey.substringAfter("X-Amz-Signature=") == sigBase,
"подпись должна зависеть от access key",
)
}
@Test
fun presignGetProducesConsistentNowUrl() {
val signed = S3Presigner.presignGet(url, accessKey, secretKey, region, expires)
assertTrue(signed.startsWith("$url?"))
val query = signed.substringAfter('?')
assertTrue(query.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"))
assertTrue(query.contains("X-Amz-Credential="))
assertTrue(query.contains("X-Amz-SignedHeaders=host"))
assertTrue(query.contains("X-Amz-Expires=$expires"))
val amzDateNow = query.substringAfter("X-Amz-Date=").substringBefore("&X-Amz-Expires")
assertTrue(
Regex("^\\d{8}T\\d{6}Z$").matches(amzDateNow),
"X-Amz-Date должен быть в формате yyyyMMdd'T'HHmmss'Z': $amzDateNow",
)
assertTrue(query.substringAfter("X-Amz-Signature=").let { it.length == 64 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } })
}
}
@@ -31,6 +31,13 @@ class JellyfinClient(
recursive = false,
)
/** Корень каталога с явным фильтром типов (например, только папки/библиотеки). */
suspend fun rootItems(userId: String, types: List<String>): List<JellyfinItem> = requestItems(
userId = userId,
recursive = false,
types = types,
)
/** Содержимое папки. */
suspend fun items(userId: String, parentId: String): List<JellyfinItem> = requestItems(
userId = userId,
@@ -38,6 +45,14 @@ class JellyfinClient(
recursive = false,
)
/** Содержимое папки с явным фильтром типов (например, Season/Episode для сериалов). */
suspend fun items(userId: String, parentId: String, types: List<String>): List<JellyfinItem> = requestItems(
userId = userId,
parentId = parentId,
recursive = false,
types = types,
)
/** Один элемент по id; null если не найден (404). */
suspend fun item(userId: String, itemId: String): JellyfinItem? {
val response = http.get("$baseUrl/Users/$userId/Items/$itemId") {
@@ -84,7 +84,7 @@ class PhoneActions(
// --- включить на очках ---
/** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */
suspend fun watch(itemId: String, audioIndex: Int = 0): String {
suspend fun watch(itemId: String, audioIndex: Int = 0, presign: (String) -> String = { it }): String {
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId)
val files = status?.files
@@ -93,13 +93,15 @@ class PhoneActions(
}
val title = item.Name ?: itemId
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = files.video.url, audioIndex = audioIndex))
val videoUrl = presign(files.video.url)
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = videoUrl, audioIndex = audioIndex))
state.mode = GlassesMode.MOVIE
state.currentItem = item
state.currentAudioIndex = audioIndex
state.mirrorStatus = status
state.playing = true
return "включил «$title» на очках (озвучка #$audioIndex): ${files.video.url}"
state.downloaded[itemId] = "очки"
return "включил «$title» на очках (озвучка #$audioIndex): $videoUrl"
}
// --- зеркала ---
@@ -1,5 +1,8 @@
package pw.binom.viewmate.core.phone
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.media.JellyfinItem
@@ -13,49 +16,61 @@ data class SessionInfo(
/**
* Единый стейт «телефона» (как у будущего приложения).
* Изменение любого поля логируется в консоль.
* Изменение любого поля логируется в консоль; поля наблюдаемы через *Flow.
*/
class PhoneState {
@Volatile
var mode: GlassesMode = GlassesMode.MOVIE
private val _mode = MutableStateFlow(GlassesMode.MOVIE)
val modeFlow: StateFlow<GlassesMode> = _mode.asStateFlow()
var mode: GlassesMode
get() = _mode.value
set(value) {
field = value
_mode.value = value
log("state", "режим → $value")
}
@Volatile
var currentItem: JellyfinItem? = null
private val _currentItem = MutableStateFlow<JellyfinItem?>(null)
val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow()
var currentItem: JellyfinItem?
get() = _currentItem.value
set(value) {
field = value
_currentItem.value = value
log("state", "текущий → ${value?.Name ?: value?.Id ?: "ничего"}")
}
@Volatile
var currentAudioIndex: Int = 0
private val _currentAudioIndex = MutableStateFlow(0)
val currentAudioIndexFlow: StateFlow<Int> = _currentAudioIndex.asStateFlow()
var currentAudioIndex: Int
get() = _currentAudioIndex.value
set(value) {
field = value
_currentAudioIndex.value = value
log("state", "озвучка → #$value")
}
@Volatile
var mirrorStatus: MirrorStatus? = null
private val _mirrorStatus = MutableStateFlow<MirrorStatus?>(null)
val mirrorStatusFlow: StateFlow<MirrorStatus?> = _mirrorStatus.asStateFlow()
var mirrorStatus: MirrorStatus?
get() = _mirrorStatus.value
set(value) {
field = value
_mirrorStatus.value = value
log("state", "зеркало → ${value?.status ?: "нет"}")
}
@Volatile
var activeSessionId: String? = null
private val _activeSessionId = MutableStateFlow<String?>(null)
val activeSessionIdFlow: StateFlow<String?> = _activeSessionId.asStateFlow()
var activeSessionId: String?
get() = _activeSessionId.value
set(value) {
field = value
_activeSessionId.value = value
log("state", "сессия → $value")
}
@Volatile
var playing: Boolean = false
private val _playing = MutableStateFlow(false)
val playingFlow: StateFlow<Boolean> = _playing.asStateFlow()
var playing: Boolean
get() = _playing.value
set(value) {
field = value
_playing.value = value
log("state", if (value) "воспроизведение" else "пауза")
}
@@ -66,6 +81,48 @@ class PhoneState {
SessionInfo("general", "Общее"),
)
/** Эмуляция скачанного: itemId → куда (очки|телефон). */
val downloaded: MutableMap<String, String> = mutableMapOf()
private val downloadedImpl = LinkedHashMap<String, String>()
private val _downloaded = MutableStateFlow<Map<String, String>>(emptyMap())
/** Наблюдаемый снимок [downloaded]. */
val downloadedFlow: StateFlow<Map<String, String>> = _downloaded.asStateFlow()
/** Эмуляция скачанного: itemId → куда (очки|телефон). Наблюдается через [downloadedFlow]. */
val downloaded: MutableMap<String, String> = object : MutableMap<String, String> {
private val delegate = downloadedImpl
override val size: Int get() = delegate.size
override fun isEmpty(): Boolean = delegate.isEmpty()
override fun containsKey(key: String): Boolean = delegate.containsKey(key)
override fun containsValue(value: String): Boolean = delegate.containsValue(value)
override fun get(key: String): String? = delegate[key]
override val keys: MutableSet<String> get() = delegate.keys
override val values: MutableCollection<String> get() = delegate.values
override val entries: MutableSet<MutableMap.MutableEntry<String, String>> get() = delegate.entries
override fun put(key: String, value: String): String? {
val prev = delegate.put(key, value)
emitDownloaded()
return prev
}
override fun remove(key: String): String? {
val prev = delegate.remove(key)
emitDownloaded()
return prev
}
override fun putAll(from: Map<out String, String>) {
delegate.putAll(from)
emitDownloaded()
}
override fun clear() {
delegate.clear()
emitDownloaded()
}
}
private fun emitDownloaded() {
_downloaded.value = LinkedHashMap(downloadedImpl)
}
}