fix(app-phone): звук при watch (диагностика+подстраховка mirrorByItem) + качка не воскресает после удаления
- onWatch: явные логи в каждой точке отказа (watch result / audio source / уже играет / не скачано / mediaDir пуст), свежий mirrorByItem если files не догрузился, звук стартует с позиции очков при уже идущем видео - resolveAudioSource → resolveAudioSourceDetailed (AudioSourceResult с причиной null) - DownloadManager: поколения generations — delete() инвалидирует старую корутину (не воскрешает качку), ensureActive перед .part/rename, jobs.remove(itemId, job) - Тесты 62/0 (в т.ч. поколения — поймали null != 0L) Инфраструктурный блокер (не код): SeaweedFS на Server3 76.66 в IO-шторме (load ~184, wa 20%, чужой yt-dlp на хосте) — большие GET с s3.binom.pw висят/рвутся с ~14:00; качка на очки FAILED по этой причине.
This commit is contained in:
@@ -50,26 +50,55 @@ internal fun decideSync(
|
||||
return SyncDecision(seekToMs = seekToMs, playing = playing)
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат выбора аудио-источника: [source] (локальный путь или presigned URL)
|
||||
* либо null с человекочитаемой [reason] — чтобы лог показал точную причину отсутствия звука.
|
||||
*/
|
||||
internal data class AudioSourceResult(
|
||||
val source: String?,
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Источник аудио для просмотра на очках: локальный скачанный `audio-N.ogg`
|
||||
* (если есть — быстрее, без сети), иначе presigned URL дорожки [audioIndex]
|
||||
* (стриминг, без скачивания). null — дорожки нет.
|
||||
* (стриминг, без скачивания). null — дорожки нет, причина в [AudioSourceResult.reason].
|
||||
* Чистая функция (без Android) — покрыта unit-тестами.
|
||||
*/
|
||||
internal fun resolveAudioSourceDetailed(
|
||||
mirrorFiles: MirrorFiles?,
|
||||
audioIndex: Int,
|
||||
localAudioFile: (index: Int) -> File,
|
||||
presign: (url: String) -> String = { S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION) },
|
||||
): AudioSourceResult {
|
||||
if (mirrorFiles == null) {
|
||||
return AudioSourceResult(null, "files=null (зеркало не получено)")
|
||||
}
|
||||
val audio = mirrorFiles.audios.firstOrNull { it.index == audioIndex } ?: mirrorFiles.audios.firstOrNull()
|
||||
if (audio == null) {
|
||||
return AudioSourceResult(null, "audios пусто (искал дорожку #$audioIndex)")
|
||||
}
|
||||
val local = localAudioFile(audio.index)
|
||||
return if (local.exists()) {
|
||||
AudioSourceResult(local.absolutePath, "локальный файл ${local.absolutePath}")
|
||||
} else {
|
||||
AudioSourceResult(presign(audio.url), "стриминг (локального audio-${audio.index}.ogg на телефоне нет)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public-обёртка для UI: источник аудио по [DownloadManager] (S3-presign по умолчанию).
|
||||
*/
|
||||
fun resolveAudioSource(
|
||||
downloadManager: DownloadManager,
|
||||
itemId: String,
|
||||
mirrorFiles: MirrorFiles?,
|
||||
audioIndex: Int,
|
||||
): String? {
|
||||
val files = mirrorFiles ?: return null
|
||||
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull() ?: return null
|
||||
val local = downloadManager.audioFile(itemId, audio.index)
|
||||
return if (local.exists()) {
|
||||
local.absolutePath
|
||||
} else {
|
||||
S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
|
||||
}
|
||||
}
|
||||
): String? = resolveAudioSourceDetailed(
|
||||
mirrorFiles = mirrorFiles,
|
||||
audioIndex = audioIndex,
|
||||
localAudioFile = { downloadManager.audioFile(itemId, it) },
|
||||
).source
|
||||
|
||||
/**
|
||||
* Аудио-плеер телефона, синхронизированный с мастер-таймкодом очков.
|
||||
|
||||
@@ -19,6 +19,7 @@ import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
import pw.binom.viewmate.core.media.MirrorFiles
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -47,6 +48,10 @@ data class DownloadStatus(
|
||||
val percent: Int = 0,
|
||||
val totalBytes: Long = 0,
|
||||
val updatedAt: Long = 0,
|
||||
/** Ожидаемый набор дорожек: "video", "audio-0", ... — из зеркала, а не из того, что на диске. */
|
||||
val expectedFiles: List<String> = emptyList(),
|
||||
/** Размер каждой дорожки (key → байт), заполняется после получения заголовков. */
|
||||
val fileSizes: Map<String, Long> = emptyMap(),
|
||||
)
|
||||
|
||||
internal val statusJson: Json = Json {
|
||||
@@ -82,9 +87,96 @@ internal data class DiskScanResult(
|
||||
)
|
||||
|
||||
/**
|
||||
* Скан папок filesDir/viewmate/<itemId>: финальный video.mkv → itemId в downloaded
|
||||
* (заодно чистим лишние .part); есть только .part → фаза downloading с percent
|
||||
* из размера .part и totalBytes из статуса.
|
||||
* Удалить состояние скачивания [itemId] из всех карт. Идемпотентно:
|
||||
* повторный вызов ничего не меняет (карты уже не содержат itemId).
|
||||
*/
|
||||
internal fun removeDownloadState(
|
||||
statuses: MutableMap<String, DownloadStatus>,
|
||||
downloads: MutableMap<String, DownloadProgress>,
|
||||
downloadedIds: MutableSet<String>,
|
||||
itemId: String,
|
||||
) {
|
||||
downloads.remove(itemId)
|
||||
downloadedIds.remove(itemId)
|
||||
statuses.remove(itemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Актуальна ли качка поколения [generation] для [itemId]. [delete] увеличивает поколение —
|
||||
* старая незавершённая корутина после удаления видит несовпадение и не «воскрешает» качку.
|
||||
* Нет записи — поколение 0 (никогда не удалялось).
|
||||
*/
|
||||
internal fun isCurrentGeneration(generations: Map<String, Long>, itemId: String, generation: Long): Boolean =
|
||||
(generations[itemId] ?: 0L) == generation
|
||||
|
||||
/** Имя финального файла для ключа дорожки ("video" → "video.mkv", "audio-0" → "audio-0.ogg"). */
|
||||
internal fun finalFileName(key: String): String = if (key == "video") "video.mkv" else "$key.ogg"
|
||||
|
||||
/** Имя .part-файла для ключа дорожки. */
|
||||
internal fun partFileName(key: String): String = "${finalFileName(key)}.part"
|
||||
|
||||
/** Ключ дорожки по имени .part-файла ("video.mkv.part" → "video", "audio-0.ogg.part" → "audio-0"). */
|
||||
internal fun fileKeyFor(partName: String): String {
|
||||
val base = partName.removeSuffix(".part")
|
||||
return if (base == "video.mkv") "video" else base.removeSuffix(".ogg")
|
||||
}
|
||||
|
||||
/** Список ключей дорожек зеркала: video + каждая аудио (из files.audios, а не с диска). */
|
||||
internal fun fileKeys(files: MirrorFiles): List<String> =
|
||||
listOf("video") + files.audios.map { "audio-${it.index}" }
|
||||
|
||||
/** Полностью ли скачано на диск: все финальные файлы [keys] есть, >0 байт, без .part. */
|
||||
internal fun isFullyOnDisk(dir: File, keys: List<String>): Boolean {
|
||||
if (keys.isEmpty()) return false
|
||||
return keys.all { key ->
|
||||
val final = File(dir, finalFileName(key))
|
||||
final.isFile && final.length() > 0 && !File(dir, partFileName(key)).exists()
|
||||
}
|
||||
}
|
||||
|
||||
/** Общий процент скачанного на диск по .part/финальным файлам относительно [sizes]. */
|
||||
internal fun diskPercent(dir: File, keys: List<String>, sizes: Map<String, Long>): Int {
|
||||
if (keys.isEmpty()) return 0
|
||||
val totalSum = keys.sumOf { sizes[it] ?: 0L }
|
||||
if (totalSum <= 0) return 0
|
||||
var done = 0L
|
||||
keys.forEach { key ->
|
||||
val size = sizes[key] ?: 0L
|
||||
val part = File(dir, partFileName(key))
|
||||
val final = File(dir, finalFileName(key))
|
||||
done += when {
|
||||
part.isFile && part.length() > 0 -> if (size > 0) part.length().coerceAtMost(size) else 0L
|
||||
final.isFile && final.length() > 0 -> if (size > 0) size else final.length()
|
||||
else -> 0L
|
||||
}
|
||||
}
|
||||
return ((done * 100) / totalSum).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
/** Процент одного файла [fileName] по .part/финальному файлу относительно его размера; null, если посчитать нельзя. */
|
||||
internal fun filePercent(dir: File, fileName: String, sizes: Map<String, Long>): Int? {
|
||||
val final = File(dir, fileName)
|
||||
if (final.isFile && final.length() > 0) return 100
|
||||
val part = File(dir, "$fileName.part")
|
||||
if (part.isFile && part.length() > 0) {
|
||||
val total = sizes[fileKeyFor(part.name)] ?: 0L
|
||||
if (total > 0) return ((part.length() * 100) / total).toInt().coerceIn(0, 99)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Общий процент по живым суммам байтов (скачано/всего по всем файлам). */
|
||||
internal fun combinedPercent(totals: Map<String, Long>, done: Map<String, Long>): Int {
|
||||
val totalSum = totals.values.sum()
|
||||
if (totalSum <= 0) return 0
|
||||
val doneSum = done.values.sum()
|
||||
return ((doneSum * 100) / totalSum).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Скан папок filesDir/viewmate/<itemId>: все ожидаемые файлы → itemId в downloaded
|
||||
* (заодно чистим лишние .part); есть только .part → фаза downloading с общим процентом
|
||||
* по всем дорожкам. Список дорожек — из статуса (expectedFiles), а не с диска.
|
||||
*/
|
||||
internal fun scanDownloads(
|
||||
baseDir: File,
|
||||
@@ -96,17 +188,23 @@ internal fun scanDownloads(
|
||||
baseDir.listFiles()?.forEach { dir ->
|
||||
if (!dir.isDirectory) return@forEach
|
||||
val itemId = dir.name
|
||||
val savedStatus = saved[itemId]
|
||||
val keys = savedStatus?.expectedFiles.orEmpty()
|
||||
val parts = dir.listFiles { f -> f.isFile && f.name.endsWith(".part") }?.toList().orEmpty()
|
||||
val biggestPart = parts.maxByOrNull { it.length() }
|
||||
val hasFinal = File(dir, "video.mkv").exists()
|
||||
|
||||
if (hasFinal) {
|
||||
// старый формат (без expectedFiles): полным считаем наличие video.mkv
|
||||
val fully = if (keys.isNotEmpty()) isFullyOnDisk(dir, keys) else File(dir, "video.mkv").isFile
|
||||
if (fully) {
|
||||
downloaded += itemId
|
||||
parts.forEach { it.delete() }
|
||||
} else if (biggestPart != null && biggestPart.length() > 0) {
|
||||
val savedStatus = saved[itemId]
|
||||
val total = savedStatus?.totalBytes ?: 0L
|
||||
val percent = if (total > 0) ((biggestPart.length() * 100) / total).toInt().coerceIn(0, 99) else 0
|
||||
} else if (parts.any { it.length() > 0 }) {
|
||||
val percent = if (keys.isNotEmpty()) {
|
||||
diskPercent(dir, keys, savedStatus?.fileSizes.orEmpty())
|
||||
} else {
|
||||
val total = savedStatus?.totalBytes ?: 0L
|
||||
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() } ?: "видео"
|
||||
progress[itemId] = DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, stage)
|
||||
}
|
||||
@@ -123,7 +221,7 @@ internal fun requestFor(url: String, existingBytes: Long): Request {
|
||||
}
|
||||
|
||||
/**
|
||||
* Реальное скачивание зеркала (видео + выбранная озвучка) с прогрессом.
|
||||
* Реальное скачивание зеркала (видео + все аудио-дорожки) с общим прогрессом.
|
||||
* Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg.
|
||||
* Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
|
||||
* при старте диск сканируется, докачка — только по явному тапу пользователя.
|
||||
@@ -135,6 +233,13 @@ class DownloadManager(
|
||||
private val baseDir = File(context.filesDir, "viewmate")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val jobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
/**
|
||||
* Поколение запуска качки itemId. Растёт при [delete]: незавершённая корутина
|
||||
* после удаления видит несовпадение поколений и «умирает», не создавая файлы/статус заново.
|
||||
*/
|
||||
private val generations = ConcurrentHashMap<String, Long>()
|
||||
|
||||
private val statuses = ConcurrentHashMap<String, DownloadStatus>()
|
||||
private val statusStore = DownloadStatusStore(File(baseDir, "status.json"))
|
||||
private val okHttp = OkHttpClient.Builder()
|
||||
@@ -180,6 +285,8 @@ class DownloadManager(
|
||||
percent = p.percent,
|
||||
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
expectedFiles = statuses[itemId]?.expectedFiles ?: emptyList(),
|
||||
fileSizes = statuses[itemId]?.fileSizes ?: emptyMap(),
|
||||
)
|
||||
}
|
||||
statusStore.write(statuses)
|
||||
@@ -213,74 +320,122 @@ class DownloadManager(
|
||||
/** Видео уже скачано на телефон. */
|
||||
fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists()
|
||||
|
||||
/** Процент неполного файла [part] от полного размера (totalBytes из статуса). */
|
||||
fun partPercent(itemId: String, part: File): Int {
|
||||
if (!part.exists() || part.length() <= 0) return 0
|
||||
val total = statuses[itemId]?.totalBytes ?: 0L
|
||||
if (total <= 0) return 0
|
||||
return ((part.length() * 100) / total).toInt().coerceIn(0, 99)
|
||||
/**
|
||||
* Полностью ли скачано зеркало: video.mkv + все аудио из [files] (без .part, >0 байт).
|
||||
* Список дорожек берётся из зеркала (files.audios), а не из того, что есть на диске.
|
||||
*/
|
||||
fun isFullyDownloaded(itemId: String, files: MirrorFiles?): Boolean {
|
||||
val keys = files?.let { fileKeys(it) } ?: return false
|
||||
if (keys.isEmpty()) return false
|
||||
return isFullyOnDisk(dirFor(itemId), keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Начать (или продолжить) загрузку зеркала [itemId] (видео + озвучка [audioIndex]).
|
||||
* Если уже качается — повторный вызов игнорируется; если файл полный — тоже.
|
||||
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-).
|
||||
*/
|
||||
fun startDownload(itemId: String, audioIndex: Int) {
|
||||
if (jobs[itemId]?.isActive == true) return
|
||||
if (isDownloaded(itemId)) return
|
||||
/** Общий процент скачанного на диск по всем дорожкам (по .part и полным файлам). */
|
||||
fun overallPercent(itemId: String): Int {
|
||||
val st = statuses[itemId] ?: return 0
|
||||
if (st.expectedFiles.isEmpty()) return 0
|
||||
return diskPercent(dirFor(itemId), st.expectedFiles, st.fileSizes)
|
||||
}
|
||||
|
||||
jobs[itemId] = scope.launch {
|
||||
/** Известные размеры дорожек (key → байт) после скачивания на телефон; пусто, если качки не было. */
|
||||
fun knownFileSizes(itemId: String): Map<String, Long> =
|
||||
statuses[itemId]?.fileSizes.orEmpty()
|
||||
|
||||
/** Процент конкретного файла [fileName] ("video.mkv", "audio-0.ogg") на диске телефона; null, если посчитать нельзя. */
|
||||
fun percentForFile(itemId: String, fileName: String): Int? {
|
||||
val st = statuses[itemId]
|
||||
return filePercent(dirFor(itemId), fileName, st?.fileSizes.orEmpty())
|
||||
}
|
||||
|
||||
/** Процент неполного файла [part] от полного размера дорожки (fileSizes/totalBytes из статуса). */
|
||||
fun partPercent(itemId: String, part: File): Int =
|
||||
percentForFile(itemId, part.name.removeSuffix(".part")) ?: 0
|
||||
|
||||
/**
|
||||
* Начать (или продолжить) загрузку зеркала [itemId]: видео + ВСЕ аудио-дорожки.
|
||||
* Если уже качается — повторный вызов игнорируется; если всё полностью скачано — тоже.
|
||||
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-) для каждого файла.
|
||||
*/
|
||||
fun startDownload(itemId: String) {
|
||||
if (jobs[itemId]?.isActive == true) return
|
||||
val generation = generations[itemId] ?: 0L
|
||||
|
||||
lateinit var job: Job
|
||||
job = scope.launch {
|
||||
_activeIds.value = _activeIds.value + itemId
|
||||
try {
|
||||
// корутина «устарела»: itemId удалили (delete) — дальше не идём,
|
||||
// чтобы не создать заново каталог/статус/.part после удаления
|
||||
fun stale() = !isCurrentGeneration(generations, itemId, generation)
|
||||
if (stale()) return@launch
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, resumePercent(itemId), "получаю зеркало"))
|
||||
val status = mirror.mirrorByItem(itemId)
|
||||
if (stale()) return@launch
|
||||
val files = status?.files
|
||||
if (status == null || status.status != "done" || files == null) {
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
|
||||
if (!stale()) update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
|
||||
return@launch
|
||||
}
|
||||
|
||||
val keys = fileKeys(files)
|
||||
val dir = dirFor(itemId)
|
||||
if (stale()) return@launch
|
||||
dir.mkdirs()
|
||||
|
||||
downloadFile(
|
||||
url = S3Presigner.presignGet(files.video.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
||||
part = partVideo(itemId),
|
||||
dest = videoFile(itemId),
|
||||
onProgress = { percent ->
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "видео"))
|
||||
},
|
||||
onTotal = { total -> setTotalBytes(itemId, total) },
|
||||
)
|
||||
if (isFullyOnDisk(dir, keys)) {
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
|
||||
_downloadedIds.value = _downloadedIds.value + itemId
|
||||
log("download", "$itemId уже полностью скачан на диск")
|
||||
return@launch
|
||||
}
|
||||
setExpectedFiles(itemId, keys)
|
||||
|
||||
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull()
|
||||
if (audio != null) {
|
||||
val totals = HashMap<String, Long>()
|
||||
val done = HashMap<String, Long>()
|
||||
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 "звук"
|
||||
downloadFile(
|
||||
url = S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
||||
part = partAudio(itemId, audio.index),
|
||||
dest = audioFile(itemId, audio.index),
|
||||
onProgress = { percent ->
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "звук"))
|
||||
url = S3Presigner.presignGet(url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
||||
part = File(dir, partFileName(key)),
|
||||
dest = File(dir, finalFileName(key)),
|
||||
onTotal = { total ->
|
||||
totals[key] = total
|
||||
setFileSize(itemId, key, total)
|
||||
},
|
||||
onProgress = { d, t ->
|
||||
totals[key] = t
|
||||
done[key] = d
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, combinedPercent(totals, done), stage))
|
||||
},
|
||||
onTotal = { total -> setTotalBytes(itemId, total) },
|
||||
)
|
||||
if (stale()) return@launch
|
||||
done[key] = totals[key] ?: 0L
|
||||
}
|
||||
|
||||
if (stale()) return@launch
|
||||
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, "отменено"))
|
||||
// после delete() не воскрешаем статус «отменено» — состояние уже сброшено
|
||||
if (isCurrentGeneration(generations, itemId, generation)) {
|
||||
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}"))
|
||||
if (isCurrentGeneration(generations, itemId, generation)) {
|
||||
log("download", "ошибка скачивания $itemId: ${e.message}")
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
|
||||
}
|
||||
} finally {
|
||||
jobs.remove(itemId)
|
||||
// снимаем ТОЛЬКО свою корутину: после cancel/delete в jobs могла лечь новая
|
||||
jobs.remove(itemId, job)
|
||||
_activeIds.value = _activeIds.value - itemId
|
||||
}
|
||||
}
|
||||
jobs[itemId] = job
|
||||
}
|
||||
|
||||
/** Отменить текущую загрузку (.part остаётся — при следующем тапе докачается). */
|
||||
@@ -288,32 +443,34 @@ class DownloadManager(
|
||||
jobs.remove(itemId)?.cancel()
|
||||
}
|
||||
|
||||
/** Удалить скачанные файлы с телефона и сбросить статус. */
|
||||
/** Удалить скачанные файлы с телефона и сбросить статус. Удаление финально. */
|
||||
fun delete(itemId: String) {
|
||||
cancel(itemId)
|
||||
// инвалидируем незавершённую корутину: даже если она проснётся позже — дальше не пойдёт
|
||||
generations[itemId] = (generations[itemId] ?: 0L) + 1
|
||||
dirFor(itemId).deleteRecursively()
|
||||
_downloads.value = _downloads.value - itemId
|
||||
_downloadedIds.value = _downloadedIds.value - itemId
|
||||
statuses.remove(itemId)
|
||||
val downloads = _downloads.value.toMutableMap()
|
||||
val downloadedIds = _downloadedIds.value.toMutableSet()
|
||||
removeDownloadState(statuses, downloads, downloadedIds, itemId)
|
||||
_downloads.value = downloads
|
||||
_downloadedIds.value = downloadedIds
|
||||
_activeIds.value = _activeIds.value - itemId
|
||||
persistStatus(force = true)
|
||||
log("download", "удалено скачанное $itemId (поколение → ${generations[itemId]})")
|
||||
}
|
||||
|
||||
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке). */
|
||||
private fun resumePercent(itemId: String): Int {
|
||||
val total = statuses[itemId]?.totalBytes ?: 0L
|
||||
if (total <= 0) return 0
|
||||
return partPercent(itemId, partVideo(itemId))
|
||||
}
|
||||
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке) — общий процент по всем дорожкам. */
|
||||
private fun resumePercent(itemId: String): Int = overallPercent(itemId)
|
||||
|
||||
/**
|
||||
* Скачивание одного файла в [part] (докачка с Range, если уже есть данные),
|
||||
* по завершении — rename в [dest] (без .part).
|
||||
* по завершении — rename в [dest] (без .part). [onProgress] отдаёт (скачано, всего).
|
||||
*/
|
||||
private suspend fun downloadFile(
|
||||
url: String,
|
||||
part: File,
|
||||
dest: File,
|
||||
onProgress: (Int) -> Unit,
|
||||
onProgress: (done: Long, total: Long) -> Unit,
|
||||
onTotal: (Long) -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val existing = if (part.exists()) part.length() else 0L
|
||||
@@ -334,6 +491,8 @@ class DownloadManager(
|
||||
onTotal(total)
|
||||
|
||||
val input = body.byteStream()
|
||||
// перед созданием/открытием .part — стоп, если качку отменили/удалили
|
||||
currentCoroutineContext().ensureActive()
|
||||
FileOutputStream(part, resumed).use { out ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var done = from
|
||||
@@ -343,13 +502,13 @@ class DownloadManager(
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
if (total > 0) {
|
||||
val percent = ((done * 100) / total).toInt().coerceIn(0, 100)
|
||||
onProgress(percent)
|
||||
}
|
||||
if (total > 0) onProgress(done, total)
|
||||
}
|
||||
}
|
||||
|
||||
// перед финализацией (.part → конечный файл) — стоп, если качку отменили/удалили,
|
||||
// иначе удаление «воскреснет» переименованным .part
|
||||
currentCoroutineContext().ensureActive()
|
||||
if (dest.exists()) dest.delete()
|
||||
if (!part.renameTo(dest)) {
|
||||
part.copyTo(dest, overwrite = true)
|
||||
@@ -358,22 +517,49 @@ class DownloadManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun setTotalBytes(itemId: String, total: Long) {
|
||||
if (total <= 0) return
|
||||
private fun setFileSize(itemId: String, key: String, size: Long) {
|
||||
if (size <= 0) return
|
||||
val prev = statuses[itemId]
|
||||
if (prev != null && prev.totalBytes > 0) return
|
||||
statuses[itemId] = prev?.copy(totalBytes = total) ?: DownloadStatus(totalBytes = total)
|
||||
val sizes = prev?.fileSizes ?: emptyMap()
|
||||
if (sizes.containsKey(key)) return
|
||||
statuses[itemId] = DownloadStatus(
|
||||
phase = prev?.phase ?: "downloading",
|
||||
stage = prev?.stage ?: "",
|
||||
percent = prev?.percent ?: 0,
|
||||
totalBytes = (prev?.totalBytes ?: 0L) + size,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
expectedFiles = prev?.expectedFiles ?: emptyList(),
|
||||
fileSizes = sizes + (key to size),
|
||||
)
|
||||
persistStatus()
|
||||
}
|
||||
|
||||
private fun setExpectedFiles(itemId: String, keys: List<String>) {
|
||||
val prev = statuses[itemId]
|
||||
if (prev?.expectedFiles == keys) return
|
||||
statuses[itemId] = DownloadStatus(
|
||||
phase = prev?.phase ?: "downloading",
|
||||
stage = prev?.stage ?: "",
|
||||
percent = prev?.percent ?: 0,
|
||||
totalBytes = prev?.totalBytes ?: 0L,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
expectedFiles = keys,
|
||||
fileSizes = prev?.fileSizes ?: emptyMap(),
|
||||
)
|
||||
persistStatus()
|
||||
}
|
||||
|
||||
private fun update(itemId: String, progress: DownloadProgress) {
|
||||
_downloads.value = _downloads.value + (itemId to progress)
|
||||
val prev = statuses[itemId]
|
||||
statuses[itemId] = DownloadStatus(
|
||||
phase = progress.phase.name.lowercase(),
|
||||
stage = progress.stage,
|
||||
percent = progress.percent,
|
||||
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
|
||||
totalBytes = prev?.totalBytes ?: 0L,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
expectedFiles = prev?.expectedFiles ?: emptyList(),
|
||||
fileSizes = prev?.fileSizes ?: emptyMap(),
|
||||
)
|
||||
persistStatus(force = progress.phase != DownloadPhase.DOWNLOADING)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||
import java.io.File
|
||||
|
||||
/** Статус файла на устройстве (телефон или очки) для строк «скачивание на устройства». */
|
||||
enum class FileDownloadState {
|
||||
NONE,
|
||||
DOWNLOADING,
|
||||
DONE,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
/** Статус файла на очках из последнего DownloadProgress (null — не начат). */
|
||||
fun glassesFileState(progress: DownloadProgress?): Pair<FileDownloadState, Int> {
|
||||
if (progress == null) return FileDownloadState.NONE to 0
|
||||
return when (progress.phase) {
|
||||
DownloadPhase.DONE -> FileDownloadState.DONE to 100
|
||||
DownloadPhase.DOWNLOADING -> FileDownloadState.DOWNLOADING to progress.percent.coerceIn(0, 99)
|
||||
DownloadPhase.FAILED -> FileDownloadState.FAILED to 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Статус файла на телефоне: финальный файл есть → DONE; .part есть → DOWNLOADING. */
|
||||
fun phoneFileState(finalFile: File, partFile: File): Pair<FileDownloadState, Int> {
|
||||
if (finalFile.isFile && finalFile.length() > 0) return FileDownloadState.DONE to 100
|
||||
if (partFile.isFile && partFile.length() > 0) return FileDownloadState.DOWNLOADING to 0
|
||||
return FileDownloadState.NONE to 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Общий процент скачивания на очки (для кнопки «Скачать на очки»).
|
||||
* Сначала — по известным размерам дорожек [knownSizes] (из качки на телефон);
|
||||
* если размеров нет — по прогрессу уже начатых файлов.
|
||||
*/
|
||||
fun glassesOverallPercent(
|
||||
fileNames: List<String>,
|
||||
knownSizes: Map<String, Long>,
|
||||
perFile: Map<String, DownloadProgress>,
|
||||
): Int? {
|
||||
if (perFile.isEmpty()) return null
|
||||
val fullyDone = fileNames.isNotEmpty() &&
|
||||
perFile.keys.containsAll(fileNames) &&
|
||||
perFile.values.all { it.phase == DownloadPhase.DONE }
|
||||
if (fullyDone) return 100
|
||||
|
||||
val total = fileNames.sumOf { knownSizes[it] ?: 0L }
|
||||
if (total > 0) {
|
||||
val done = fileNames.sumOf { name ->
|
||||
val p = perFile[name]
|
||||
when {
|
||||
p == null -> 0L
|
||||
p.phase == DownloadPhase.DONE -> if (p.totalBytes > 0) p.totalBytes else knownSizes[name] ?: 0L
|
||||
else -> p.doneBytes
|
||||
}
|
||||
}
|
||||
return ((done * 100) / total).toInt().coerceIn(0, 99)
|
||||
}
|
||||
|
||||
val totalStarted = perFile.values.sumOf { it.totalBytes }
|
||||
if (totalStarted <= 0) return null
|
||||
val doneStarted = perFile.values.sumOf { it.doneBytes }
|
||||
return ((doneStarted * 100) / totalStarted).toInt().coerceIn(0, 99)
|
||||
}
|
||||
|
||||
/** Простой «живой» процент по текущим прогрессам (для индикатора на экране «Очки»). */
|
||||
fun glassesLivePercent(perFile: Map<String, DownloadProgress>): Int? {
|
||||
if (perFile.isEmpty()) return null
|
||||
if (perFile.values.all { it.phase == DownloadPhase.DONE }) return 100
|
||||
val total = perFile.values.sumOf { it.totalBytes }
|
||||
if (total <= 0) return null
|
||||
val done = perFile.values.sumOf { it.doneBytes }
|
||||
return ((done * 100) / total).toInt().coerceIn(0, 99)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import kotlinx.serialization.encodeToString
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.phone.GlassesSender
|
||||
import pw.binom.viewmate.core.phone.PhoneState
|
||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||
import pw.binom.viewmate.core.protocol.GlassesOff
|
||||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
@@ -36,7 +37,8 @@ const val GLASSES_WS_PATH = "/ws/glasses"
|
||||
/**
|
||||
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
||||
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
|
||||
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза).
|
||||
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза,
|
||||
* DownloadProgress → карта скачивания на очки).
|
||||
*/
|
||||
class GlassesHub(
|
||||
private val state: PhoneState,
|
||||
@@ -58,6 +60,29 @@ class GlassesHub(
|
||||
private val _storageTotalGb = MutableStateFlow<Double?>(null)
|
||||
val storageTotalGb: StateFlow<Double?> = _storageTotalGb.asStateFlow()
|
||||
|
||||
private val _storageTotalBytes = MutableStateFlow<Long?>(null)
|
||||
val storageTotalBytes: StateFlow<Long?> = _storageTotalBytes.asStateFlow()
|
||||
|
||||
private val _storageFreeBytes = MutableStateFlow<Long?>(null)
|
||||
val storageFreeBytes: StateFlow<Long?> = _storageFreeBytes.asStateFlow()
|
||||
|
||||
private val _mediaBytes = MutableStateFlow<Long?>(null)
|
||||
val mediaBytes: StateFlow<Long?> = _mediaBytes.asStateFlow()
|
||||
|
||||
private val _appVersion = MutableStateFlow<String?>(null)
|
||||
val appVersion: StateFlow<String?> = _appVersion.asStateFlow()
|
||||
|
||||
private val _mediaDir = MutableStateFlow<String?>(null)
|
||||
val mediaDir: StateFlow<String?> = _mediaDir.asStateFlow()
|
||||
|
||||
private val _downloadedItemIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
/** itemId полностью скачанных на очки (из периодического GlassesStatus). */
|
||||
val downloadedItemIds: StateFlow<Set<String>> = _downloadedItemIds.asStateFlow()
|
||||
|
||||
private val _glassesDownloads = MutableStateFlow<Map<String, Map<String, DownloadProgress>>>(emptyMap())
|
||||
/** Прогресс скачивания на очки: itemId → имя файла → последний DownloadProgress от очков. */
|
||||
val glassesDownloads: StateFlow<Map<String, Map<String, DownloadProgress>>> = _glassesDownloads.asStateFlow()
|
||||
|
||||
private val _positionMs = MutableStateFlow<Long?>(null)
|
||||
val positionMs: StateFlow<Long?> = _positionMs.asStateFlow()
|
||||
|
||||
@@ -106,7 +131,24 @@ class GlassesHub(
|
||||
_batteryPercent.value = msg.batteryPercent
|
||||
_storageUsedGb.value = msg.storageUsedGb
|
||||
_storageTotalGb.value = msg.storageTotalGb
|
||||
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
|
||||
if (msg.storageTotalBytes > 0) _storageTotalBytes.value = msg.storageTotalBytes
|
||||
if (msg.storageFreeBytes > 0) _storageFreeBytes.value = msg.storageFreeBytes
|
||||
if (msg.mediaBytes > 0) _mediaBytes.value = msg.mediaBytes
|
||||
if (msg.mediaDir.isNotBlank()) _mediaDir.value = msg.mediaDir
|
||||
if (msg.appVersion.isNotBlank()) _appVersion.value = msg.appVersion
|
||||
_downloadedItemIds.value = msg.downloadedItemIds.toSet()
|
||||
log(
|
||||
"glasses",
|
||||
"статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ, " +
|
||||
"медиа ${msg.mediaBytes} байт, скачано: ${msg.downloadedItemIds.size}, app=${msg.appVersion}",
|
||||
)
|
||||
}
|
||||
|
||||
is DownloadProgress -> {
|
||||
val prev = _glassesDownloads.value[msg.itemId] ?: emptyMap()
|
||||
val updated = _glassesDownloads.value + (msg.itemId to (prev + (msg.fileName to msg)))
|
||||
_glassesDownloads.value = updated
|
||||
log("glasses", "скачивание на очки: ${msg.itemId} ${msg.fileName} ${msg.percent}% (${msg.phase})")
|
||||
}
|
||||
|
||||
is PlaybackPosition -> {
|
||||
|
||||
@@ -37,6 +37,7 @@ enum class Screen {
|
||||
CATALOG,
|
||||
DETAILS,
|
||||
GLASSES,
|
||||
GLASSES_INFO,
|
||||
CHAT,
|
||||
}
|
||||
|
||||
@@ -161,6 +162,18 @@ fun msToClock(ms: Long): String {
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s)
|
||||
}
|
||||
|
||||
/** Байты → «1.86 ГБ» / «65 МБ» / «--» если размер неизвестен. */
|
||||
fun bytesToSize(bytes: Long?): String {
|
||||
if (bytes == null || bytes <= 0) return "--"
|
||||
val gb = 1024.0 * 1024 * 1024
|
||||
val mb = 1024.0 * 1024
|
||||
return when {
|
||||
bytes >= gb -> "%.2f ГБ".format(bytes / gb)
|
||||
bytes >= mb -> "%d МБ".format((bytes / mb).toLong())
|
||||
else -> "%d КБ".format((bytes / 1024).toLong())
|
||||
}
|
||||
}
|
||||
|
||||
/** Заглушка-подложка скрина (loading / error / пусто). */
|
||||
@Composable
|
||||
fun ScreenPlaceholder(text: String, modifier: Modifier = Modifier) {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -41,11 +42,21 @@ 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.core.phone.trackFileName
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.phone.FileDownloadState
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.PhoneConfig
|
||||
import pw.binom.viewmate.phone.S3Presigner
|
||||
import pw.binom.viewmate.phone.fileKeys
|
||||
import pw.binom.viewmate.phone.finalFileName
|
||||
import pw.binom.viewmate.phone.glassesFileState
|
||||
import pw.binom.viewmate.phone.glassesOverallPercent
|
||||
import pw.binom.viewmate.phone.log
|
||||
import pw.binom.viewmate.phone.phoneFileState
|
||||
import pw.binom.viewmate.phone.resolveAudioSource
|
||||
import pw.binom.viewmate.phone.resolveAudioSourceDetailed
|
||||
import java.io.File
|
||||
|
||||
/** Экран 2 — Детали элемента: озвучка, статус конвертации, скачивание, кнопки. */
|
||||
@Composable
|
||||
@@ -63,11 +74,10 @@ fun DetailsScreen(
|
||||
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 activeIds by app.downloadManager.activeIds.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()) }
|
||||
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||
val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
|
||||
|
||||
// статус зеркала + дорожки; пока конвертируется — опрашиваем
|
||||
LaunchedEffect(item.Id, refreshKey) {
|
||||
@@ -82,23 +92,29 @@ fun DetailsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// файлы озвучки появляются после завершения загрузки на телефон
|
||||
LaunchedEffect(item.Id, downloadProgress) {
|
||||
audioDownloaded = app.downloadManager.audioFiles(item.Id).isNotEmpty()
|
||||
}
|
||||
|
||||
val dm = app.downloadManager
|
||||
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]
|
||||
// «качается» только если идёт активное скачивание; восстановленный из статуса .part — не качается
|
||||
val downloading = activeIds.contains(item.Id)
|
||||
val videoPart = app.downloadManager.partVideo(item.Id)
|
||||
val hasVideoPart = videoPart.length() > 0
|
||||
val audioPartFiles = app.downloadManager.audioParts(item.Id)
|
||||
|
||||
val videoPart = dm.partVideo(item.Id)
|
||||
val hasVideoPart = videoPart.isFile && videoPart.length() > 0
|
||||
val audioPartFiles = dm.audioParts(item.Id)
|
||||
val hasAudioPart = audioPartFiles.any { it.length() > 0 }
|
||||
val videoPartPercent = app.downloadManager.partPercent(item.Id, videoPart)
|
||||
val audioPartPercent = audioPartFiles.maxOfOrNull { app.downloadManager.partPercent(item.Id, it) } ?: 0
|
||||
val hasAnyPart = hasVideoPart || hasAudioPart
|
||||
val fullyDownloaded = dm.isFullyDownloaded(item.Id, files)
|
||||
val overallPartPercent = dm.overallPercent(item.Id)
|
||||
|
||||
// --- статус контента на очках (из расширенного GlassesStatus + DownloadProgress) ---
|
||||
val keys = files?.let { fileKeys(it) }.orEmpty()
|
||||
val fileNames = keys.map { finalFileName(it) }
|
||||
val knownSizeByName = dm.knownFileSizes(item.Id).mapKeys { (k, _) -> finalFileName(k) }
|
||||
val perFileProgress = glassesDownloads[item.Id].orEmpty()
|
||||
val glassesFully = glassesDownloadedIds.contains(item.Id)
|
||||
val downloadingOnGlasses = !glassesFully && perFileProgress.values.any { it.phase == DownloadPhase.DOWNLOADING }
|
||||
val glassesOverall = glassesOverallPercent(fileNames, knownSizeByName, perFileProgress)
|
||||
|
||||
fun onSelectAudio(index: Int) {
|
||||
audioIndex = index
|
||||
@@ -114,6 +130,86 @@ fun DetailsScreen(
|
||||
}
|
||||
|
||||
fun onWatch() {
|
||||
// звук уже играет — повторный тап не должен пересоздавать аудио-плеер.
|
||||
// state.playing в guard НЕ включаем: он остаётся true после рестарта телефона
|
||||
// (очки продолжают играть), когда аудио-плеер ещё не создан, и блокировал бы запуск звука.
|
||||
if (app.audioSync.isActive) {
|
||||
log("watch", "пропущено: аудио-плеер уже активен (audioSync.isActive=true)")
|
||||
scope.launch { snackbar.showSnackbar("уже играет") }
|
||||
return
|
||||
}
|
||||
if (!glassesFully) {
|
||||
log("watch", "пропущено: контент не скачан на очки (glassesFully=false)")
|
||||
scope.launch { snackbar.showSnackbar("сначала нажми «Скачать на очки»") }
|
||||
return
|
||||
}
|
||||
if (glassesMediaDir.isNullOrBlank()) {
|
||||
log("watch", "пропущено: очки ещё не сообщили каталог (mediaDir пуст)")
|
||||
scope.launch { snackbar.showSnackbar("очки ещё не сообщили каталог — подожди") }
|
||||
return
|
||||
}
|
||||
val mediaDir = glassesMediaDir!!
|
||||
scope.launch {
|
||||
if (app.server.hub.connected.value == 0) {
|
||||
log("watch", "пропущено: очки не подключены (connected=0)")
|
||||
snackbar.showSnackbar("очки не подключены")
|
||||
return@launch
|
||||
}
|
||||
val actions = app.actions
|
||||
// видео на очках уже играет (последний PlaybackPosition) — PlayVideo не пересылаем,
|
||||
// чтобы не перезапускать фильм: подключаем только звук с текущей позиции очков
|
||||
val alreadyPlaying = app.state.playing
|
||||
if (!alreadyPlaying && actions == null) {
|
||||
log("watch", "пропущено: телефон ещё не готов (actions=null)")
|
||||
snackbar.showSnackbar("телефон ещё не готов")
|
||||
return@launch
|
||||
}
|
||||
// PlayVideo — только локальный путь на очках, никаких S3-URL очкам
|
||||
val localPath = "file://$mediaDir/${item.Id}/video.mkv"
|
||||
val result: String
|
||||
if (alreadyPlaying) {
|
||||
result = "включил (видео уже играет на очках)"
|
||||
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
|
||||
} else {
|
||||
result = try {
|
||||
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath)
|
||||
} catch (e: Exception) {
|
||||
log("watch", "watch упал: ${e.message}")
|
||||
snackbar.showSnackbar("watch упал: ${e.message}")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
log("watch", "watch result: $result")
|
||||
if (result.contains("включил")) {
|
||||
// зеркало в композиции могло не догрузиться — подстрахуемся свежим статусом
|
||||
var mirrorFiles = files
|
||||
if (mirrorFiles == null) {
|
||||
mirrorFiles = runCatching { app.mirror.mirrorByItem(item.Id)?.files }.getOrNull()
|
||||
log("watch", "files в композиции не было — запросил зеркало заново: ${if (mirrorFiles == null) "всё ещё null" else "получено"}")
|
||||
}
|
||||
// звук — на телефоне: запускаем ту же дорожку (локально или стриминг)
|
||||
val audio = resolveAudioSourceDetailed(
|
||||
mirrorFiles = mirrorFiles,
|
||||
audioIndex = effectiveAudioIndex,
|
||||
localAudioFile = { dm.audioFile(item.Id, it) },
|
||||
)
|
||||
if (audio.source == null) {
|
||||
log("watch", "audio source: null — ${audio.reason}")
|
||||
snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}")
|
||||
} else {
|
||||
log("watch", "audio source: ${audio.source} (${audio.reason})")
|
||||
val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else 0L
|
||||
app.audioSync.play(audio.source, startPositionMs)
|
||||
}
|
||||
} else {
|
||||
log("watch", "watch отклонён телефоном: $result")
|
||||
snackbar.showSnackbar(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDownloadToGlasses() {
|
||||
if (glassesFully) return
|
||||
scope.launch {
|
||||
if (app.server.hub.connected.value == 0) {
|
||||
snackbar.showSnackbar("очки не подключены")
|
||||
@@ -124,33 +220,28 @@ fun DetailsScreen(
|
||||
snackbar.showSnackbar("телефон ещё не готов")
|
||||
return@launch
|
||||
}
|
||||
val result = actions.watch(item.Id, effectiveAudioIndex) {
|
||||
val sizes = dm.knownFileSizes(item.Id)
|
||||
val result = actions.downloadToGlasses(item.Id, sizes = sizes) {
|
||||
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
|
||||
}
|
||||
if (result.contains("включил")) {
|
||||
// звук — на телефоне: запускаем ту же дорожку (локально или стриминг)
|
||||
resolveAudioSource(app.downloadManager, item.Id, files, effectiveAudioIndex)?.let { source ->
|
||||
app.audioSync.play(source, 0L)
|
||||
}
|
||||
} else {
|
||||
snackbar.showSnackbar(result)
|
||||
}
|
||||
snackbar.showSnackbar(result)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDownloadToggle() {
|
||||
val dm = app.downloadManager
|
||||
when {
|
||||
// файл уже полный — не запускаем повторную качку
|
||||
dm.isDownloaded(item.Id) -> Unit
|
||||
// всё уже полное — не запускаем повторную качку
|
||||
dm.isFullyDownloaded(item.Id, files) -> Unit
|
||||
downloading -> dm.cancel(item.Id)
|
||||
else -> dm.startDownload(item.Id, effectiveAudioIndex)
|
||||
else -> dm.startDownload(item.Id)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDeleteDownloaded() {
|
||||
app.downloadManager.delete(item.Id)
|
||||
app.actions?.deleteDownloaded(item.Id)
|
||||
scope.launch {
|
||||
app.downloadManager.delete(item.Id)
|
||||
app.actions?.deleteDownloaded(item.Id)
|
||||
}
|
||||
}
|
||||
|
||||
fun onOrderMirror() {
|
||||
@@ -275,29 +366,41 @@ fun DetailsScreen(
|
||||
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 = !onPhoneVideo && hasVideoPart,
|
||||
percent = download?.percent ?: videoPartPercent,
|
||||
)
|
||||
}
|
||||
StatusLine("📱 Телефон — звук") {
|
||||
DeviceChip(
|
||||
downloaded = onPhoneAudio,
|
||||
downloading = !onPhoneAudio && hasAudioPart,
|
||||
percent = download?.percent ?: audioPartPercent,
|
||||
)
|
||||
if (keys.isEmpty()) {
|
||||
Text("нет дорожек зеркала", color = PhoneMuted, fontSize = 11.sp)
|
||||
} else {
|
||||
val trackFiles: Map<String, Pair<File, File>> = keys.associateWith { key ->
|
||||
if (key == "video") dm.videoFile(item.Id) to dm.partVideo(item.Id)
|
||||
else {
|
||||
val idx = key.removePrefix("audio-").toIntOrNull() ?: 0
|
||||
dm.audioFile(item.Id, idx) to dm.partAudio(item.Id, idx)
|
||||
}
|
||||
}
|
||||
keys.forEach { key ->
|
||||
val name = finalFileName(key)
|
||||
val (finalFile, partFile) = trackFiles[key] ?: return@forEach
|
||||
val phoneRaw = phoneFileState(finalFile, partFile)
|
||||
val phonePercent = if (phoneRaw.first == FileDownloadState.DOWNLOADING) {
|
||||
dm.percentForFile(item.Id, name) ?: dm.overallPercent(item.Id)
|
||||
} else {
|
||||
phoneRaw.second
|
||||
}
|
||||
val glassesState = if (glassesFully) {
|
||||
FileDownloadState.DONE to 100
|
||||
} else {
|
||||
glassesFileState(perFileProgress[name])
|
||||
}
|
||||
DownloadFileRow(
|
||||
name = name,
|
||||
sizeLabel = fileSizeOrProgress(
|
||||
sizeBytes = knownSizeByName[name],
|
||||
phone = phoneRaw.first to phonePercent,
|
||||
glasses = glassesState,
|
||||
),
|
||||
phone = phoneRaw.first to phonePercent,
|
||||
glasses = glassesState,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedButton(
|
||||
@@ -320,11 +423,37 @@ fun DetailsScreen(
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
||||
Button(
|
||||
onClick = ::onWatch,
|
||||
enabled = glassesFully,
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PhoneAccent, contentColor = PhoneBackground),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Text("▶ Смотреть на очках", fontSize = 13.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
when {
|
||||
downloadingOnGlasses -> "⏳ качается на очки, погоди"
|
||||
else -> "▶ Смотреть на очках"
|
||||
},
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Button(
|
||||
onClick = ::onDownloadToGlasses,
|
||||
enabled = !glassesFully && !downloadingOnGlasses,
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PhoneSurface, contentColor = PhoneTextDim),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
glassesFully -> "✓ скачано на очки"
|
||||
downloadingOnGlasses -> "⏳ качается на очки… ${glassesOverall ?: 0}%"
|
||||
glassesOverall != null -> "Продолжить на очки ($glassesOverall%)"
|
||||
else -> "Скачать на очки"
|
||||
},
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedButton(
|
||||
@@ -338,9 +467,9 @@ fun DetailsScreen(
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
localIds.contains(item.Id) -> "✓ скачано на телефон"
|
||||
fullyDownloaded -> "✓ скачано на телефон"
|
||||
downloading -> "Отменить"
|
||||
hasVideoPart -> "Продолжить ($videoPartPercent%)"
|
||||
hasAnyPart -> "Продолжить ($overallPartPercent%)"
|
||||
else -> "Скачать на телефон"
|
||||
},
|
||||
fontSize = 13.sp,
|
||||
@@ -356,12 +485,49 @@ fun DetailsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Метка «размер/прогресс» для строки файла: при качке — ⏳ N%, иначе размер («--», если размера ещё нет). */
|
||||
private fun fileSizeOrProgress(
|
||||
sizeBytes: Long?,
|
||||
phone: Pair<FileDownloadState, Int>,
|
||||
glasses: Pair<FileDownloadState, Int>,
|
||||
): String = when {
|
||||
phone.first == FileDownloadState.DOWNLOADING -> "⏳ ${phone.second}%"
|
||||
glasses.first == FileDownloadState.DOWNLOADING -> "⏳ ${glasses.second}%"
|
||||
else -> bytesToSize(sizeBytes)
|
||||
}
|
||||
|
||||
/** Строка файла: имя · размер, ниже — чипы «телефон» / «очки». */
|
||||
@Composable
|
||||
private fun DeviceChip(downloaded: Boolean, downloading: Boolean, percent: Int) {
|
||||
when {
|
||||
downloaded -> StatusChip("✓ скачано", PhoneGreen, PhoneGreenText)
|
||||
downloading -> StatusChip("↓ качается… $percent%", PhoneYellow, PhoneBackground)
|
||||
else -> StatusChip("нет", PhoneSurface, PhoneMuted)
|
||||
private fun DownloadFileRow(
|
||||
name: String,
|
||||
sizeLabel: String,
|
||||
phone: Pair<FileDownloadState, Int>,
|
||||
glasses: Pair<FileDownloadState, Int>,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp)
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("телефон", color = PhoneMuted, fontSize = 9.sp)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
FileChip(phone)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text("очки", color = PhoneMuted, fontSize = 9.sp)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
FileChip(glasses)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileChip(state: Pair<FileDownloadState, Int>) {
|
||||
when (state.first) {
|
||||
FileDownloadState.DONE -> StatusChip("✓", PhoneGreen, PhoneGreenText)
|
||||
FileDownloadState.DOWNLOADING -> StatusChip("⏳ ${state.second}%", PhoneYellow, PhoneBackground)
|
||||
FileDownloadState.FAILED -> StatusChip("✗", PhoneRed, Color.White)
|
||||
FileDownloadState.NONE -> StatusChip("нет", PhoneSurface, PhoneMuted)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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 pw.binom.viewmate.phone.PhoneApp
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/** Экран «Информация об очках»: модель, батарея, связь, диск (медиа/свободно/прочее), версия. */
|
||||
@Composable
|
||||
fun GlassesInfoScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val hub = PhoneApp.instance.server.hub
|
||||
|
||||
val connected by hub.connected.collectAsState()
|
||||
val battery by hub.batteryPercent.collectAsState()
|
||||
val totalBytes by hub.storageTotalBytes.collectAsState()
|
||||
val freeBytes by hub.storageFreeBytes.collectAsState()
|
||||
val mediaBytes by hub.mediaBytes.collectAsState()
|
||||
val appVersion by hub.appVersion.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Информация об очках",
|
||||
color = PhoneText,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
)
|
||||
|
||||
StatusBlock {
|
||||
InfoRow("Модель", "RayNeo X2")
|
||||
InfoRow(
|
||||
"Связь",
|
||||
if (connected > 0) "подключено (WiFi)" else "не подключено",
|
||||
)
|
||||
InfoRow("Батарея", "${battery ?: "--"}%")
|
||||
InfoRow("Версия приложения", appVersion?.takeIf { it.isNotBlank() } ?: "—")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// состояние диска: занято / свободно / занято медиа
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
||||
SectionLabel("диск очков")
|
||||
StatusBlock {
|
||||
val total = totalBytes
|
||||
val free = freeBytes
|
||||
val media = mediaBytes
|
||||
if (total == null || total <= 0) {
|
||||
Text("очки не сообщили память", color = PhoneMuted, fontSize = 11.sp)
|
||||
} else {
|
||||
val used = (total - (free ?: 0L)).coerceIn(0L, total)
|
||||
val mediaClamped = media?.coerceIn(0L, used) ?: 0L
|
||||
val other = (used - mediaClamped).coerceAtLeast(0L)
|
||||
|
||||
// «медиа / свободно / прочее»
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(10.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.background(PhoneLine),
|
||||
) {
|
||||
if (mediaClamped > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(mediaClamped.toFloat())
|
||||
.background(PhoneAccent),
|
||||
)
|
||||
}
|
||||
if (other > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(other.toFloat())
|
||||
.background(PhoneRed),
|
||||
)
|
||||
}
|
||||
if ((free ?: 0L) > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight((free ?: 0L).toFloat())
|
||||
.background(PhoneGreen),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
InfoRow("Занято", bytesToSize(used))
|
||||
InfoRow("Свободно", bytesToSize(free ?: 0L))
|
||||
InfoRow("Занято медиа", bytesToSize(mediaClamped))
|
||||
InfoRow("Прочее", bytesToSize(other))
|
||||
Text(
|
||||
text = "доля медиа на диске: ${percentOf(mediaClamped, total)}%",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun percentOf(part: Long, total: Long): Int {
|
||||
if (total <= 0) return 0
|
||||
return ((part * 100.0) / total).roundToLong().toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
color = PhoneText,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,15 @@ 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.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.glassesLivePercent
|
||||
|
||||
/** Экран 3 — Статус очков + пульт. */
|
||||
@Composable
|
||||
fun GlassesScreen() {
|
||||
fun GlassesScreen(
|
||||
onOpenInfo: () -> Unit,
|
||||
) {
|
||||
val app = PhoneApp.instance
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -54,12 +58,18 @@ fun GlassesScreen() {
|
||||
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()
|
||||
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||
|
||||
var showDownloads by remember { mutableStateOf(false) }
|
||||
|
||||
val actions = app.actions
|
||||
|
||||
// активное скачивание на очки: первое с незавершённым файлом
|
||||
val activeGlassesItem = glassesDownloads.entries.firstOrNull { (_, perFile) ->
|
||||
perFile.values.any { it.phase != DownloadPhase.DONE }
|
||||
}
|
||||
val activeGlassesPercent = activeGlassesItem?.let { (_, perFile) -> glassesLivePercent(perFile) }
|
||||
|
||||
fun sendCommand(cmd: MediaCommand, seekMs: Long = 0) {
|
||||
val a = actions ?: return
|
||||
if (connected == 0) return
|
||||
@@ -98,8 +108,20 @@ fun GlassesScreen() {
|
||||
audioPositionMs = audioPos,
|
||||
durationMs = currentItem?.RunTimeTicks?.div(10_000),
|
||||
playing = playing,
|
||||
onClick = onOpenInfo,
|
||||
)
|
||||
|
||||
// активная загрузка контента на очки
|
||||
if (activeGlassesItem != null) {
|
||||
Text(
|
||||
text = "загрузка на очки: ${activeGlassesPercent ?: 0}%",
|
||||
color = PhoneYellow,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -152,6 +174,7 @@ private fun GlassesStatusCard(
|
||||
audioPositionMs: Long,
|
||||
durationMs: Long?,
|
||||
playing: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -159,6 +182,7 @@ private fun GlassesStatusCard(
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(PhoneCard)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// связь
|
||||
@@ -279,14 +303,15 @@ private fun OcuRow(icon: String, content: @Composable () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Диалог: список скачанного на очках (downloaded → «очки») с удалением. */
|
||||
/** Диалог: список скачанного на очках (реальный статус от очков) с удалением. */
|
||||
@Composable
|
||||
private fun GlassesDownloadsDialog(
|
||||
app: PhoneApp,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val downloaded by app.state.downloadedFlow.collectAsState()
|
||||
val glassesIds = downloaded.filterValues { it == "очки" }.keys.toList()
|
||||
val scope = rememberCoroutineScope()
|
||||
val downloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||
val glassesIds = downloadedIds.toList()
|
||||
|
||||
var names by remember(glassesIds) { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
LaunchedEffect(glassesIds) {
|
||||
@@ -318,7 +343,9 @@ private fun GlassesDownloadsDialog(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = {
|
||||
app.actions?.deleteDownloaded(id)
|
||||
scope.launch {
|
||||
app.actions?.deleteDownloaded(id)
|
||||
}
|
||||
}) {
|
||||
Text("удалить", color = PhoneRedText, fontSize = 11.sp)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ fun ViewMateApp() {
|
||||
Scaffold(
|
||||
containerColor = PhoneBackground,
|
||||
bottomBar = {
|
||||
if (screen != Screen.DETAILS) {
|
||||
if (screen != Screen.DETAILS && screen != Screen.GLASSES_INFO) {
|
||||
BottomNavBar(current = screen) { screen = it }
|
||||
}
|
||||
},
|
||||
@@ -62,7 +62,8 @@ fun ViewMateApp() {
|
||||
}
|
||||
}
|
||||
|
||||
Screen.GLASSES -> GlassesScreen()
|
||||
Screen.GLASSES -> GlassesScreen(onOpenInfo = { screen = Screen.GLASSES_INFO })
|
||||
Screen.GLASSES_INFO -> GlassesInfoScreen(onBack = { screen = Screen.GLASSES })
|
||||
Screen.CHAT -> ChatScreen()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user