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