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

- 4 экрана по макету sketches/phone-ui: Каталог (табы Фильмы/Сериалы/Папки, поиск, бейджи зеркала), Детали (озвучки, статус конвертации, чипы скачивания), Очки (пульт, статус), Чат-заглушка
- S3 SigV4 presign (S3Presigner): mirror-client read-only креды; watch() и DownloadManager подписывают URL (бакет приватный)
- DownloadManager: реальное скачивание video+audio с прогрессом, readTimeout=0 (длинные файлы)
- PhoneActions.watch: параметр presign (app-host работает без изменений)
- TESTING.md: блок телефонных кейсов TP-1..6
This commit is contained in:
Hermes Agent
2026-08-19 09:13:56 +03:00
parent 4458678698
commit 6069400b88
19 changed files with 2145 additions and 68 deletions
@@ -31,6 +31,13 @@ class JellyfinClient(
recursive = false,
)
/** Корень каталога с явным фильтром типов (например, только папки/библиотеки). */
suspend fun rootItems(userId: String, types: List<String>): List<JellyfinItem> = requestItems(
userId = userId,
recursive = false,
types = types,
)
/** Содержимое папки. */
suspend fun items(userId: String, parentId: String): List<JellyfinItem> = requestItems(
userId = userId,
@@ -38,6 +45,14 @@ class JellyfinClient(
recursive = false,
)
/** Содержимое папки с явным фильтром типов (например, Season/Episode для сериалов). */
suspend fun items(userId: String, parentId: String, types: List<String>): List<JellyfinItem> = requestItems(
userId = userId,
parentId = parentId,
recursive = false,
types = types,
)
/** Один элемент по id; null если не найден (404). */
suspend fun item(userId: String, itemId: String): JellyfinItem? {
val response = http.get("$baseUrl/Users/$userId/Items/$itemId") {
@@ -84,7 +84,7 @@ class PhoneActions(
// --- включить на очках ---
/** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */
suspend fun watch(itemId: String, audioIndex: Int = 0): String {
suspend fun watch(itemId: String, audioIndex: Int = 0, presign: (String) -> String = { it }): String {
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId)
val files = status?.files
@@ -93,13 +93,15 @@ class PhoneActions(
}
val title = item.Name ?: itemId
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = files.video.url, audioIndex = audioIndex))
val videoUrl = presign(files.video.url)
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = videoUrl, audioIndex = audioIndex))
state.mode = GlassesMode.MOVIE
state.currentItem = item
state.currentAudioIndex = audioIndex
state.mirrorStatus = status
state.playing = true
return "включил «$title» на очках (озвучка #$audioIndex): ${files.video.url}"
state.downloaded[itemId] = "очки"
return "включил «$title» на очках (озвучка #$audioIndex): $videoUrl"
}
// --- зеркала ---
@@ -1,5 +1,8 @@
package pw.binom.viewmate.core.phone
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.media.JellyfinItem
@@ -13,49 +16,61 @@ data class SessionInfo(
/**
* Единый стейт «телефона» (как у будущего приложения).
* Изменение любого поля логируется в консоль.
* Изменение любого поля логируется в консоль; поля наблюдаемы через *Flow.
*/
class PhoneState {
@Volatile
var mode: GlassesMode = GlassesMode.MOVIE
private val _mode = MutableStateFlow(GlassesMode.MOVIE)
val modeFlow: StateFlow<GlassesMode> = _mode.asStateFlow()
var mode: GlassesMode
get() = _mode.value
set(value) {
field = value
_mode.value = value
log("state", "режим → $value")
}
@Volatile
var currentItem: JellyfinItem? = null
private val _currentItem = MutableStateFlow<JellyfinItem?>(null)
val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow()
var currentItem: JellyfinItem?
get() = _currentItem.value
set(value) {
field = value
_currentItem.value = value
log("state", "текущий → ${value?.Name ?: value?.Id ?: "ничего"}")
}
@Volatile
var currentAudioIndex: Int = 0
private val _currentAudioIndex = MutableStateFlow(0)
val currentAudioIndexFlow: StateFlow<Int> = _currentAudioIndex.asStateFlow()
var currentAudioIndex: Int
get() = _currentAudioIndex.value
set(value) {
field = value
_currentAudioIndex.value = value
log("state", "озвучка → #$value")
}
@Volatile
var mirrorStatus: MirrorStatus? = null
private val _mirrorStatus = MutableStateFlow<MirrorStatus?>(null)
val mirrorStatusFlow: StateFlow<MirrorStatus?> = _mirrorStatus.asStateFlow()
var mirrorStatus: MirrorStatus?
get() = _mirrorStatus.value
set(value) {
field = value
_mirrorStatus.value = value
log("state", "зеркало → ${value?.status ?: "нет"}")
}
@Volatile
var activeSessionId: String? = null
private val _activeSessionId = MutableStateFlow<String?>(null)
val activeSessionIdFlow: StateFlow<String?> = _activeSessionId.asStateFlow()
var activeSessionId: String?
get() = _activeSessionId.value
set(value) {
field = value
_activeSessionId.value = value
log("state", "сессия → $value")
}
@Volatile
var playing: Boolean = false
private val _playing = MutableStateFlow(false)
val playingFlow: StateFlow<Boolean> = _playing.asStateFlow()
var playing: Boolean
get() = _playing.value
set(value) {
field = value
_playing.value = value
log("state", if (value) "воспроизведение" else "пауза")
}
@@ -66,6 +81,48 @@ class PhoneState {
SessionInfo("general", "Общее"),
)
/** Эмуляция скачанного: itemId → куда (очки|телефон). */
val downloaded: MutableMap<String, String> = mutableMapOf()
private val downloadedImpl = LinkedHashMap<String, String>()
private val _downloaded = MutableStateFlow<Map<String, String>>(emptyMap())
/** Наблюдаемый снимок [downloaded]. */
val downloadedFlow: StateFlow<Map<String, String>> = _downloaded.asStateFlow()
/** Эмуляция скачанного: itemId → куда (очки|телефон). Наблюдается через [downloadedFlow]. */
val downloaded: MutableMap<String, String> = object : MutableMap<String, String> {
private val delegate = downloadedImpl
override val size: Int get() = delegate.size
override fun isEmpty(): Boolean = delegate.isEmpty()
override fun containsKey(key: String): Boolean = delegate.containsKey(key)
override fun containsValue(value: String): Boolean = delegate.containsValue(value)
override fun get(key: String): String? = delegate[key]
override val keys: MutableSet<String> get() = delegate.keys
override val values: MutableCollection<String> get() = delegate.values
override val entries: MutableSet<MutableMap.MutableEntry<String, String>> get() = delegate.entries
override fun put(key: String, value: String): String? {
val prev = delegate.put(key, value)
emitDownloaded()
return prev
}
override fun remove(key: String): String? {
val prev = delegate.remove(key)
emitDownloaded()
return prev
}
override fun putAll(from: Map<out String, String>) {
delegate.putAll(from)
emitDownloaded()
}
override fun clear() {
delegate.clear()
emitDownloaded()
}
}
private fun emitDownloaded() {
_downloaded.value = LinkedHashMap(downloadedImpl)
}
}