From 6069400b885e1988ac7b706f4f7b27cef9c71915 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 19 Aug 2026 09:13:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(app-phone):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=BE=D1=86=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20UI=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D1=84=D0=BE=D0=BD=D0=B0=20=E2=80=94=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20Jellyfin,=20=D0=B4=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8,=20=D0=BF=D1=83=D0=BB=D1=8C=D1=82=20=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=BE=D0=B2,=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BA=D0=B0=D1=87=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=81=20S3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- TESTING.md | 84 ++++ app-phone/build.gradle.kts | 4 + .../binom/viewmate/phone/DownloadManager.kt | 169 +++++++ .../pw/binom/viewmate/phone/GlassesServer.kt | 17 + .../pw/binom/viewmate/phone/MainActivity.kt | 33 +- .../pw/binom/viewmate/phone/PhoneApp.kt | 36 +- .../pw/binom/viewmate/phone/PhoneConfig.kt | 5 + .../pw/binom/viewmate/phone/S3Presigner.kt | 105 +++++ .../binom/viewmate/phone/ui/CatalogScreen.kt | 370 ++++++++++++++++ .../pw/binom/viewmate/phone/ui/ChatScreen.kt | 35 ++ .../pw/binom/viewmate/phone/ui/Components.kt | 291 ++++++++++++ .../binom/viewmate/phone/ui/DetailsScreen.kt | 418 ++++++++++++++++++ .../binom/viewmate/phone/ui/GlassesScreen.kt | 317 +++++++++++++ .../pw/binom/viewmate/phone/ui/PhoneTheme.kt | 51 +++ .../pw/binom/viewmate/phone/ui/ViewMateApp.kt | 71 +++ .../binom/viewmate/phone/S3PresignerTest.kt | 85 ++++ .../viewmate/core/media/JellyfinClient.kt | 15 + .../binom/viewmate/core/phone/PhoneActions.kt | 8 +- .../binom/viewmate/core/phone/PhoneState.kt | 99 ++++- 19 files changed, 2145 insertions(+), 68 deletions(-) create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/S3Presigner.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/PhoneTheme.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ViewMateApp.kt create mode 100644 app-phone/src/test/kotlin/pw/binom/viewmate/phone/S3PresignerTest.kt diff --git a/TESTING.md b/TESTING.md index 533f297..8bd2cad 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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): » +- На экране кадр фильма (из зеркала 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, чипы «скачано». Удаление через «Удалить скачанное». diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts index 321613d..0122122 100644 --- a/app-phone/build.gradle.kts +++ b/app-phone/build.gradle.kts @@ -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) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt new file mode 100644 index 0000000..378f3af --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt @@ -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() + private val okHttp = OkHttpClient.Builder() + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(0, java.util.concurrent.TimeUnit.MILLISECONDS) // длинные файлы, паузы в потоке + .build() + + private val _downloads = MutableStateFlow>(emptyMap()) + val downloads: StateFlow> = _downloads.asStateFlow() + + private val _downloadedIds = MutableStateFlow(scanExistingDownloads()) + /** itemId, у которых видео уже скачано на телефон (переживает рестарт приложения). */ + val downloadedIds: StateFlow> = _downloadedIds.asStateFlow() + + private fun scanExistingDownloads(): Set = + 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 = + 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) + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt index dc685f9..30cbf98 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -49,6 +49,18 @@ class GlassesHub( private val _gesturesReceived = MutableStateFlow(0) val gesturesReceived: StateFlow = _gesturesReceived.asStateFlow() + private val _batteryPercent = MutableStateFlow(null) + val batteryPercent: StateFlow = _batteryPercent.asStateFlow() + + private val _storageUsedGb = MutableStateFlow(null) + val storageUsedGb: StateFlow = _storageUsedGb.asStateFlow() + + private val _storageTotalGb = MutableStateFlow(null) + val storageTotalGb: StateFlow = _storageTotalGb.asStateFlow() + + private val _positionMs = MutableStateFlow(null) + val positionMs: StateFlow = _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") } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt index f62d43d..6be50e3 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt @@ -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") - } -} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index a844e8f..90e7183 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -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 = _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, diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt index ef49cd0..c0add91 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt @@ -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" } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/S3Presigner.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/S3Presigner.kt new file mode 100644 index 0000000..213cd12 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/S3Presigner.kt @@ -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) } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt new file mode 100644 index 0000000..7ddb5ae --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt @@ -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>(emptyList()) + private set + var loading by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + var mirrors by mutableStateOf>(emptyMap()) + private set + var path by mutableStateOf(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), + ) + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt new file mode 100644 index 0000000..eaff399 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt @@ -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), + ) + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt new file mode 100644 index 0000000..e946363 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt @@ -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, + localDownloaded: Set, +): 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), + ) +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt new file mode 100644 index 0000000..4d6706d --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt @@ -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(null) } + var mirrorJob by remember(item.Id) { mutableStateOf(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() + 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(" · ") +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt new file mode 100644 index 0000000..10d84a5 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt @@ -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>(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) + } + }, + ) +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/PhoneTheme.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/PhoneTheme.kt new file mode 100644 index 0000000..58daaf8 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/PhoneTheme.kt @@ -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, + ) +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ViewMateApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ViewMateApp.kt new file mode 100644 index 0000000..4d266a3 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ViewMateApp.kt @@ -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(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() + } + } + } + } +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/S3PresignerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/S3PresignerTest.kt new file mode 100644 index 0000000..a2b7de6 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/S3PresignerTest.kt @@ -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' } }) + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt index 17c3a95..ab581a7 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt @@ -31,6 +31,13 @@ class JellyfinClient( recursive = false, ) + /** Корень каталога с явным фильтром типов (например, только папки/библиотеки). */ + suspend fun rootItems(userId: String, types: List): List = requestItems( + userId = userId, + recursive = false, + types = types, + ) + /** Содержимое папки. */ suspend fun items(userId: String, parentId: String): List = requestItems( userId = userId, @@ -38,6 +45,14 @@ class JellyfinClient( recursive = false, ) + /** Содержимое папки с явным фильтром типов (например, Season/Episode для сериалов). */ + suspend fun items(userId: String, parentId: String, types: List): List = 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") { diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt index 1aa11eb..9dcc0b2 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt @@ -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" } // --- зеркала --- diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt index 5aaf803..7460231 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt @@ -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 = _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(null) + val currentItemFlow: StateFlow = _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 = _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(null) + val mirrorStatusFlow: StateFlow = _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(null) + val activeSessionIdFlow: StateFlow = _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 = _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 = mutableMapOf() + private val downloadedImpl = LinkedHashMap() + private val _downloaded = MutableStateFlow>(emptyMap()) + /** Наблюдаемый снимок [downloaded]. */ + val downloadedFlow: StateFlow> = _downloaded.asStateFlow() + + /** Эмуляция скачанного: itemId → куда (очки|телефон). Наблюдается через [downloadedFlow]. */ + val downloaded: MutableMap = object : MutableMap { + 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 get() = delegate.keys + override val values: MutableCollection get() = delegate.values + override val entries: MutableSet> 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) { + delegate.putAll(from) + emitDownloaded() + } + + override fun clear() { + delegate.clear() + emitDownloaded() + } + } + + private fun emitDownloaded() { + _downloaded.value = LinkedHashMap(downloadedImpl) + } }