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)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,18 @@ class GlassesHub(
|
||||
private val _gesturesReceived = MutableStateFlow(0)
|
||||
val gesturesReceived: StateFlow<Int> = _gesturesReceived.asStateFlow()
|
||||
|
||||
private val _batteryPercent = MutableStateFlow<Int?>(null)
|
||||
val batteryPercent: StateFlow<Int?> = _batteryPercent.asStateFlow()
|
||||
|
||||
private val _storageUsedGb = MutableStateFlow<Double?>(null)
|
||||
val storageUsedGb: StateFlow<Double?> = _storageUsedGb.asStateFlow()
|
||||
|
||||
private val _storageTotalGb = MutableStateFlow<Double?>(null)
|
||||
val storageTotalGb: StateFlow<Double?> = _storageTotalGb.asStateFlow()
|
||||
|
||||
private val _positionMs = MutableStateFlow<Long?>(null)
|
||||
val positionMs: StateFlow<Long?> = _positionMs.asStateFlow()
|
||||
|
||||
fun add(session: DefaultWebSocketServerSession) {
|
||||
sessions[session] = Unit
|
||||
_connected.value = sessions.size
|
||||
@@ -88,10 +100,15 @@ class GlassesHub(
|
||||
}
|
||||
|
||||
is GlassesStatus -> {
|
||||
_batteryPercent.value = msg.batteryPercent
|
||||
_storageUsedGb.value = msg.storageUsedGb
|
||||
_storageTotalGb.value = msg.storageTotalGb
|
||||
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
|
||||
}
|
||||
|
||||
is PlaybackPosition -> {
|
||||
_positionMs.value = msg.positionMs
|
||||
this.state.playing = msg.playing
|
||||
val state = if (msg.playing) "играет" else "пауза"
|
||||
log("glasses", "позиция: ${msg.positionMs} мс, $state")
|
||||
}
|
||||
|
||||
@@ -3,42 +3,13 @@ package pw.binom.viewmate.phone
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import pw.binom.viewmate.phone.ui.ViewMateApp
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
ServerStatusScreen()
|
||||
}
|
||||
ViewMateApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerStatusScreen() {
|
||||
val app = PhoneApp.instance
|
||||
val connected by app.server.hub.connected.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("ViewMate Phone", style = MaterialTheme.typography.headlineMedium)
|
||||
Text("WS-сервер запущен")
|
||||
Text("Порт: ${PhoneConfig.SERVER_PORT}")
|
||||
Text("Путь: $GLASSES_WS_PATH")
|
||||
Text("Подключено очков: $connected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import android.app.Application
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.media.JellyfinClient
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
@@ -12,15 +15,27 @@ import pw.binom.viewmate.core.phone.PhoneState
|
||||
|
||||
/**
|
||||
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
|
||||
* WS-сервер (GlassesServer) и PhoneActions. Синглтон-доступ через companion.
|
||||
* WS-сервер (GlassesServer), DownloadManager и PhoneActions. Синглтон-доступ через companion.
|
||||
*/
|
||||
class PhoneApp : Application() {
|
||||
|
||||
lateinit var state: PhoneState
|
||||
private set
|
||||
lateinit var server: GlassesServer
|
||||
private set
|
||||
lateinit var actions: PhoneActions
|
||||
val state: PhoneState = PhoneState()
|
||||
|
||||
/** WS-сервер для очков. */
|
||||
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
|
||||
|
||||
/** HTTP-клиенты бэкендов (Jellyfin-каталог, media-mirror). */
|
||||
val jellyfin: JellyfinClient = JellyfinClient(PhoneConfig.JELLYFIN_URL, PhoneConfig.JELLYFIN_API_KEY)
|
||||
val mirror: MirrorClient = MirrorClient(PhoneConfig.MIRROR_URL, PhoneConfig.MIRROR_API_KEY)
|
||||
|
||||
private val _userId = MutableStateFlow("")
|
||||
/** Первый пользователь Jellyfin (подтягивается один раз при старте). */
|
||||
val userId: StateFlow<String> = _userId.asStateFlow()
|
||||
|
||||
/** Реальное скачивание зеркал на телефон (с прогрессом). */
|
||||
val downloadManager: DownloadManager by lazy { DownloadManager(this, mirror) }
|
||||
|
||||
var actions: PhoneActions? = null
|
||||
private set
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
@@ -29,18 +44,13 @@ class PhoneApp : Application() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
state = PhoneState()
|
||||
server = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
|
||||
|
||||
val jellyfin = JellyfinClient(PhoneConfig.JELLYFIN_URL, PhoneConfig.JELLYFIN_API_KEY)
|
||||
val mirror = MirrorClient(PhoneConfig.MIRROR_URL, PhoneConfig.MIRROR_API_KEY)
|
||||
|
||||
server.start()
|
||||
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
||||
|
||||
// первый пользователь Jellyfin — один раз при старте
|
||||
scope.launch {
|
||||
val userId = runCatching { jellyfin.users().firstOrNull()?.Id }.getOrNull()
|
||||
?: ""
|
||||
_userId.value = userId
|
||||
if (userId.isBlank()) log("app", "не удалось получить пользователя Jellyfin")
|
||||
actions = PhoneActions(
|
||||
jellyfin = jellyfin,
|
||||
|
||||
@@ -11,4 +11,9 @@ object PhoneConfig {
|
||||
const val MIRROR_URL = "https://mirror.binom.pw"
|
||||
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
||||
const val SERVER_PORT = 8080
|
||||
|
||||
// S3 SeaweedFS (bucket media, префикс mirror/): read-only identity mirror-client
|
||||
const val S3_ACCESS_KEY = "MirrorClient01QwertyKey"
|
||||
const val S3_SECRET_KEY = "MirrorClient01SecretKeyValueForGlassesOnly"
|
||||
const val S3_REGION = "us-east-1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Подпись GET-запросов к S3 SeaweedFS (bucket `media`, префикс `mirror/`) по AWS SigV4
|
||||
* в виде presigned URL (query-авторизация). Бакет закрыт на анонимное чтение, поэтому
|
||||
* клиент (очки/телефон) подписывает URL read-only ключом `mirror-client` из [PhoneConfig].
|
||||
*
|
||||
* Алгоритм (проверен 19.08: подписанный URL отдаёт 200):
|
||||
* - канонический запрос: GET\n{path}\n{query}\nhost:{host}\n\nhost\nUNSIGNED-PAYLOAD
|
||||
* - query-параметры X-Amz-* в алфавитном порядке, без URL-кодирования пробелов/`/`
|
||||
* - ключ подписи: HMAC-SHA256 цепочкой dateKey → regionKey → serviceKey → signingKey
|
||||
*/
|
||||
object S3Presigner {
|
||||
|
||||
private const val SERVICE = "s3"
|
||||
private const val HMAC_ALGORITHM = "HmacSHA256"
|
||||
|
||||
/** Подписать GET-запрос к [url] на [expiresSeconds] секунд (время берётся текущее UTC). */
|
||||
fun presignGet(
|
||||
url: String,
|
||||
accessKey: String,
|
||||
secretKey: String,
|
||||
region: String = "us-east-1",
|
||||
expiresSeconds: Int = 3600,
|
||||
): String = presignGetAt(url, accessKey, secretKey, region, expiresSeconds, nowUtc())
|
||||
|
||||
/** То же, но с фиксированным временем X-Amz-Date (для unit-теста с эталонной сигнатурой). */
|
||||
internal fun presignGetAt(
|
||||
url: String,
|
||||
accessKey: String,
|
||||
secretKey: String,
|
||||
region: String,
|
||||
expiresSeconds: Int,
|
||||
amzDate: String,
|
||||
): String {
|
||||
val parsed = URL(url)
|
||||
val host = parsed.host + if (parsed.port != -1) ":${parsed.port}" else ""
|
||||
val path = parsed.path.ifEmpty { "/" }
|
||||
|
||||
val date = amzDate.take(8)
|
||||
val query = sortedMapOf(
|
||||
"X-Amz-Algorithm" to "AWS4-HMAC-SHA256",
|
||||
"X-Amz-Credential" to "$accessKey/$date/$region/$SERVICE/aws4_request",
|
||||
"X-Amz-Date" to amzDate,
|
||||
"X-Amz-Expires" to expiresSeconds.toString(),
|
||||
"X-Amz-SignedHeaders" to "host",
|
||||
)
|
||||
val canonicalQuery = query.entries.joinToString("&") { (name, value) ->
|
||||
"${uriEncode(name)}=${uriEncode(value)}"
|
||||
}
|
||||
|
||||
val canonicalRequest = "GET\n$path\n$canonicalQuery\nhost:$host\n\nhost\nUNSIGNED-PAYLOAD"
|
||||
val scope = "$date/$region/$SERVICE/aws4_request"
|
||||
val stringToSign = "AWS4-HMAC-SHA256\n$amzDate\n$scope\n${sha256Hex(canonicalRequest)}"
|
||||
|
||||
val signingKey = deriveSigningKey(secretKey, date, region)
|
||||
val signature = hmacHex(signingKey, stringToSign)
|
||||
|
||||
return "$url?$canonicalQuery&X-Amz-Signature=$signature"
|
||||
}
|
||||
|
||||
private fun nowUtc(): String =
|
||||
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC).format(Instant.now())
|
||||
|
||||
/** RFC3986 URI-encode (без safe-символов): буквы/цифры/-_.~ оставить, остальное в %XX. */
|
||||
private fun uriEncode(value: String): String {
|
||||
val sb = StringBuilder()
|
||||
for (byte in value.toByteArray(Charsets.UTF_8)) {
|
||||
val c = byte.toInt() and 0xFF
|
||||
val ok = (c in 'A'.code..'Z'.code) || (c in 'a'.code..'z'.code) ||
|
||||
(c in '0'.code..'9'.code) || c == '-'.code || c == '_'.code ||
|
||||
c == '.'.code || c == '~'.code
|
||||
if (ok) sb.append(c.toChar()) else sb.append('%').append("%02X".format(c))
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun deriveSigningKey(secretKey: String, date: String, region: String): ByteArray {
|
||||
val dateKey = hmac("AWS4$secretKey".toByteArray(Charsets.UTF_8), date)
|
||||
val regionKey = hmac(dateKey, region)
|
||||
val serviceKey = hmac(regionKey, SERVICE)
|
||||
return hmac(serviceKey, "aws4_request")
|
||||
}
|
||||
|
||||
private fun hmac(key: ByteArray, data: String): ByteArray {
|
||||
val mac = Mac.getInstance(HMAC_ALGORITHM)
|
||||
mac.init(SecretKeySpec(key, HMAC_ALGORITHM))
|
||||
return mac.doFinal(data.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
private fun hmacHex(key: ByteArray, data: String): String = hmac(key, data).toHex()
|
||||
|
||||
private fun sha256Hex(data: String): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(data.toByteArray(Charsets.UTF_8)).toHex()
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
|
||||
/** Вкладки каталога: Фильмы / Сериалы / Папки. */
|
||||
enum class CatalogTab { MOVIES, SERIES, FOLDERS }
|
||||
|
||||
/**
|
||||
* Стейт экрана каталога: вкладки, поиск, вложенность (сериал→сезоны→эпизоды,
|
||||
* папки→папки) и бейджи зеркал. Живёт на уровне корня приложения, чтобы
|
||||
* переживать переходы на детали и обратно.
|
||||
*/
|
||||
@Stable
|
||||
class CatalogState(
|
||||
private val app: PhoneApp,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
sealed interface Path {
|
||||
data object Root : Path
|
||||
data class Series(val series: JellyfinItem) : Path
|
||||
data class Season(val season: JellyfinItem, val series: JellyfinItem) : Path
|
||||
data class Folder(val folder: JellyfinItem) : Path
|
||||
}
|
||||
|
||||
var tab by mutableStateOf(CatalogTab.MOVIES)
|
||||
private set
|
||||
var query by mutableStateOf("")
|
||||
private set
|
||||
var items by mutableStateOf<List<JellyfinItem>>(emptyList())
|
||||
private set
|
||||
var loading by mutableStateOf(false)
|
||||
private set
|
||||
var error by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var mirrors by mutableStateOf<Map<String, MirrorJob>>(emptyMap())
|
||||
private set
|
||||
var path by mutableStateOf<Path>(Path.Root)
|
||||
private set
|
||||
|
||||
private var searchJob: Job? = null
|
||||
private var loadJob: Job? = null
|
||||
|
||||
fun selectTab(tab: CatalogTab) {
|
||||
if (this.tab == tab) {
|
||||
// повторное нажатие на вкладку — принудительное обновление
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
this.tab = tab
|
||||
this.path = Path.Root
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun onQueryChange(q: String) {
|
||||
query = q
|
||||
searchJob?.cancel()
|
||||
searchJob = scope.launch {
|
||||
delay(300)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun openSeries(item: JellyfinItem) {
|
||||
path = Path.Series(item)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun openSeason(season: JellyfinItem, series: JellyfinItem) {
|
||||
path = Path.Season(season, series)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun openFolder(item: JellyfinItem) {
|
||||
path = Path.Folder(item)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun back() {
|
||||
path = when (val p = path) {
|
||||
Path.Root -> Path.Root
|
||||
is Path.Series -> Path.Root
|
||||
is Path.Season -> Path.Series(p.series)
|
||||
is Path.Folder -> Path.Root
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Заголовок под шапкой: текущая вкладка или хлебная крошка внутри сериала/папки. */
|
||||
fun subtitle(): String = when (val p = path) {
|
||||
Path.Root -> when (tab) {
|
||||
CatalogTab.MOVIES -> "Фильмы"
|
||||
CatalogTab.SERIES -> "Сериалы"
|
||||
CatalogTab.FOLDERS -> "Папки"
|
||||
}
|
||||
|
||||
is Path.Series -> p.series.Name ?: p.series.Id
|
||||
is Path.Season -> "${p.series.Name ?: p.series.Id} — ${p.season.Name ?: p.season.Id}"
|
||||
is Path.Folder -> p.folder.Name ?: p.folder.Id
|
||||
}
|
||||
|
||||
fun canGoBack(): Boolean = path != Path.Root
|
||||
|
||||
fun refresh() {
|
||||
loadJob?.cancel()
|
||||
val userId = app.userId.value
|
||||
if (userId.isBlank()) return
|
||||
loadJob = scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
val loaded = if (query.isNotBlank()) {
|
||||
app.jellyfin.search(userId, query.trim())
|
||||
} else {
|
||||
when (val p = path) {
|
||||
Path.Root -> when (tab) {
|
||||
CatalogTab.MOVIES -> app.jellyfin.allItems(userId, listOf("Movie"))
|
||||
CatalogTab.SERIES -> app.jellyfin.allItems(userId, listOf("Series"))
|
||||
CatalogTab.FOLDERS -> app.jellyfin.rootItems(userId, FOLDER_TYPES)
|
||||
}
|
||||
|
||||
is Path.Series -> app.jellyfin.items(userId, p.series.Id, listOf("Season"))
|
||||
is Path.Season -> app.jellyfin.items(userId, p.season.Id, listOf("Episode"))
|
||||
is Path.Folder -> app.jellyfin.items(userId, p.folder.Id, FOLDER_TYPES)
|
||||
}
|
||||
}
|
||||
items = loaded
|
||||
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "ошибка загрузки"
|
||||
items = emptyList()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Типы, показываемые при обходе папок Jellyfin (папки + медиа). */
|
||||
val FOLDER_TYPES = listOf(
|
||||
"CollectionFolder", "Folder", "Movie", "Series", "Season",
|
||||
"Episode", "Video", "MusicVideo", "BoxSet", "Audio",
|
||||
"PhotoAlbum", "Playlist", "Recording", "Book",
|
||||
)
|
||||
|
||||
/** Типы-«контейнеры»: клик заходит внутрь (папки/библиотеки/сезоны-коробки). */
|
||||
val CONTAINER_TYPES = listOf(
|
||||
"CollectionFolder", "Folder", "BoxSet", "Playlist", "PhotoAlbum", "Recording",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Экран 1 — Каталог Jellyfin: поиск, вкладки, сетка карточек с бейджами. */
|
||||
@Composable
|
||||
fun CatalogScreen(
|
||||
state: CatalogState,
|
||||
onItemClick: (JellyfinItem) -> Unit,
|
||||
) {
|
||||
val app = PhoneApp.instance
|
||||
val userId by app.userId.collectAsState()
|
||||
val downloaded by app.state.downloadedFlow.collectAsState()
|
||||
val localDownloaded by app.downloadManager.downloadedIds.collectAsState()
|
||||
|
||||
// подтянуть список, когда появился пользователь Jellyfin
|
||||
LaunchedEffect(userId) {
|
||||
if (userId.isNotBlank() && state.items.isEmpty()) state.refresh()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
CatalogTopBar(state = state)
|
||||
if (state.loading && state.items.isEmpty()) {
|
||||
ScreenPlaceholder("загружаю…")
|
||||
} else if (state.error != null && state.items.isEmpty()) {
|
||||
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
|
||||
} else if (state.items.isEmpty()) {
|
||||
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(state.items, key = { it.Id }) { item ->
|
||||
ItemCard(
|
||||
item = item,
|
||||
posterUrl = posterUrlOrNull(app, item),
|
||||
badge = mirrorBadge(
|
||||
itemId = item.Id,
|
||||
job = state.mirrors[item.Id],
|
||||
downloaded = downloaded,
|
||||
localDownloaded = localDownloaded,
|
||||
),
|
||||
onClick = {
|
||||
when {
|
||||
item.Type == "Series" -> state.openSeries(item)
|
||||
item.Type == "Season" -> {
|
||||
val series = when (val p = state.path) {
|
||||
is CatalogState.Path.Series -> p.series
|
||||
is CatalogState.Path.Season -> p.series
|
||||
else -> item
|
||||
}
|
||||
state.openSeason(item, series)
|
||||
}
|
||||
item.Type in CatalogState.CONTAINER_TYPES -> state.openFolder(item)
|
||||
else -> onItemClick(item)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogTopBar(state: CatalogState) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = state.query,
|
||||
onValueChange = state::onQueryChange,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
placeholder = { Text("🔍 поиск по каталогу…", fontSize = 13.sp, color = PhoneMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (state.canGoBack()) {
|
||||
TextButton(onClick = state::back) {
|
||||
Text("← назад", color = PhoneAccent, fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = state.subtitle(),
|
||||
color = PhoneTextDim,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
CatalogTab.entries.forEach { tab ->
|
||||
val active = state.tab == tab
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (active) PhoneAccent else PhoneSurface)
|
||||
.clickable { state.selectTab(tab) }
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = tabLabel(tab),
|
||||
color = if (active) PhoneBackground else PhoneMuted,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (active) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tabLabel(tab: CatalogTab): String = when (tab) {
|
||||
CatalogTab.MOVIES -> "Фильмы"
|
||||
CatalogTab.SERIES -> "Сериалы"
|
||||
CatalogTab.FOLDERS -> "Папки"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemCard(
|
||||
item: JellyfinItem,
|
||||
posterUrl: String?,
|
||||
badge: MirrorBadge,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(PhoneCard)
|
||||
.padding(8.dp)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Box {
|
||||
PosterImage(
|
||||
item = item,
|
||||
posterUrl = posterUrl,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(110.dp)
|
||||
.padding(bottom = 6.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(badge.bg)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
) {
|
||||
Text(
|
||||
text = badge.text,
|
||||
color = badge.fg,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.Name ?: item.Id,
|
||||
color = PhoneTextDim,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/** Экран 4 — Чат с ассистентом (заглушка: отдельный этап, Hermes-интеграция). */
|
||||
@Composable
|
||||
fun ChatScreen() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Чат с Порфирием",
|
||||
color = PhoneText,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "в разработке (следующий этап)",
|
||||
color = PhoneMuted,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
|
||||
/** Экраны приложения (простая навигация без nav-библиотеки). */
|
||||
enum class Screen {
|
||||
CATALOG,
|
||||
DETAILS,
|
||||
GLASSES,
|
||||
CHAT,
|
||||
}
|
||||
|
||||
private data class BottomTab(val screen: Screen, val icon: String, val label: String)
|
||||
|
||||
private val bottomTabs = listOf(
|
||||
BottomTab(Screen.CATALOG, "🎬", "Каталог"),
|
||||
BottomTab(Screen.GLASSES, "🕶️", "Очки"),
|
||||
BottomTab(Screen.CHAT, "💬", "Чат"),
|
||||
)
|
||||
|
||||
/** Нижняя навигация (Каталог / Очки / Чат). */
|
||||
@Composable
|
||||
fun BottomNavBar(current: Screen, onSelect: (Screen) -> Unit) {
|
||||
NavigationBar(containerColor = PhoneCard) {
|
||||
bottomTabs.forEach { tab ->
|
||||
NavigationBarItem(
|
||||
selected = current == tab.screen,
|
||||
onClick = { onSelect(tab.screen) },
|
||||
icon = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(tab.icon, fontSize = 16.sp)
|
||||
Text(
|
||||
tab.label,
|
||||
fontSize = 10.sp,
|
||||
color = if (current == tab.screen) PhoneAccent else PhoneMuted,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Бейдж статуса зеркала на карточке каталога. */
|
||||
data class MirrorBadge(val text: String, val bg: Color, val fg: Color)
|
||||
|
||||
/**
|
||||
* Статус зеркала элемента:
|
||||
* - ⟳ N% — конвертируется;
|
||||
* - ↓ часть — что-то скачано локально (телефон) или на очки;
|
||||
* - ✓ — зеркало готово;
|
||||
* - ✗ — ошибка;
|
||||
* - нет — ничего нет.
|
||||
*/
|
||||
fun mirrorBadge(
|
||||
itemId: String,
|
||||
job: MirrorJob?,
|
||||
downloaded: Map<String, String>,
|
||||
localDownloaded: Set<String>,
|
||||
): MirrorBadge {
|
||||
val status = job?.status
|
||||
val hasLocal = localDownloaded.contains(itemId)
|
||||
val hasAny = downloaded.containsKey(itemId)
|
||||
return when {
|
||||
status == "processing" || status == "queued" -> MirrorBadge(
|
||||
"⟳ ${job.progress ?: 0}%",
|
||||
PhoneYellow,
|
||||
PhoneBackground,
|
||||
)
|
||||
|
||||
status == "failed" || status == "error" -> MirrorBadge("✗", PhoneRed, Color.White)
|
||||
|
||||
hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText)
|
||||
|
||||
status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText)
|
||||
|
||||
else -> MirrorBadge("нет", Color(0xFF555555), PhoneTextDim)
|
||||
}
|
||||
}
|
||||
|
||||
/** Постер элемента: Coil по ImageTags.Primary, иначе градиент-заглушка с названием. */
|
||||
@Composable
|
||||
fun PosterImage(
|
||||
item: JellyfinItem,
|
||||
posterUrl: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (posterUrl != null) {
|
||||
AsyncImage(
|
||||
model = posterUrl,
|
||||
contentDescription = item.Name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
Brush.linearGradient(listOf(Color(0xFF2A2E38), Color(0xFF1D2026))),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = item.Name?.take(1)?.uppercase() ?: "?",
|
||||
color = Color(0xFF555555),
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** URL постера из item (null — если у элемента нет Primary-картинки). */
|
||||
fun posterUrlOrNull(app: pw.binom.viewmate.phone.PhoneApp, item: JellyfinItem): String? =
|
||||
if (item.ImageTags?.containsKey("Primary") == true) app.jellyfin.posterUrl(item.Id) else null
|
||||
|
||||
/** Длительность из RunTimeTicks → «2ч 46м». */
|
||||
fun ticksToDuration(ticks: Long?): String? {
|
||||
if (ticks == null || ticks <= 0) return null
|
||||
val totalSec = ticks / 10_000_000
|
||||
val h = totalSec / 3600
|
||||
val m = (totalSec % 3600) / 60
|
||||
return if (h > 0) "${h}ч ${m}м" else "${m}м"
|
||||
}
|
||||
|
||||
/** ms → «12:34» / «1:02:03». */
|
||||
fun msToClock(ms: Long): String {
|
||||
val totalSec = ms / 1000
|
||||
val h = totalSec / 3600
|
||||
val m = (totalSec % 3600) / 60
|
||||
val s = totalSec % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s)
|
||||
}
|
||||
|
||||
/** Заглушка-подложка скрина (loading / error / пусто). */
|
||||
@Composable
|
||||
fun ScreenPlaceholder(text: String, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = PhoneMuted,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Маленький чип статуса (✓ скачано / ↓ качается… N% / нет). */
|
||||
@Composable
|
||||
fun StatusChip(text: String, bg: Color, fg: Color) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(bg)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = fg,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Строка статуса «левая метка + чип справа» (для секции скачивания). */
|
||||
@Composable
|
||||
fun StatusLine(label: String, chip: @Composable () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = PhoneTextDim,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
chip()
|
||||
}
|
||||
}
|
||||
|
||||
/** Карточка-статус из макета (фон PhoneCard, скругление 10). */
|
||||
@Composable
|
||||
fun StatusBlock(content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(PhoneCard)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Кнопка-«чип» в пульте. */
|
||||
@Composable
|
||||
fun RemoteButton(
|
||||
text: String,
|
||||
big: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val bg = if (big) PhoneAccent else PhoneSurface
|
||||
val fg = if (big) PhoneBackground else PhoneTextDim
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(if (big) 56.dp else 44.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bg)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = fg,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (big) FontWeight.Bold else FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Полоса прогресса из макета. */
|
||||
@Composable
|
||||
fun ProgressBar(percent: Int, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(PhoneLine),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(percent.coerceIn(0, 100) / 100f)
|
||||
.background(PhoneAccent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.5.sp,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
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.fillMaxSize
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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 kotlinx.coroutines.delay
|
||||
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.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.PhoneConfig
|
||||
import pw.binom.viewmate.phone.S3Presigner
|
||||
|
||||
/** Экран 2 — Детали элемента: озвучка, статус конвертации, скачивание, кнопки. */
|
||||
@Composable
|
||||
fun DetailsScreen(
|
||||
item: JellyfinItem,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val app = PhoneApp.instance
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
var mirrorStatus by remember(item.Id) { mutableStateOf<MirrorStatus?>(null) }
|
||||
var mirrorJob by remember(item.Id) { mutableStateOf<MirrorJob?>(null) }
|
||||
var loading by remember(item.Id) { mutableStateOf(true) }
|
||||
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 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()) }
|
||||
|
||||
// статус зеркала + дорожки; пока конвертируется — опрашиваем
|
||||
LaunchedEffect(item.Id, refreshKey) {
|
||||
loading = true
|
||||
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
|
||||
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
|
||||
loading = false
|
||||
while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") {
|
||||
delay(3_000)
|
||||
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
|
||||
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
// файлы озвучки появляются после завершения загрузки на телефон
|
||||
LaunchedEffect(item.Id, downloadProgress) {
|
||||
audioDownloaded = app.downloadManager.audioFiles(item.Id).isNotEmpty()
|
||||
}
|
||||
|
||||
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]
|
||||
val downloading = download?.phase == DownloadPhase.DOWNLOADING
|
||||
|
||||
fun onSelectAudio(index: Int) {
|
||||
audioIndex = index
|
||||
app.state.currentAudioIndex = index
|
||||
}
|
||||
|
||||
fun onWatch() {
|
||||
scope.launch {
|
||||
if (app.server.hub.connected.value == 0) {
|
||||
snackbar.showSnackbar("очки не подключены")
|
||||
return@launch
|
||||
}
|
||||
val actions = app.actions
|
||||
if (actions == null) {
|
||||
snackbar.showSnackbar("телефон ещё не готов")
|
||||
return@launch
|
||||
}
|
||||
val result = actions.watch(item.Id, effectiveAudioIndex) {
|
||||
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
|
||||
}
|
||||
if (!result.contains("включил")) snackbar.showSnackbar(result)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDownloadToggle() {
|
||||
val dm = app.downloadManager
|
||||
if (downloading) dm.cancel(item.Id)
|
||||
else dm.startDownload(item.Id, effectiveAudioIndex)
|
||||
}
|
||||
|
||||
fun onDeleteDownloaded() {
|
||||
app.downloadManager.delete(item.Id)
|
||||
app.actions?.deleteDownloaded(item.Id)
|
||||
}
|
||||
|
||||
fun onOrderMirror() {
|
||||
scope.launch {
|
||||
runCatching { app.mirror.createMirror(item.Id) }
|
||||
.onSuccess { refreshKey++ }
|
||||
.onFailure { e -> snackbar.showSnackbar("ошибка заказа: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.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),
|
||||
)
|
||||
}
|
||||
|
||||
PosterImage(
|
||||
item = item,
|
||||
posterUrl = posterUrlOrNull(app, item),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp)
|
||||
.height(150.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = item.Name ?: item.Id,
|
||||
color = PhoneText,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = detailMeta(item),
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(horizontal = 14.dp),
|
||||
)
|
||||
item.Overview?.takeIf { it.isNotBlank() }?.let { overview ->
|
||||
Text(
|
||||
text = overview,
|
||||
color = PhoneTextDim,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// 1 · выбор озвучки
|
||||
if (audios.isNotEmpty()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
||||
SectionLabel("выбор озвучки")
|
||||
audios.forEach { audio ->
|
||||
val selected = audio.index == effectiveAudioIndex
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 6.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(PhoneCard)
|
||||
.clickable { onSelectAudio(audio.index) }
|
||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) PhoneAccent else Color.Transparent)
|
||||
.padding(2.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) PhoneAccent else PhoneSurface),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = audio.title ?: "Дорожка ${audio.index}",
|
||||
color = if (selected) PhoneText else PhoneTextDim,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f).padding(start = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = audio.language ?: "",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2 · статус конвертации
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
||||
SectionLabel("статус конвертации")
|
||||
ConversionStatus(
|
||||
loading = loading,
|
||||
status = mirrorStatus,
|
||||
job = mirrorJob,
|
||||
onOrder = ::onOrderMirror,
|
||||
)
|
||||
}
|
||||
|
||||
// 3 · скачивание на устройства
|
||||
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 = downloading && download?.stage == "видео",
|
||||
percent = download?.percent ?: 0,
|
||||
)
|
||||
}
|
||||
StatusLine("📱 Телефон — звук") {
|
||||
DeviceChip(
|
||||
downloaded = onPhoneAudio,
|
||||
downloading = downloading && download?.stage == "звук",
|
||||
percent = download?.percent ?: 0,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedButton(
|
||||
onClick = ::onDeleteDownloaded,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = PhoneDangerBg,
|
||||
contentColor = PhoneRedText,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Text("Удалить скачанное (очки + телефон)", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// главные действия
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
||||
Button(
|
||||
onClick = ::onWatch,
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PhoneAccent, contentColor = PhoneBackground),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Text("▶ Смотреть на очках", fontSize = 13.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedButton(
|
||||
onClick = ::onDownloadToggle,
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = PhoneSurface,
|
||||
contentColor = PhoneTextDim,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Text(
|
||||
if (downloading) "Отменить" else "Скачать на телефон",
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SnackbarHost(
|
||||
hostState = snackbar,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceChip(downloaded: Boolean, downloading: Boolean, percent: Int) {
|
||||
when {
|
||||
downloaded -> StatusChip("✓ скачано", PhoneGreen, PhoneGreenText)
|
||||
downloading -> StatusChip("↓ качается… $percent%", PhoneYellow, PhoneBackground)
|
||||
else -> StatusChip("нет", PhoneSurface, PhoneMuted)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConversionStatus(
|
||||
loading: Boolean,
|
||||
status: MirrorStatus?,
|
||||
job: MirrorJob?,
|
||||
onOrder: () -> Unit,
|
||||
) {
|
||||
when {
|
||||
loading -> StatusBlock { Text("проверяю зеркало…", color = PhoneMuted, fontSize = 11.sp) }
|
||||
|
||||
status == null -> StatusBlock {
|
||||
Text("Зеркало: нет", color = PhoneTextDim, fontSize = 11.sp)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedButton(
|
||||
onClick = onOrder,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(containerColor = PhoneSurface, contentColor = PhoneTextDim),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Text("Заказать конвертацию", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
status.status == "done" -> StatusBlock {
|
||||
Text("✓ зеркало готово", color = PhoneGreenText, fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
status.status == "processing" || status.status == "queued" -> StatusBlock {
|
||||
val percent = job?.progress ?: 0
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Конвертация зеркала",
|
||||
color = PhoneTextDim,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(text = "⟳ $percent%", color = PhoneYellow, fontSize = 11.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
ProgressBar(percent = percent, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
status.status == "failed" || status.status == "error" -> StatusBlock {
|
||||
Text("конвертация упала", color = PhoneRedText, fontSize = 11.sp)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedButton(
|
||||
onClick = onOrder,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(containerColor = PhoneSurface, contentColor = PhoneTextDim),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Text("Перезаказать", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
else -> StatusBlock { Text("статус: ${status.status}", color = PhoneMuted, fontSize = 11.sp) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun detailMeta(item: JellyfinItem): String {
|
||||
val parts = mutableListOf<String>()
|
||||
when (item.Type) {
|
||||
"Episode" -> {
|
||||
// S01E05 · Название сериала
|
||||
val s = item.ParentIndexNumber?.let { "S%02d".format(it) } ?: ""
|
||||
val e = item.IndexNumber?.let { "E%02d".format(it) } ?: ""
|
||||
val label = (s + e).ifEmpty { "Эпизод" }
|
||||
parts += label
|
||||
item.SeriesName?.let { parts += it }
|
||||
}
|
||||
|
||||
else -> {
|
||||
parts += when (item.Type) {
|
||||
"Movie" -> "Фильм"
|
||||
"Series" -> "Сериал"
|
||||
"Season" -> "Сезон"
|
||||
"Folder", "CollectionFolder" -> "Папка"
|
||||
else -> item.Type ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
ticksToDuration(item.RunTimeTicks)?.let { parts += it }
|
||||
return parts.filter { it.isNotBlank() }.joinToString(" · ")
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.fillMaxSize
|
||||
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.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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 kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
|
||||
/** Экран 3 — Статус очков + пульт. */
|
||||
@Composable
|
||||
fun GlassesScreen() {
|
||||
val app = PhoneApp.instance
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val connected by app.server.hub.connected.collectAsState()
|
||||
val battery by app.server.hub.batteryPercent.collectAsState()
|
||||
val storageUsed by app.server.hub.storageUsedGb.collectAsState()
|
||||
val storageTotal by app.server.hub.storageTotalGb.collectAsState()
|
||||
val positionMs by app.server.hub.positionMs.collectAsState()
|
||||
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()
|
||||
|
||||
var showDownloads by remember { mutableStateOf(false) }
|
||||
|
||||
val actions = app.actions
|
||||
|
||||
fun sendCommand(cmd: MediaCommand, seekMs: Long = 0) {
|
||||
val a = actions ?: return
|
||||
if (connected == 0) return
|
||||
scope.launch { a.applyMediaCommand(cmd, seekMs) }
|
||||
}
|
||||
|
||||
fun applyMode(target: GlassesMode) {
|
||||
val a = actions ?: return
|
||||
if (connected == 0) return
|
||||
scope.launch { a.applyMode(target) }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
GlassesStatusCard(
|
||||
connected = connected > 0,
|
||||
battery = battery,
|
||||
storageUsed = storageUsed,
|
||||
storageTotal = storageTotal,
|
||||
currentItemName = currentItem?.Name,
|
||||
positionMs = positionMs,
|
||||
durationMs = currentItem?.RunTimeTicks?.div(10_000),
|
||||
playing = playing,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton("⏪ −30с") { sendCommand(MediaCommand.SEEK_BACKWARD, 30_000) }
|
||||
RemoteButton("▶ Плей") { sendCommand(MediaCommand.PLAY) }
|
||||
RemoteButton("🔀 Режим: Кино") { applyMode(GlassesMode.MOVIE) }
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton("⏩ +30с") { sendCommand(MediaCommand.SEEK_FORWARD, 30_000) }
|
||||
RemoteButton("⏸ Пауза") { sendCommand(MediaCommand.PAUSE) }
|
||||
RemoteButton("💬 Режим: Чат") { applyMode(GlassesMode.CHAT) }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
RemoteButton(
|
||||
text = "Скачанное на очках →",
|
||||
big = true,
|
||||
onClick = { showDownloads = true },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Текущий режим очков: ${if (mode == GlassesMode.MOVIE) "Кино" else "Чат"}",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (showDownloads) {
|
||||
GlassesDownloadsDialog(
|
||||
app = app,
|
||||
onDismiss = { showDownloads = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlassesStatusCard(
|
||||
connected: Boolean,
|
||||
battery: Int?,
|
||||
storageUsed: Double?,
|
||||
storageTotal: Double?,
|
||||
currentItemName: String?,
|
||||
positionMs: Long?,
|
||||
durationMs: Long?,
|
||||
playing: Boolean,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(PhoneCard)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// связь
|
||||
OcuRow(icon = "🕶️") {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Связь с очками", color = PhoneMuted, fontSize = 11.sp)
|
||||
Text(
|
||||
if (connected) "подключено (WiFi)" else "не подключено",
|
||||
color = PhoneText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
StatusChip(
|
||||
text = "●",
|
||||
bg = if (connected) PhoneGreen else Color(0xFF555555),
|
||||
fg = if (connected) PhoneGreenText else PhoneTextDim,
|
||||
)
|
||||
}
|
||||
|
||||
// батарея
|
||||
OcuRow(icon = "🔋") {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Батарея очков", color = PhoneMuted, fontSize = 11.sp)
|
||||
Text("${battery ?: "--"}%", color = PhoneText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(70.dp)
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(PhoneLine),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth((battery ?: 0).coerceIn(0, 100) / 100f)
|
||||
.height(8.dp)
|
||||
.background(PhoneGreen),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// память
|
||||
OcuRow(icon = "💾") {
|
||||
val used = storageUsed
|
||||
val total = storageTotal
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Память очков", color = PhoneMuted, fontSize = 11.sp)
|
||||
Text(
|
||||
if (used != null && total != null)
|
||||
"%.1f ГБ из %.1f ГБ занято".format(used, total)
|
||||
else "—",
|
||||
color = PhoneText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(70.dp)
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(PhoneLine),
|
||||
) {
|
||||
val fill = if (used != null && total != null && total > 0) (used / total).toFloat() else 0f
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fill.coerceIn(0f, 1f))
|
||||
.height(8.dp)
|
||||
.background(PhoneRed),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// сейчас на очках
|
||||
OcuRow(icon = "🎬") {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Сейчас на очках", color = PhoneMuted, fontSize = 11.sp)
|
||||
val pos = positionMs?.let { msToClock(it) } ?: "--:--"
|
||||
val dur = durationMs?.let { msToClock(it) } ?: "--:--"
|
||||
val name = currentItemName ?: "—"
|
||||
Text("$name · $pos / $dur", color = PhoneText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
StatusChip(
|
||||
text = if (playing) "плей" else "пауза",
|
||||
bg = if (playing) PhoneBlue else PhoneSurface,
|
||||
fg = if (playing) PhoneBlueText else PhoneMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OcuRow(icon: String, content: @Composable () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(PhoneSurface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(icon, fontSize = 16.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Диалог: список скачанного на очках (downloaded → «очки») с удалением. */
|
||||
@Composable
|
||||
private fun GlassesDownloadsDialog(
|
||||
app: PhoneApp,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val downloaded by app.state.downloadedFlow.collectAsState()
|
||||
val glassesIds = downloaded.filterValues { it == "очки" }.keys.toList()
|
||||
|
||||
var names by remember(glassesIds) { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
LaunchedEffect(glassesIds) {
|
||||
val userId = app.userId.value
|
||||
if (userId.isBlank()) return@LaunchedEffect
|
||||
names = glassesIds.associateWith { id ->
|
||||
runCatching { app.jellyfin.item(userId, id)?.Name ?: id }.getOrDefault(id)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = PhoneCard,
|
||||
title = { Text("Скачанное на очках", color = PhoneText, fontSize = 15.sp) },
|
||||
text = {
|
||||
if (glassesIds.isEmpty()) {
|
||||
Text("ничего", color = PhoneMuted, fontSize = 12.sp)
|
||||
} else {
|
||||
Column {
|
||||
glassesIds.forEach { id ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = names[id] ?: id,
|
||||
color = PhoneTextDim,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = {
|
||||
app.actions?.deleteDownloaded(id)
|
||||
}) {
|
||||
Text("удалить", color = PhoneRedText, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("закрыть", color = PhoneAccent, fontSize = 12.sp)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Цвета из макета sketches/phone-ui
|
||||
val PhoneBackground = Color(0xFF1B1D22)
|
||||
val PhoneCard = Color(0xFF181A1F)
|
||||
val PhoneSurface = Color(0xFF23262E)
|
||||
val PhoneAccent = Color(0xFFFFB454)
|
||||
val PhoneText = Color(0xFFE8E8E8)
|
||||
val PhoneTextDim = Color(0xFFDDDDDD)
|
||||
val PhoneMuted = Color(0xFF888888)
|
||||
val PhoneLine = Color(0xFF2A2D35)
|
||||
val PhoneGreen = Color(0xFF2E7D32)
|
||||
val PhoneGreenText = Color(0xFFC8FFC8)
|
||||
val PhoneYellow = Color(0xFFF9A825)
|
||||
val PhoneBlue = Color(0xFF0277BD)
|
||||
val PhoneBlueText = Color(0xFFD3F0FF)
|
||||
val PhoneRed = Color(0xFFE53935)
|
||||
val PhoneRedText = Color(0xFFFF8A93)
|
||||
val PhoneDangerBg = Color(0xFF4A1F24)
|
||||
|
||||
private val ViewMateColorScheme = darkColorScheme(
|
||||
primary = PhoneAccent,
|
||||
onPrimary = PhoneBackground,
|
||||
secondary = PhoneAccent,
|
||||
onSecondary = PhoneBackground,
|
||||
background = PhoneBackground,
|
||||
onBackground = PhoneText,
|
||||
surface = PhoneCard,
|
||||
onSurface = PhoneText,
|
||||
surfaceVariant = PhoneSurface,
|
||||
onSurfaceVariant = PhoneMuted,
|
||||
outline = PhoneLine,
|
||||
outlineVariant = PhoneLine,
|
||||
error = PhoneRed,
|
||||
onError = Color.White,
|
||||
primaryContainer = PhoneSurface,
|
||||
onPrimaryContainer = PhoneText,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PhoneTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = ViewMateColorScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
|
||||
/**
|
||||
* Корень UI телефона: простая навигация между экранами (без nav-библиотеки)
|
||||
* + нижняя панель Каталог / Очки / Чат. Экран деталей — поверх (кнопка назад).
|
||||
*/
|
||||
@Composable
|
||||
fun ViewMateApp() {
|
||||
val app = PhoneApp.instance
|
||||
val scope = rememberCoroutineScope()
|
||||
val catalog = remember { CatalogState(app, scope) }
|
||||
|
||||
var screen by rememberSaveable { mutableStateOf(Screen.CATALOG) }
|
||||
var selected by remember { mutableStateOf<JellyfinItem?>(null) }
|
||||
|
||||
PhoneTheme {
|
||||
Scaffold(
|
||||
containerColor = PhoneBackground,
|
||||
bottomBar = {
|
||||
if (screen != Screen.DETAILS) {
|
||||
BottomNavBar(current = screen) { screen = it }
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
when (screen) {
|
||||
Screen.CATALOG -> CatalogScreen(
|
||||
state = catalog,
|
||||
onItemClick = {
|
||||
selected = it
|
||||
screen = Screen.DETAILS
|
||||
},
|
||||
)
|
||||
|
||||
Screen.DETAILS -> {
|
||||
val item = selected
|
||||
if (item != null) {
|
||||
DetailsScreen(item = item, onBack = { screen = Screen.CATALOG })
|
||||
} else {
|
||||
CatalogScreen(
|
||||
state = catalog,
|
||||
onItemClick = {
|
||||
selected = it
|
||||
screen = Screen.DETAILS
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Screen.GLASSES -> GlassesScreen()
|
||||
Screen.CHAT -> ChatScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user