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
@@ -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)
}
}