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:
+84
@@ -187,3 +187,87 @@ APK `pw.binom.viewmate.glasses` установлен.
|
|||||||
**Ожидаемо:**
|
**Ожидаемо:**
|
||||||
- В логе периодически (раз в ~5 секунд): `glasses статус: батарея 80%, память 10.5/32.0 ГБ`
|
- В логе периодически (раз в ~5 секунд): `glasses статус: батарея 80%, память 10.5/32.0 ГБ`
|
||||||
- И `glasses позиция: NNN мс, играет/пауза` с реально растущей позицией во время воспроизведения
|
- И `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, чипы «скачано». Удаление через «Удалить скачанное».
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ dependencies {
|
|||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.android.material3)
|
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.exoplayer)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
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)
|
private val _gesturesReceived = MutableStateFlow(0)
|
||||||
val gesturesReceived: StateFlow<Int> = _gesturesReceived.asStateFlow()
|
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) {
|
fun add(session: DefaultWebSocketServerSession) {
|
||||||
sessions[session] = Unit
|
sessions[session] = Unit
|
||||||
_connected.value = sessions.size
|
_connected.value = sessions.size
|
||||||
@@ -88,10 +100,15 @@ class GlassesHub(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is GlassesStatus -> {
|
is GlassesStatus -> {
|
||||||
|
_batteryPercent.value = msg.batteryPercent
|
||||||
|
_storageUsedGb.value = msg.storageUsedGb
|
||||||
|
_storageTotalGb.value = msg.storageTotalGb
|
||||||
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
|
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
|
||||||
}
|
}
|
||||||
|
|
||||||
is PlaybackPosition -> {
|
is PlaybackPosition -> {
|
||||||
|
_positionMs.value = msg.positionMs
|
||||||
|
this.state.playing = msg.playing
|
||||||
val state = if (msg.playing) "играет" else "пауза"
|
val state = if (msg.playing) "играет" else "пауза"
|
||||||
log("glasses", "позиция: ${msg.positionMs} мс, $state")
|
log("glasses", "позиция: ${msg.positionMs} мс, $state")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,42 +3,13 @@ package pw.binom.viewmate.phone
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import pw.binom.viewmate.phone.ui.ViewMateApp
|
||||||
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
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme {
|
ViewMateApp()
|
||||||
ServerStatusScreen()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.viewmate.core.media.JellyfinClient
|
import pw.binom.viewmate.core.media.JellyfinClient
|
||||||
import pw.binom.viewmate.core.media.MirrorClient
|
import pw.binom.viewmate.core.media.MirrorClient
|
||||||
@@ -12,15 +15,27 @@ import pw.binom.viewmate.core.phone.PhoneState
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
|
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
|
||||||
* WS-сервер (GlassesServer) и PhoneActions. Синглтон-доступ через companion.
|
* WS-сервер (GlassesServer), DownloadManager и PhoneActions. Синглтон-доступ через companion.
|
||||||
*/
|
*/
|
||||||
class PhoneApp : Application() {
|
class PhoneApp : Application() {
|
||||||
|
|
||||||
lateinit var state: PhoneState
|
val state: PhoneState = PhoneState()
|
||||||
private set
|
|
||||||
lateinit var server: GlassesServer
|
/** WS-сервер для очков. */
|
||||||
private set
|
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
|
||||||
lateinit var actions: PhoneActions
|
|
||||||
|
/** 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 set
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
@@ -29,18 +44,13 @@ class PhoneApp : Application() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
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()
|
server.start()
|
||||||
|
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
||||||
|
|
||||||
// первый пользователь Jellyfin — один раз при старте
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val userId = runCatching { jellyfin.users().firstOrNull()?.Id }.getOrNull()
|
val userId = runCatching { jellyfin.users().firstOrNull()?.Id }.getOrNull()
|
||||||
?: ""
|
?: ""
|
||||||
|
_userId.value = userId
|
||||||
if (userId.isBlank()) log("app", "не удалось получить пользователя Jellyfin")
|
if (userId.isBlank()) log("app", "не удалось получить пользователя Jellyfin")
|
||||||
actions = PhoneActions(
|
actions = PhoneActions(
|
||||||
jellyfin = jellyfin,
|
jellyfin = jellyfin,
|
||||||
|
|||||||
@@ -11,4 +11,9 @@ object PhoneConfig {
|
|||||||
const val MIRROR_URL = "https://mirror.binom.pw"
|
const val MIRROR_URL = "https://mirror.binom.pw"
|
||||||
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
||||||
const val SERVER_PORT = 8080
|
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,
|
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(
|
suspend fun items(userId: String, parentId: String): List<JellyfinItem> = requestItems(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
@@ -38,6 +45,14 @@ class JellyfinClient(
|
|||||||
recursive = false,
|
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). */
|
/** Один элемент по id; null если не найден (404). */
|
||||||
suspend fun item(userId: String, itemId: String): JellyfinItem? {
|
suspend fun item(userId: String, itemId: String): JellyfinItem? {
|
||||||
val response = http.get("$baseUrl/Users/$userId/Items/$itemId") {
|
val response = http.get("$baseUrl/Users/$userId/Items/$itemId") {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class PhoneActions(
|
|||||||
// --- включить на очках ---
|
// --- включить на очках ---
|
||||||
|
|
||||||
/** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */
|
/** Включить на очках: зеркало должно быть 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 item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
|
||||||
val status = mirror.mirrorByItem(itemId)
|
val status = mirror.mirrorByItem(itemId)
|
||||||
val files = status?.files
|
val files = status?.files
|
||||||
@@ -93,13 +93,15 @@ class PhoneActions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val title = item.Name ?: itemId
|
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.mode = GlassesMode.MOVIE
|
||||||
state.currentItem = item
|
state.currentItem = item
|
||||||
state.currentAudioIndex = audioIndex
|
state.currentAudioIndex = audioIndex
|
||||||
state.mirrorStatus = status
|
state.mirrorStatus = status
|
||||||
state.playing = true
|
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
|
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.GlassesMode
|
||||||
import pw.binom.viewmate.core.log
|
import pw.binom.viewmate.core.log
|
||||||
import pw.binom.viewmate.core.media.JellyfinItem
|
import pw.binom.viewmate.core.media.JellyfinItem
|
||||||
@@ -13,49 +16,61 @@ data class SessionInfo(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Единый стейт «телефона» (как у будущего приложения).
|
* Единый стейт «телефона» (как у будущего приложения).
|
||||||
* Изменение любого поля логируется в консоль.
|
* Изменение любого поля логируется в консоль; поля наблюдаемы через *Flow.
|
||||||
*/
|
*/
|
||||||
class PhoneState {
|
class PhoneState {
|
||||||
|
|
||||||
@Volatile
|
private val _mode = MutableStateFlow(GlassesMode.MOVIE)
|
||||||
var mode: GlassesMode = GlassesMode.MOVIE
|
val modeFlow: StateFlow<GlassesMode> = _mode.asStateFlow()
|
||||||
|
var mode: GlassesMode
|
||||||
|
get() = _mode.value
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
_mode.value = value
|
||||||
log("state", "режим → $value")
|
log("state", "режим → $value")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Volatile
|
private val _currentItem = MutableStateFlow<JellyfinItem?>(null)
|
||||||
var currentItem: JellyfinItem? = null
|
val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow()
|
||||||
|
var currentItem: JellyfinItem?
|
||||||
|
get() = _currentItem.value
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
_currentItem.value = value
|
||||||
log("state", "текущий → ${value?.Name ?: value?.Id ?: "ничего"}")
|
log("state", "текущий → ${value?.Name ?: value?.Id ?: "ничего"}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Volatile
|
private val _currentAudioIndex = MutableStateFlow(0)
|
||||||
var currentAudioIndex: Int = 0
|
val currentAudioIndexFlow: StateFlow<Int> = _currentAudioIndex.asStateFlow()
|
||||||
|
var currentAudioIndex: Int
|
||||||
|
get() = _currentAudioIndex.value
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
_currentAudioIndex.value = value
|
||||||
log("state", "озвучка → #$value")
|
log("state", "озвучка → #$value")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Volatile
|
private val _mirrorStatus = MutableStateFlow<MirrorStatus?>(null)
|
||||||
var mirrorStatus: MirrorStatus? = null
|
val mirrorStatusFlow: StateFlow<MirrorStatus?> = _mirrorStatus.asStateFlow()
|
||||||
|
var mirrorStatus: MirrorStatus?
|
||||||
|
get() = _mirrorStatus.value
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
_mirrorStatus.value = value
|
||||||
log("state", "зеркало → ${value?.status ?: "нет"}")
|
log("state", "зеркало → ${value?.status ?: "нет"}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Volatile
|
private val _activeSessionId = MutableStateFlow<String?>(null)
|
||||||
var activeSessionId: String? = null
|
val activeSessionIdFlow: StateFlow<String?> = _activeSessionId.asStateFlow()
|
||||||
|
var activeSessionId: String?
|
||||||
|
get() = _activeSessionId.value
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
_activeSessionId.value = value
|
||||||
log("state", "сессия → $value")
|
log("state", "сессия → $value")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Volatile
|
private val _playing = MutableStateFlow(false)
|
||||||
var playing: Boolean = false
|
val playingFlow: StateFlow<Boolean> = _playing.asStateFlow()
|
||||||
|
var playing: Boolean
|
||||||
|
get() = _playing.value
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
_playing.value = value
|
||||||
log("state", if (value) "воспроизведение" else "пауза")
|
log("state", if (value) "воспроизведение" else "пауза")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +81,48 @@ class PhoneState {
|
|||||||
SessionInfo("general", "Общее"),
|
SessionInfo("general", "Общее"),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Эмуляция скачанного: itemId → куда (очки|телефон). */
|
private val downloadedImpl = LinkedHashMap<String, String>()
|
||||||
val downloaded: MutableMap<String, String> = mutableMapOf()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user