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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user