feat: сетевая часть для app-phone (WS-сервер) и app-glasses (WS-клиент), PhoneState/PhoneActions → lib-core
lib-core: - core/phone/: PhoneState, PhoneActions, GlassesSender (интерфейс) — перенесены из app-host - core/net/: GlassesWsClient — KMP WS-клиент очков (реконнект, Hello, send GlassesToHost) - GlassesWsClientTest + PhoneActionsTest (перенесён) app-phone (Android): - GlassesServer: Ktor WS-сервер (CIO) 0.0.0.0:8080/ws/glasses, принимает очки, Welcome из стейта - PhoneApp (Application): клиенты, стейт, действия, сервер - PhoneConfig: креды Jellyfin/Mirror - MainActivity: статус сервера (порт, счётчик подключений) - GlassesServerTest: hello→welcome, gesture app-glasses (Android): - GlassesApp (Application), HostConnection: WS-клиент к телефону, Hello, реконнект, обработка HostToGlasses (PlayVideo/SetMode и т.д.), StateFlow для UI - MainActivity: статус подключения app-host: PhoneActions/PhoneState удалены (теперь lib-core), адаптирован под GlassesSender 171 тест, 0 провалов; app-host жив (catalog/watch/status работают через lib-core)
This commit is contained in:
@@ -7,6 +7,8 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
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.GlassesOff
|
||||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
@@ -24,7 +26,7 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
open class GlassesHub(
|
||||
private val json: Json,
|
||||
private val state: PhoneState = PhoneState(),
|
||||
) {
|
||||
) : GlassesSender {
|
||||
|
||||
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
|
||||
|
||||
@@ -69,6 +71,9 @@ open class GlassesHub(
|
||||
}
|
||||
}
|
||||
|
||||
/** GlassesSender: то же, что [broadcast]. */
|
||||
override suspend fun send(msg: HostToGlasses) = broadcast(msg)
|
||||
|
||||
/** Сменить режим очков и разослать SetMode. */
|
||||
open suspend fun setMode(mode: GlassesMode) {
|
||||
_mode.value = mode
|
||||
|
||||
@@ -10,12 +10,11 @@ import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.media.JellyfinClient
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
import pw.binom.viewmate.core.phone.PhoneActions
|
||||
import pw.binom.viewmate.core.phone.PhoneState
|
||||
import pw.binom.viewmate.core.protocol.AssistantState
|
||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
import pw.binom.viewmate.core.protocol.ShowText
|
||||
|
||||
const val HOST_PORT = 8080
|
||||
|
||||
@@ -23,28 +22,36 @@ fun main() {
|
||||
val config = Config.load()
|
||||
log("config", "Jellyfin: ${config.jellyfinUrl}, Mirror: ${config.mirrorUrl}")
|
||||
|
||||
val state = PhoneState()
|
||||
val hub = GlassesHub(hostJson, state)
|
||||
val actions = PhoneActions(
|
||||
jellyfin = JellyfinClient(config.jellyfinUrl, config.jellyfinApiKey),
|
||||
mirror = MirrorClient(config.mirrorUrl, config.mirrorApiKey),
|
||||
hub = hub,
|
||||
state = state,
|
||||
json = hostJson,
|
||||
)
|
||||
|
||||
val server = embeddedServer(Netty, port = HOST_PORT, host = "0.0.0.0") {
|
||||
hostModule(hub)
|
||||
}
|
||||
server.start(wait = false)
|
||||
log("server", "ViewMate Host слушает ws://0.0.0.0:$HOST_PORT$GLASSES_WS_PATH")
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
server.stop(1_000, 3_000)
|
||||
log("server", "остановлен")
|
||||
})
|
||||
|
||||
runBlocking {
|
||||
val jellyfin = JellyfinClient(config.jellyfinUrl, config.jellyfinApiKey)
|
||||
val mirror = MirrorClient(config.mirrorUrl, config.mirrorApiKey)
|
||||
|
||||
// первый пользователь Jellyfin — один раз при старте
|
||||
val userId = jellyfin.users().firstOrNull()?.Id
|
||||
?: throw IllegalStateException("нет пользователей в Jellyfin")
|
||||
log("config", "пользователь Jellyfin: $userId")
|
||||
|
||||
val state = PhoneState()
|
||||
val hub = GlassesHub(hostJson, state)
|
||||
val actions = PhoneActions(
|
||||
jellyfin = jellyfin,
|
||||
mirror = mirror,
|
||||
sender = hub,
|
||||
state = state,
|
||||
userId = userId,
|
||||
)
|
||||
|
||||
val server = embeddedServer(Netty, port = HOST_PORT, host = "0.0.0.0") {
|
||||
hostModule(hub)
|
||||
}
|
||||
server.start(wait = false)
|
||||
log("server", "ViewMate Host слушает ws://0.0.0.0:$HOST_PORT$GLASSES_WS_PATH")
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
server.stop(1_000, 3_000)
|
||||
log("server", "остановлен")
|
||||
})
|
||||
|
||||
launch {
|
||||
hub.connected.collectLatest { count ->
|
||||
log("hub", "подключено очков: $count")
|
||||
@@ -85,20 +92,20 @@ private suspend fun handleConsoleCommand(actions: PhoneActions, hub: GlassesHub,
|
||||
"mode" -> {
|
||||
val mode = parseEnum<GlassesMode>(parts.getOrNull(1))
|
||||
if (mode == null) return logUsage("mode MOVIE|CHAT")
|
||||
hub.setMode(mode)
|
||||
out(actions.applyMode(mode))
|
||||
}
|
||||
|
||||
"state" -> {
|
||||
val state = parseEnum<AssistantState>(parts.getOrNull(1))
|
||||
if (state == null) return logUsage("state IDLE|LISTENING|THINKING|SPEAKING [seconds]")
|
||||
val seconds = parts.getOrNull(2)?.toIntOrNull() ?: 0
|
||||
hub.broadcast(AssistantStateMsg(state = state, recordingSeconds = seconds))
|
||||
out(actions.applyAssistantState(state, seconds))
|
||||
}
|
||||
|
||||
"text" -> {
|
||||
val text = parts.drop(1).joinToString(" ")
|
||||
if (text.isEmpty()) return logUsage("text <текст>")
|
||||
hub.broadcast(ShowText(text = text))
|
||||
out(actions.applyText(text))
|
||||
}
|
||||
|
||||
"session" -> {
|
||||
@@ -117,7 +124,7 @@ private suspend fun handleConsoleCommand(actions: PhoneActions, hub: GlassesHub,
|
||||
val command = parseEnum<MediaCommand>(parts.getOrNull(1))
|
||||
if (command == null) return logUsage("cmd PLAY|PAUSE|TOGGLE|SEEK_FORWARD|SEEK_BACKWARD|NEXT_AUDIO|PREV_AUDIO|CLOSE [seekMs]")
|
||||
val seekMs = parts.getOrNull(2)?.toLongOrNull() ?: 0
|
||||
hub.broadcast(MediaCommandMsg(command = command, seekMs = seekMs))
|
||||
out(actions.applyMediaCommand(command, seekMs))
|
||||
}
|
||||
|
||||
"catalog" -> out(actions.catalog(parts.getOrNull(1)))
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
package pw.binom.viewmate.host
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.media.ApiClientException
|
||||
import pw.binom.viewmate.core.media.JellyfinClient
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import pw.binom.viewmate.core.media.MirrorStatus
|
||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
|
||||
/**
|
||||
* Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки.
|
||||
* Каждый метод возвращает человекочитаемый текст для консоли
|
||||
* и дёргает hub для реальных команд очкам.
|
||||
*/
|
||||
class PhoneActions(
|
||||
private val jellyfin: JellyfinClient,
|
||||
private val mirror: MirrorClient,
|
||||
private val hub: GlassesHub,
|
||||
private val state: PhoneState,
|
||||
private val json: Json,
|
||||
) {
|
||||
|
||||
private var cachedUserId: String? = null
|
||||
|
||||
private suspend fun userId(): String {
|
||||
cachedUserId?.let { return it }
|
||||
val users = jellyfin.users()
|
||||
val id = users.firstOrNull()?.Id
|
||||
?: throw ApiClientException("нет пользователей в Jellyfin")
|
||||
cachedUserId = id
|
||||
return id
|
||||
}
|
||||
|
||||
// --- каталог ---
|
||||
|
||||
/** Корень Jellyfin или содержимое папки, с бейджами статусов зеркал. */
|
||||
suspend fun catalog(parentId: String? = null): String {
|
||||
val uid = userId()
|
||||
val items = if (parentId == null) jellyfin.rootItems(uid) else jellyfin.items(uid, parentId)
|
||||
val mirrors = mirror.list(limit = 500).associateBy { it.itemId }
|
||||
|
||||
val rows = items.map { item -> catalogLine(item, mirrors[item.Id]) }
|
||||
val width = rows.maxOfOrNull { it.first.length } ?: 0
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine(if (parentId == null) "Каталог Jellyfin (корень):" else "Каталог Jellyfin (папка $parentId):")
|
||||
rows.forEach { (prefix, badge) ->
|
||||
sb.appendLine(" ${prefix.padEnd(width)} $badge")
|
||||
}
|
||||
return sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
private fun catalogLine(item: JellyfinItem, job: MirrorJob?): Pair<String, String> {
|
||||
val prefix = "${typeLabel(item)} ${item.Name ?: item.Id} (${item.Id})"
|
||||
val badge = when {
|
||||
job == null -> "нет"
|
||||
job.status == "done" -> "✓"
|
||||
job.status == "processing" || job.status == "queued" -> "⟳ ${job.progress ?: 0}%"
|
||||
else -> job.status ?: "нет"
|
||||
}
|
||||
return prefix to badge
|
||||
}
|
||||
|
||||
private fun typeLabel(item: JellyfinItem): String = when (item.Type) {
|
||||
"Folder", "CollectionFolder" -> "[Папка]"
|
||||
"Movie" -> "[Фильм]"
|
||||
"Series" -> "[Сериал]"
|
||||
"Episode" -> "[Эпизод]"
|
||||
else -> "[${item.Type ?: "Элемент"}]"
|
||||
}
|
||||
|
||||
// --- открытие элемента ---
|
||||
|
||||
/** Инфо об элементе + статус зеркала + дорожки озвучки. */
|
||||
suspend fun open(itemId: String): String {
|
||||
val uid = userId()
|
||||
val item = jellyfin.item(uid, itemId) ?: return "элемент $itemId не найден"
|
||||
val status = mirror.mirrorByItem(itemId)
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("${typeLabel(item)} ${item.Name ?: itemId} (${item.Id})")
|
||||
item.Overview?.let { sb.appendLine("Описание: ${it.take(200)}") }
|
||||
item.RunTimeTicks?.let { sb.appendLine("Длительность: ${ticksToMinutes(it)} мин") }
|
||||
sb.append(mirrorStatusText(status))
|
||||
return sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
// --- включить на очках ---
|
||||
|
||||
/** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */
|
||||
suspend fun watch(itemId: String, audioIndex: Int = 0): String {
|
||||
val uid = userId()
|
||||
val item = jellyfin.item(uid, itemId) ?: return "элемент $itemId не найден"
|
||||
val status = mirror.mirrorByItem(itemId)
|
||||
val files = status?.files
|
||||
if (status == null || status.status != "done" || files == null) {
|
||||
return "зеркало для «${item.Name ?: itemId}» не готово (${status?.status ?: "нет"}). Закажите: mirror $itemId"
|
||||
}
|
||||
|
||||
val title = item.Name ?: itemId
|
||||
hub.broadcast(PlayVideo(itemId = itemId, title = title, videoUrl = files.video.url, audioIndex = audioIndex))
|
||||
state.mode = GlassesMode.MOVIE
|
||||
state.currentItem = item
|
||||
state.currentAudioIndex = audioIndex
|
||||
state.mirrorStatus = status
|
||||
state.playing = true
|
||||
return "включил «$title» на очках (озвучка #$audioIndex): ${files.video.url}"
|
||||
}
|
||||
|
||||
// --- зеркала ---
|
||||
|
||||
/** Заказать конвертацию. */
|
||||
suspend fun makeMirror(itemId: String): String {
|
||||
val job = mirror.createMirror(itemId)
|
||||
return "заказана конвертация $itemId: id=${job.id}, статус=${job.status}"
|
||||
}
|
||||
|
||||
/** Статус зеркала по itemId. */
|
||||
suspend fun mirrorStatus(itemId: String): String {
|
||||
val status = mirror.mirrorByItem(itemId)
|
||||
return mirrorStatusText(status)
|
||||
}
|
||||
|
||||
private fun mirrorStatusText(status: MirrorStatus?): String {
|
||||
if (status == null) return "Зеркало: нет (закажите: mirror <id>)"
|
||||
val lines = mutableListOf<String>()
|
||||
lines += "Зеркало: ${status.status}${if (status.status == "done") " ✓" else ""}"
|
||||
val files = status.files
|
||||
if (files != null) {
|
||||
lines += " Видео: ${files.video.url}"
|
||||
if (files.audios.isEmpty()) {
|
||||
lines += " Дорожки: нет"
|
||||
} else {
|
||||
lines += " Дорожки:"
|
||||
files.audios.forEach { a ->
|
||||
lines += " [${a.index}] ${a.title ?: a.language ?: "без названия"} → ${a.url}"
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.joinToString("\n")
|
||||
}
|
||||
|
||||
// --- скачивание (эмуляция) ---
|
||||
|
||||
/** Скачать на очки/телефон (эмуляция: отметить в стейте + лог). */
|
||||
fun download(itemId: String, target: String): String {
|
||||
state.downloaded[itemId] = target
|
||||
log("phone", "скачано $itemId → $target (эмуляция)")
|
||||
return "скачано $itemId → $target (эмуляция, отмечено в стейте)"
|
||||
}
|
||||
|
||||
/** Удалить скачанное (эмуляция). */
|
||||
fun deleteDownloaded(itemId: String): String {
|
||||
val removed = state.downloaded.remove(itemId)
|
||||
return if (removed != null) "удалено скачанное: $itemId ($removed)"
|
||||
else "не было скачано: $itemId"
|
||||
}
|
||||
|
||||
// --- сессии ---
|
||||
|
||||
fun sessions(): String {
|
||||
val sb = StringBuilder("Сессии:")
|
||||
state.sessions.forEach { s ->
|
||||
val marker = if (s.id == state.activeSessionId) " ◄ активна" else ""
|
||||
sb.appendLine(" ${s.id} — ${s.title}$marker")
|
||||
}
|
||||
return sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
/** Переключить сессию и разослать ChatSessionMsg очкам. */
|
||||
suspend fun selectSession(id: String): String {
|
||||
val session = state.sessions.firstOrNull { it.id == id }
|
||||
?: return "сессия «$id» не найдена. Доступны: ${state.sessions.joinToString(", ") { it.id }}"
|
||||
state.activeSessionId = id
|
||||
hub.broadcast(ChatSessionMsg(sessionId = id, title = session.title))
|
||||
return "выбрана сессия: ${session.title} (${session.id})"
|
||||
}
|
||||
|
||||
// --- состояние ---
|
||||
|
||||
/** Что сейчас на очках/в телефоне. */
|
||||
fun status(): String {
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("Режим: ${state.mode}")
|
||||
sb.appendLine("Играет: ${state.currentItem?.Name ?: state.currentItem?.Id ?: "ничего"} " +
|
||||
"(${state.currentItem?.Id ?: "-"})")
|
||||
sb.appendLine("Озвучка: #${state.currentAudioIndex}")
|
||||
sb.appendLine("Воспроизведение: ${if (state.playing) "играет" else "пауза"}")
|
||||
sb.appendLine("Зеркало: ${state.mirrorStatus?.status ?: "нет"}")
|
||||
sb.appendLine("Сессия: ${state.activeSessionId ?: "нет"}")
|
||||
val dl = state.downloaded.entries.joinToString("; ") { "${it.key} → ${it.value}" }
|
||||
sb.appendLine("Скачано: ${dl.ifEmpty { "ничего" }}")
|
||||
return sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package pw.binom.viewmate.host
|
||||
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorStatus
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** Сессия чата (эмуляция) — простой DTO. */
|
||||
data class SessionInfo(
|
||||
val id: String,
|
||||
val title: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Единый стейт «телефона» (как у будущего приложения).
|
||||
* Изменение любого поля логируется в консоль.
|
||||
*/
|
||||
class PhoneState {
|
||||
|
||||
@Volatile
|
||||
var mode: GlassesMode = GlassesMode.MOVIE
|
||||
set(value) {
|
||||
field = value
|
||||
log("state", "режим → $value")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var currentItem: JellyfinItem? = null
|
||||
set(value) {
|
||||
field = value
|
||||
log("state", "текущий → ${value?.Name ?: value?.Id ?: "ничего"}")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var currentAudioIndex: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
log("state", "озвучка → #$value")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var mirrorStatus: MirrorStatus? = null
|
||||
set(value) {
|
||||
field = value
|
||||
log("state", "зеркало → ${value?.status ?: "нет"}")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var activeSessionId: String? = null
|
||||
set(value) {
|
||||
field = value
|
||||
log("state", "сессия → $value")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var playing: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
log("state", if (value) "воспроизведение" else "пауза")
|
||||
}
|
||||
|
||||
/** Эмуляция сессий чата: 3 фейковых + текущая. */
|
||||
val sessions: List<SessionInfo> = listOf(
|
||||
SessionInfo("glasses-movie", "Очки/кино"),
|
||||
SessionInfo("work", "Работа"),
|
||||
SessionInfo("general", "Общее"),
|
||||
SessionInfo("current", "Текущая"),
|
||||
)
|
||||
|
||||
/** Эмуляция скачанного: itemId → куда (очки|телефон). */
|
||||
val downloaded: ConcurrentHashMap<String, String> = ConcurrentHashMap()
|
||||
}
|
||||
Reference in New Issue
Block a user