diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt index 17fe3a9..2460cb2 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt @@ -125,6 +125,10 @@ internal fun fileKeyFor(partName: String): String { internal fun fileKeys(files: MirrorFiles): List = listOf("video") + files.audios.map { "audio-${it.index}" } +/** Ключи аудио-дорожек зеркала (audio-N) — телефон качает только звук. */ +internal fun audioKeys(files: MirrorFiles): List = + files.audios.map { "audio-${it.index}" } + /** Полностью ли скачано на диск: все финальные файлы [keys] есть, >0 байт, без .part. */ internal fun isFullyOnDisk(dir: File, keys: List): Boolean { if (keys.isEmpty()) return false @@ -205,7 +209,7 @@ internal fun scanDownloads( val biggest = parts.maxByOrNull { it.length() } ?: return@forEach if (total > 0) ((biggest.length() * 100) / total).toInt().coerceIn(0, 99) else 0 } - val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "видео" + val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "звук" progress[itemId] = DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, stage) } } @@ -221,8 +225,8 @@ internal fun requestFor(url: String, existingBytes: Long): Request { } /** - * Реальное скачивание зеркала (видео + все аудио-дорожки) с общим прогрессом. - * Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg. + * Реальное скачивание зеркала (только аудио-дорожки — звук на телефоне) с общим прогрессом. + * Файлы: filesDir/viewmate/{itemId}/audio-{index}.ogg. * Скачивание пишется в .part и переживает рестарт: статус хранится в status.json, * при старте диск сканируется, докачка — только по явному тапу пользователя. */ @@ -251,7 +255,7 @@ class DownloadManager( val downloads: StateFlow> = _downloads.asStateFlow() private val _downloadedIds = MutableStateFlow>(emptySet()) - /** itemId, у которых видео уже скачано на телефон (переживает рестарт приложения). */ + /** itemId, у которых звук уже скачан на телефон (переживает рестарт приложения). */ val downloadedIds: StateFlow> = _downloadedIds.asStateFlow() private val _activeIds = MutableStateFlow>(emptySet()) @@ -321,15 +325,25 @@ class DownloadManager( fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists() /** - * Полностью ли скачано зеркало: video.mkv + все аудио из [files] (без .part, >0 байт). + * Полностью ли скачан звук зеркала на телефон: все аудио из [files] (без .part, >0 байт). * Список дорожек берётся из зеркала (files.audios), а не из того, что есть на диске. */ fun isFullyDownloaded(itemId: String, files: MirrorFiles?): Boolean { - val keys = files?.let { fileKeys(it) } ?: return false + val keys = files?.let { audioKeys(it) } ?: return false if (keys.isEmpty()) return false return isFullyOnDisk(dirFor(itemId), keys) } + /** + * Все аудио-дорожки зеркала на диске (можно смотреть: звук будет). + * Список дорожек — из статуса качки (expectedFiles), а не с диска. + */ + fun audioReady(itemId: String): Boolean { + val status = statuses[itemId] ?: return false + if (status.expectedFiles.isEmpty()) return false + return isFullyOnDisk(dirFor(itemId), status.expectedFiles) + } + /** Общий процент скачанного на диск по всем дорожкам (по .part и полным файлам). */ fun overallPercent(itemId: String): Int { val st = statuses[itemId] ?: return 0 @@ -352,7 +366,7 @@ class DownloadManager( percentForFile(itemId, part.name.removeSuffix(".part")) ?: 0 /** - * Начать (или продолжить) загрузку зеркала [itemId]: видео + ВСЕ аудио-дорожки. + * Начать (или продолжить) загрузку зеркала [itemId]: ТОЛЬКО аудио-дорожки (звук на телефоне). * Если уже качается — повторный вызов игнорируется; если всё полностью скачано — тоже. * При наличии .part докачивает с места обрыва (Range: bytes={размер}-) для каждого файла. */ @@ -376,7 +390,7 @@ class DownloadManager( if (!stale()) update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово")) return@launch } - val keys = fileKeys(files) + val keys = audioKeys(files) val dir = dirFor(itemId) if (stale()) return@launch dir.mkdirs() @@ -393,9 +407,8 @@ class DownloadManager( val done = HashMap() keys.forEach { key -> if (stale()) return@launch - val url = if (key == "video") files.video.url - else files.audios.firstOrNull { "audio-${it.index}" == key }?.url ?: return@forEach - val stage = if (key == "video") "видео" else "звук" + val url = files.audios.firstOrNull { "audio-${it.index}" == key }?.url ?: return@forEach + val stage = "звук" downloadFile( url = S3Presigner.presignGet(url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION), part = File(dir, partFileName(key)), diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt index 214bd01..a73f5e5 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt @@ -150,6 +150,12 @@ fun DetailsScreen( scope.launch { snackbar.showSnackbar("сначала нажми «Скачать на очки»") } return } + val audioOk = dm.audioReady(item.Id) + if (!audioOk) { + log("watch", "пропущено: звук не скачан на телефон (audioReady=false)") + scope.launch { snackbar.showSnackbar("сначала нажми «Скачать на телефон»") } + return + } if (glassesMediaDir.isNullOrBlank()) { log("watch", "пропущено: очки ещё не сообщили каталог (mediaDir пуст)") scope.launch { snackbar.showSnackbar("очки ещё не сообщили каталог — подожди") } @@ -179,7 +185,7 @@ fun DetailsScreen( log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук") } else { result = try { - actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath) + actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath, phoneAudioReady = audioOk) } catch (e: Exception) { log("watch", "watch упал: ${e.message}") snackbar.showSnackbar("watch упал: ${e.message}") diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/DownloadManagerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/DownloadManagerTest.kt index 01861b4..398ca5b 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/DownloadManagerTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/DownloadManagerTest.kt @@ -55,7 +55,7 @@ class DownloadManagerTest { assertTrue(result.downloaded.isEmpty()) assertEquals( - DownloadProgress("i2", DownloadPhase.DOWNLOADING, 50, "видео"), + DownloadProgress("i2", DownloadPhase.DOWNLOADING, 50, "звук"), result.progress["i2"], ) } @@ -68,7 +68,7 @@ class DownloadManagerTest { val result = scanDownloads(base, emptyMap()) - assertEquals(DownloadProgress("i3", DownloadPhase.DOWNLOADING, 0, "видео"), result.progress["i3"]) + assertEquals(DownloadProgress("i3", DownloadPhase.DOWNLOADING, 0, "звук"), result.progress["i3"]) } @Test @@ -131,6 +131,27 @@ class DownloadManagerTest { assertEquals(listOf("video", "audio-0", "audio-1"), fileKeys(files)) } + @Test + fun audioKeysFromMirrorCoversOnlyAudios() { + val files = MirrorFiles( + video = MirrorVideo(key = "v", url = "http://v"), + audios = listOf( + MirrorAudio(index = 0, key = "a0", url = "http://a0"), + MirrorAudio(index = 1, key = "a1", url = "http://a1"), + ), + ) + assertEquals(listOf("audio-0", "audio-1"), audioKeys(files)) + } + + @Test + fun audioKeysEmptyWithoutAudios() { + val files = MirrorFiles( + video = MirrorVideo(key = "v", url = "http://v"), + audios = emptyList(), + ) + assertTrue(audioKeys(files).isEmpty()) + } + @Test fun isFullyOnDiskRequiresAllFilesNoParts() { val base = newBaseDir() diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesFilesTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesFilesTest.kt index bedba8e..5062508 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesFilesTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesFilesTest.kt @@ -38,11 +38,9 @@ class GlassesFilesTest { assertEquals("movie-1", msg.itemId) assertEquals("Interstellar", msg.title) - assertEquals(3, msg.files.size) + assertEquals(1, msg.files.size) assertEquals("video.mkv", msg.files[0].name) assertEquals("https://s3/mirror/video.mkv?sig", msg.files[0].url) - assertEquals("audio-0.ogg", msg.files[1].name) - assertEquals("audio-1.ogg", msg.files[2].name) } @Test @@ -56,8 +54,6 @@ class GlassesFilesTest { ) assertEquals(1_863_946_240L, msg.files[0].sizeBytes) - assertEquals(68_157_440L, msg.files[1].sizeBytes) - assertEquals(0L, msg.files[2].sizeBytes) } @Test @@ -67,8 +63,9 @@ class GlassesFilesTest { sizes = emptyMap(), presign = { "signed-$it" }, ) - assertEquals(3, specs.size) + assertEquals(1, specs.size) assertTrue(specs.all { it.url.startsWith("signed-") }) + assertEquals("video.mkv", specs.single().name) } @Test diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesDownloadBuild.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesDownloadBuild.kt index 3cc0159..41b52fb 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesDownloadBuild.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesDownloadBuild.kt @@ -11,7 +11,8 @@ fun trackFileName(key: String): String = if (key == "video") "video.mkv" else "$ fun trackKeys(files: MirrorFiles): List = listOf("video") + files.audios.map { "audio-${it.index}" } -/** Собрать список файлов для DownloadFiles на очки: presigned URL, имя файла, размер (если известен). */ +/** Собрать список файлов для DownloadFiles на очки: presigned URL, имя файла, размер (если известен). + * На очки — только video.mkv; звук — на телефоне. */ fun buildGlassesFileSpecs( files: MirrorFiles, sizes: Map, @@ -22,15 +23,7 @@ fun buildGlassesFileSpecs( name = trackFileName("video"), sizeBytes = sizes["video"] ?: 0L, ) - val audios = files.audios.map { a -> - val key = "audio-${a.index}" - FileSpec( - url = presign(a.url), - name = trackFileName(key), - sizeBytes = sizes[key] ?: 0L, - ) - } - return listOf(video) + audios + return listOf(video) } /** Полное сообщение DownloadFiles для очков. */ diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt index eeba888..8105b9e 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt @@ -89,8 +89,9 @@ class PhoneActions( /** * Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках. * Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем). + * Звук — на телефоне: [phoneAudioReady] — скачаны ли аудио-дорожки на телефон. */ - suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = ""): String { + suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = "", phoneAudioReady: Boolean = true): String { val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден" val status = mirror.mirrorByItem(itemId) val files = status?.files @@ -100,6 +101,9 @@ class PhoneActions( if (!localVideoPath.startsWith("file://")) { return "«${item.Name ?: itemId}» не скачан на очки — сначала «Скачать на очки»" } + if (!phoneAudioReady) { + return "«${item.Name ?: itemId}» не скачан на телефон (звук) — сначала «Скачать на телефон»" + } val title = item.Name ?: itemId sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex)) diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt index a16ab38..355725b 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt @@ -187,6 +187,17 @@ class PhoneActionsTest { assertTrue(sender.sent.isEmpty()) } + @Test + fun watchWhenPhoneAudioNotReadyIsRejected() = runTest { + val sender = SenderStub() + val out = actions(simpleJellyfin(), doneMirror(), sender) + .watch("m1", localVideoPath = "file:///x/m1/video.mkv", phoneAudioReady = false) + + assertTrue(out.contains("не скачан на телефон")) + assertTrue(out.contains("Скачать на телефон")) + assertTrue(sender.sent.isEmpty()) + } + @Test fun watchWhenMirrorNotReadyReturnsHint() = runTest { val sender = SenderStub() @@ -295,7 +306,7 @@ class PhoneActionsTest { assertIs(msg) assertEquals("m1", msg.itemId) assertEquals("Интерстеллар", msg.title) - assertEquals(listOf("video.mkv", "audio-1.ogg", "audio-2.ogg"), msg.files.map { it.name }) + assertEquals(listOf("video.mkv"), msg.files.map { it.name }) assertEquals("signed-https://s3/media/m1/video.mp4", msg.files[0].url) assertEquals(100L, msg.files[0].sizeBytes) assertTrue(msg.files[0].name.endsWith(".mkv"))