diff --git a/app-glasses/build.gradle.kts b/app-glasses/build.gradle.kts
index d5f5615..9433d71 100644
--- a/app-glasses/build.gradle.kts
+++ b/app-glasses/build.gradle.kts
@@ -50,4 +50,8 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(project(":lib-core"))
+
+ // WS-клиент очков (OkHttp — Android-движок)
+ implementation(libs.ktor.client.okhttp)
+ implementation(libs.ktor.client.websockets)
}
diff --git a/app-glasses/src/main/AndroidManifest.xml b/app-glasses/src/main/AndroidManifest.xml
index 0aa9b1e..4ea4cfa 100644
--- a/app-glasses/src/main/AndroidManifest.xml
+++ b/app-glasses/src/main/AndroidManifest.xml
@@ -5,8 +5,19 @@
+ android:allowBackup="true"
+ android:usesCleartextTraffic="true">
+
+
+
+
+
+
+
diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt
new file mode 100644
index 0000000..2093fad
--- /dev/null
+++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt
@@ -0,0 +1,32 @@
+package pw.binom.viewmate.glasses
+
+import android.app.Application
+
+/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
+object GlassesConfig {
+ // Эмулятор — 10.0.2.2 (localhost хоста); на устройстве — LAN-адрес телефона.
+ const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
+}
+
+/**
+ * Application очков: создаёт и запускает HostConnection (WS-клиент к телефону).
+ * Синглтон-доступ через companion.
+ */
+class GlassesApp : Application() {
+
+ lateinit var hostConnection: HostConnection
+ private set
+
+ override fun onCreate() {
+ super.onCreate()
+ instance = this
+ hostConnection = HostConnection(GlassesConfig.HOST_URL)
+ hostConnection.start()
+ log("app", "HostConnection стартует: ${GlassesConfig.HOST_URL}")
+ }
+
+ companion object {
+ lateinit var instance: GlassesApp
+ private set
+ }
+}
diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt
new file mode 100644
index 0000000..2ff99c6
--- /dev/null
+++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt
@@ -0,0 +1,102 @@
+package pw.binom.viewmate.glasses
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import pw.binom.viewmate.core.net.GlassesWsClient
+import pw.binom.viewmate.core.protocol.AssistantStateMsg
+import pw.binom.viewmate.core.protocol.ChatSessionMsg
+import pw.binom.viewmate.core.protocol.GlassesStatus
+import pw.binom.viewmate.core.protocol.Hello
+import pw.binom.viewmate.core.protocol.HostToGlasses
+import pw.binom.viewmate.core.protocol.MediaCommandMsg
+import pw.binom.viewmate.core.protocol.PlayVideo
+import pw.binom.viewmate.core.protocol.PlaybackPosition
+import pw.binom.viewmate.core.protocol.SetMode
+import pw.binom.viewmate.core.protocol.ShowText
+import pw.binom.viewmate.core.protocol.Welcome
+
+/**
+ * Обвязка над [GlassesWsClient] для UI очков:
+ * реконнект, Hello при подключении, периодический статус/позиция,
+ * обработка входящих HostToGlasses в StateFlow (пока — лог).
+ */
+class HostConnection(
+ private val url: String,
+) {
+ private val client = GlassesWsClient(url)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ private val _connected = MutableStateFlow(false)
+ val connected: StateFlow = _connected.asStateFlow()
+
+ private val _status = MutableStateFlow("не подключено")
+ val status: StateFlow = _status.asStateFlow()
+
+ private val _messages = MutableStateFlow>(emptyList())
+ val messages: StateFlow> = _messages.asStateFlow()
+
+ fun start() {
+ scope.launch {
+ client.connect(
+ onHostMessage = { msg -> handleHostMessage(msg) },
+ onConnected = {
+ _connected.value = true
+ _status.value = "подключено: $url"
+ addMessage("соединение установлено")
+ client.send(Hello(appVersion = "0.1.0"))
+ },
+ onDisconnected = {
+ _connected.value = false
+ _status.value = "нет соединения (реконнект через 3с)"
+ },
+ )
+ }
+ scope.launch {
+ while (isActive) {
+ delay(5_000)
+ // статус/позиция — заглушки (реальные батарея/память/таймкод — позже)
+ client.send(GlassesStatus(batteryPercent = 80, storageUsedGb = 10.5, storageTotalGb = 32.0))
+ client.send(PlaybackPosition(positionMs = 0, playing = false))
+ }
+ }
+ }
+
+ private suspend fun handleHostMessage(msg: HostToGlasses) {
+ when (msg) {
+ is Welcome -> {
+ _connected.value = true
+ addMessage("Welcome: режим ${msg.mode}, сессия ${msg.sessionId ?: "-"}")
+ }
+
+ is SetMode -> addMessage("SetMode: ${msg.mode}")
+
+ is PlayVideo -> addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
+
+ is MediaCommandMsg -> addMessage("MediaCommand: ${msg.command} (seekMs=${msg.seekMs})")
+
+ is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
+
+ is ShowText -> addMessage("ShowText: ${msg.text}")
+
+ is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
+ }
+ }
+
+ private fun addMessage(text: String) {
+ log("glasses", text)
+ _messages.value = (_messages.value + "» $text").takeLast(50)
+ }
+
+ fun stop() {
+ client.close()
+ scope.cancel()
+ }
+}
diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt
new file mode 100644
index 0000000..a5fe211
--- /dev/null
+++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt
@@ -0,0 +1,6 @@
+package pw.binom.viewmate.glasses
+
+/** Единый лог очков: простой println (работает и в unit-тестах). */
+fun log(tag: String, message: String) {
+ println("[view-mate] $tag $message")
+}
diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt
index b23b1a3..b8d2555 100644
--- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt
+++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt
@@ -1,12 +1,53 @@
package pw.binom.viewmate.glasses
-import android.app.Activity
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.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+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
-class MainActivity : Activity() {
+class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- // Точка входа на очках: инициализация RayNeo SDK, режимы, связь.
- // Пока — заглушка, заполняется на следующем шаге.
+ setContent {
+ MaterialTheme {
+ ConnectionScreen()
+ }
+ }
+ }
+}
+
+@Composable
+fun ConnectionScreen() {
+ val connection = GlassesApp.instance.hostConnection
+ val connected by connection.connected.collectAsState()
+ val status by connection.status.collectAsState()
+ val messages by connection.messages.collectAsState()
+
+ Column(
+ modifier = Modifier.fillMaxSize().padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
+ Text(if (connected) "подключено к телефону" else "нет соединения")
+ Text(status, style = MaterialTheme.typography.bodyMedium)
+ Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium)
+ LazyColumn(modifier = Modifier.fillMaxWidth()) {
+ items(messages) { msg ->
+ Text(msg, style = MaterialTheme.typography.bodySmall)
+ }
+ }
}
}
diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt
index e838010..e78b8aa 100644
--- a/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt
+++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt
@@ -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()
@@ -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
diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt
index 9b19d27..d8a8710 100644
--- a/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt
+++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt
@@ -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(parts.getOrNull(1))
if (mode == null) return logUsage("mode MOVIE|CHAT")
- hub.setMode(mode)
+ out(actions.applyMode(mode))
}
"state" -> {
val state = parseEnum(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(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)))
diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts
index 96f130c..321613d 100644
--- a/app-phone/build.gradle.kts
+++ b/app-phone/build.gradle.kts
@@ -48,4 +48,17 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(project(":lib-core"))
+
+ // WS-сервер телефона (CIO — работает на Android)
+ implementation("io.ktor:ktor-server-core:3.3.0")
+ implementation("io.ktor:ktor-server-cio:3.3.0")
+ implementation("io.ktor:ktor-server-websockets:3.3.0")
+ implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
+
+ testImplementation(kotlin("test"))
+ testImplementation("io.ktor:ktor-server-test-host:3.3.0")
+ testImplementation("io.ktor:ktor-client-websockets:3.3.0")
+ testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0")
+ testImplementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
+ testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/app-phone/src/main/AndroidManifest.xml b/app-phone/src/main/AndroidManifest.xml
index 718e3ae..6751873 100644
--- a/app-phone/src/main/AndroidManifest.xml
+++ b/app-phone/src/main/AndroidManifest.xml
@@ -6,8 +6,19 @@
+ android:allowBackup="true"
+ android:usesCleartextTraffic="true">
+
+
+
+
+
+
+
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt
new file mode 100644
index 0000000..dc685f9
--- /dev/null
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt
@@ -0,0 +1,169 @@
+package pw.binom.viewmate.phone
+
+import io.ktor.server.application.Application
+import io.ktor.server.application.install
+import io.ktor.server.cio.CIO
+import io.ktor.server.engine.EmbeddedServer
+import io.ktor.server.engine.embeddedServer
+import io.ktor.server.routing.routing
+import io.ktor.server.websocket.DefaultWebSocketServerSession
+import io.ktor.server.websocket.WebSockets
+import io.ktor.server.websocket.webSocket
+import io.ktor.websocket.Frame
+import io.ktor.websocket.readText
+import io.ktor.websocket.send
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import pw.binom.viewmate.core.GlassesMode
+import pw.binom.viewmate.core.phone.GlassesSender
+import pw.binom.viewmate.core.phone.PhoneState
+import pw.binom.viewmate.core.protocol.GlassesOff
+import pw.binom.viewmate.core.protocol.GlassesStatus
+import pw.binom.viewmate.core.protocol.GlassesToHost
+import pw.binom.viewmate.core.protocol.Gesture
+import pw.binom.viewmate.core.protocol.Hello
+import pw.binom.viewmate.core.protocol.HostToGlasses
+import pw.binom.viewmate.core.protocol.PlaybackPosition
+import pw.binom.viewmate.core.protocol.Welcome
+import pw.binom.viewmate.core.protocol.protocolJson
+import java.util.concurrent.ConcurrentHashMap
+
+const val GLASSES_WS_PATH = "/ws/glasses"
+
+/**
+ * Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
+ * Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
+ * (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза).
+ */
+class GlassesHub(
+ private val state: PhoneState,
+) {
+ private val sessions = ConcurrentHashMap()
+
+ private val _connected = MutableStateFlow(0)
+ val connected: StateFlow = _connected.asStateFlow()
+
+ private val _gesturesReceived = MutableStateFlow(0)
+ val gesturesReceived: StateFlow = _gesturesReceived.asStateFlow()
+
+ fun add(session: DefaultWebSocketServerSession) {
+ sessions[session] = Unit
+ _connected.value = sessions.size
+ log("hub", "очки подключились, всего: ${sessions.size}")
+ }
+
+ fun remove(session: DefaultWebSocketServerSession) {
+ sessions.remove(session)
+ _connected.value = sessions.size
+ log("hub", "очки отключились, всего: ${sessions.size}")
+ }
+
+ /** Отправить сообщение всем подключённым очкам. */
+ suspend fun broadcast(msg: HostToGlasses) {
+ val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
+ for (session in sessions.keys) {
+ try {
+ session.send(text)
+ } catch (e: Exception) {
+ log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
+ }
+ }
+ }
+
+ /** Обработать входящее текстовое сообщение от очков. */
+ suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
+ val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), text)
+ when (msg) {
+ is Hello -> {
+ log("glasses", "hello (app=${msg.appVersion}) → welcome")
+ send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId))
+ }
+
+ is Gesture -> {
+ _gesturesReceived.value++
+ log("glasses", "жест с тачпада: ${msg.gesture}")
+ }
+
+ is GlassesStatus -> {
+ log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
+ }
+
+ is PlaybackPosition -> {
+ val state = if (msg.playing) "играет" else "пауза"
+ log("glasses", "позиция: ${msg.positionMs} мс, $state")
+ }
+
+ is GlassesOff -> {
+ log("glasses", "очки выключились → пауза (${msg.reason})")
+ this.state.playing = false
+ }
+ }
+ }
+
+ private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) {
+ val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
+ session.send(text)
+ }
+}
+
+/** Ktor-модуль телефона: WebSocket-роут для подключения очков. */
+fun Application.glassesServerModule(hub: GlassesHub) {
+ install(WebSockets) {
+ pingPeriodMillis = 15_000
+ timeoutMillis = 15_000
+ maxFrameSize = Long.MAX_VALUE
+ masking = false
+ }
+ routing {
+ webSocket(GLASSES_WS_PATH) {
+ hub.add(this)
+ try {
+ for (frame in incoming) {
+ if (frame is Frame.Text) {
+ val text = frame.readText()
+ hub.handle(this, text)
+ }
+ }
+ } catch (e: Exception) {
+ log("server", "WS-ошибка: ${e.message}")
+ } finally {
+ hub.remove(this)
+ }
+ }
+ }
+}
+
+/**
+ * WS-сервер телефона (CIO-движок — работает на Android, в отличие от Netty).
+ * Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses.
+ */
+class GlassesServer(
+ private val port: Int = PhoneConfig.SERVER_PORT,
+ private val state: PhoneState = PhoneState(),
+) {
+ val hub = GlassesHub(state)
+
+ /** GlassesSender-адаптер поверх хаба (для PhoneActions). */
+ val sender: GlassesSender = object : GlassesSender {
+ override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg)
+ }
+
+ private var server: EmbeddedServer<*, *>? = null
+
+ fun start() {
+ if (server != null) return
+ server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
+ glassesServerModule(hub)
+ }
+ server!!.start(wait = false)
+ log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
+ }
+
+ fun stop() {
+ server?.stop(1_000, 3_000)
+ server = null
+ }
+}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt
new file mode 100644
index 0000000..93d7601
--- /dev/null
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt
@@ -0,0 +1,6 @@
+package pw.binom.viewmate.phone
+
+/** Единый лог телефона: простой println (работает и в unit-тестах). */
+fun log(tag: String, message: String) {
+ println("[view-mate] $tag $message")
+}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt
index 8dc5b4d..f62d43d 100644
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt
@@ -1,11 +1,44 @@
package pw.binom.viewmate.phone
-import android.app.Activity
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
-class MainActivity : Activity() {
+class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- // Компаньон: дубль-управление, чат, каталог. Пока — заглушка.
+ setContent {
+ MaterialTheme {
+ ServerStatusScreen()
+ }
+ }
+ }
+}
+
+@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")
}
}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt
new file mode 100644
index 0000000..a844e8f
--- /dev/null
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt
@@ -0,0 +1,60 @@
+package pw.binom.viewmate.phone
+
+import android.app.Application
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+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
+
+/**
+ * Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
+ * WS-сервер (GlassesServer) и PhoneActions. Синглтон-доступ через companion.
+ */
+class PhoneApp : Application() {
+
+ lateinit var state: PhoneState
+ private set
+ lateinit var server: GlassesServer
+ private set
+ lateinit var actions: PhoneActions
+ private set
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onCreate() {
+ 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()
+
+ // первый пользователь Jellyfin — один раз при старте
+ scope.launch {
+ val userId = runCatching { jellyfin.users().firstOrNull()?.Id }.getOrNull()
+ ?: ""
+ if (userId.isBlank()) log("app", "не удалось получить пользователя Jellyfin")
+ actions = PhoneActions(
+ jellyfin = jellyfin,
+ mirror = mirror,
+ sender = server.sender,
+ state = state,
+ userId = userId,
+ )
+ log("app", "PhoneActions готов (userId=$userId)")
+ }
+ }
+
+ companion object {
+ lateinit var instance: PhoneApp
+ private set
+ }
+}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt
new file mode 100644
index 0000000..ef49cd0
--- /dev/null
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt
@@ -0,0 +1,14 @@
+package pw.binom.viewmate.phone
+
+/**
+ * Конфигурация телефона: креды Jellyfin/media-mirror и порт WS-сервера.
+ * Пока — простые константы (значения те же, что в app-host application.properties),
+ * позже заменится на BuildConfig/строки.
+ */
+object PhoneConfig {
+ const val JELLYFIN_URL = "https://jellyfin.binom.pw"
+ const val JELLYFIN_API_KEY = "59e6380a96a94364ae25d2b7cf9f1359"
+ const val MIRROR_URL = "https://mirror.binom.pw"
+ const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
+ const val SERVER_PORT = 8080
+}
diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt
new file mode 100644
index 0000000..55dd20e
--- /dev/null
+++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt
@@ -0,0 +1,122 @@
+package pw.binom.viewmate.phone
+
+import io.ktor.client.plugins.websocket.WebSockets
+import io.ktor.client.plugins.websocket.webSocket
+import io.ktor.server.testing.testApplication
+import io.ktor.websocket.Frame
+import io.ktor.websocket.readText
+import io.ktor.websocket.send
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import pw.binom.viewmate.core.GlassesMode
+import pw.binom.viewmate.core.phone.PhoneState
+import pw.binom.viewmate.core.protocol.Gesture
+import pw.binom.viewmate.core.protocol.GlassesStatus
+import pw.binom.viewmate.core.protocol.GlassesToHost
+import pw.binom.viewmate.core.protocol.Hello
+import pw.binom.viewmate.core.protocol.HostToGlasses
+import pw.binom.viewmate.core.protocol.SetMode
+import pw.binom.viewmate.core.protocol.Welcome
+import pw.binom.viewmate.core.protocol.protocolJson
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.fail
+
+class GlassesServerTest {
+
+ private val json = protocolJson
+
+ @Test
+ fun helloGetsWelcome() = testApplication {
+ val hub = GlassesHub(PhoneState())
+ application { glassesServerModule(hub) }
+ val client = createClient { install(WebSockets) }
+
+ client.webSocket(GLASSES_WS_PATH) {
+ send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
+ val frame = incoming.receive() as Frame.Text
+ val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
+ assertIs(welcome)
+ assertEquals(GlassesMode.MOVIE, welcome.mode)
+ }
+ }
+
+ @Test
+ fun gestureIsProcessed() = testApplication {
+ val hub = GlassesHub(PhoneState())
+ application { glassesServerModule(hub) }
+ val client = createClient { install(WebSockets) }
+
+ client.webSocket(GLASSES_WS_PATH) {
+ send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
+ send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
+ }
+ awaitTrue { hub.gesturesReceived.value == 1 }
+ awaitTrue { hub.connected.value == 0 }
+ }
+
+ @Test
+ fun connectedCounterTracksSessions() = testApplication {
+ val hub = GlassesHub(PhoneState())
+ application { glassesServerModule(hub) }
+ val client = createClient { install(WebSockets) }
+
+ client.webSocket(GLASSES_WS_PATH) { }
+ awaitTrue { hub.connected.value == 0 }
+ }
+
+ @Test
+ fun broadcastReachesAllGlasses() = testApplication {
+ val hub = GlassesHub(PhoneState())
+ application { glassesServerModule(hub) }
+ val client = createClient { install(WebSockets) }
+
+ coroutineScope {
+ val opened = Channel(2)
+ val received = Channel(2)
+
+ val jobs = (1..2).map {
+ launch {
+ client.webSocket(GLASSES_WS_PATH) {
+ opened.send(Unit)
+ while (true) {
+ val frame = incoming.receive()
+ if (frame is Frame.Text) {
+ received.send(json.decodeFromString(HostToGlasses.serializer(), frame.readText()))
+ }
+ }
+ }
+ }
+ }
+
+ repeat(2) { opened.receive() }
+ awaitTrue { hub.connected.value == 2 }
+
+ hub.broadcast(SetMode(GlassesMode.CHAT))
+
+ val first = received.receive()
+ val second = received.receive()
+ listOf(first, second).forEach {
+ assertIs(it)
+ assertEquals(GlassesMode.CHAT, it.mode)
+ }
+
+ jobs.forEach { it.cancelAndJoin() }
+ awaitTrue { hub.connected.value == 0 }
+ }
+ }
+
+ private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
+ val deadline = System.currentTimeMillis() + timeoutMs
+ while (!condition()) {
+ if (System.currentTimeMillis() > deadline) fail("условие не выполнено за ${timeoutMs}мс")
+ delay(20)
+ }
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a4f885b..bc467c5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -24,6 +24,7 @@ exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
+ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
diff --git a/lib-core/build.gradle.kts b/lib-core/build.gradle.kts
index b764a4b..76d9234 100644
--- a/lib-core/build.gradle.kts
+++ b/lib-core/build.gradle.kts
@@ -19,6 +19,7 @@ kotlin {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.websockets)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.serialization.kotlinx.json)
@@ -35,6 +36,13 @@ kotlin {
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
}
+ jvmTest.dependencies {
+ implementation(libs.ktor.client.websockets)
+ implementation("io.ktor:ktor-server-core:3.3.0")
+ implementation("io.ktor:ktor-server-cio:3.3.0")
+ implementation("io.ktor:ktor-server-websockets:3.3.0")
+ implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
+ }
}
}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt
new file mode 100644
index 0000000..8fa7b1f
--- /dev/null
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt
@@ -0,0 +1,6 @@
+package pw.binom.viewmate.core
+
+/** Единый лог общего слоя (KMP): без java.time, просто тег + сообщение. */
+fun log(tag: String, message: String) {
+ println("[view-mate] $tag $message")
+}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/HttpClient.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/HttpClient.kt
index 8a30b81..ab44c0b 100644
--- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/HttpClient.kt
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/HttpClient.kt
@@ -6,11 +6,13 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.logging.Logger
+import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
+import pw.binom.viewmate.core.protocol.protocolJson
/**
* Общий HTTP-клиент для media-бэкендов (Jellyfin, media-mirror):
@@ -45,6 +47,31 @@ object HttpClientDebugLogging {
var enabled: Boolean = true
}
+/**
+ * HTTP-клиент для WS-протокола очков (GlassesWsClient):
+ * JSON-сериализация kotlinx с дискриминатором "type", таймауты, поддержка WS.
+ */
+fun defaultWsClient(): HttpClient = HttpClient {
+ install(ContentNegotiation) {
+ json(protocolJson)
+ }
+ install(HttpTimeout) {
+ connectTimeoutMillis = 5_000
+ requestTimeoutMillis = 30_000
+ }
+ install(WebSockets) {
+ pingIntervalMillis = 15_000
+ }
+ install(Logging) {
+ logger = object : Logger {
+ override fun log(message: String) {
+ if (HttpClientDebugLogging.enabled) println("[view-mate] $message")
+ }
+ }
+ level = LogLevel.INFO
+ }
+}
+
/** Ошибка HTTP-запроса с понятным текстом. */
class ApiClientException(
message: String,
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesWsClient.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesWsClient.kt
new file mode 100644
index 0000000..a5551e1
--- /dev/null
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesWsClient.kt
@@ -0,0 +1,86 @@
+package pw.binom.viewmate.core.net
+
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
+import io.ktor.client.plugins.websocket.webSocketSession
+import io.ktor.websocket.Frame
+import io.ktor.websocket.close
+import io.ktor.websocket.readText
+import io.ktor.websocket.send
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import pw.binom.viewmate.core.log
+import pw.binom.viewmate.core.media.defaultWsClient
+import pw.binom.viewmate.core.protocol.GlassesToHost
+import pw.binom.viewmate.core.protocol.HostToGlasses
+import pw.binom.viewmate.core.protocol.protocolJson
+
+/**
+ * WS-клиент очков: подключается к хосту (телефону), шлёт [GlassesToHost],
+ * принимает [HostToGlasses]. [connect] — бесконечный цикл приёма с
+ * реконнектом через 3с при обрыве.
+ */
+class GlassesWsClient(
+ private val url: String, // ws://:8080/ws/glasses
+ private val http: HttpClient = defaultWsClient(),
+) {
+
+ @Volatile
+ private var closed = false
+
+ @Volatile
+ private var session: DefaultClientWebSocketSession? = null
+
+ /**
+ * Подключиться и слушать. Возвращается при вызове [close].
+ * [onHostMessage] — входящие от хоста; [onConnected]/[onDisconnected] —
+ * события (пере)подключения (для Hello и смены статуса в UI).
+ */
+ suspend fun connect(
+ onHostMessage: suspend (HostToGlasses) -> Unit,
+ onConnected: suspend () -> Unit = {},
+ onDisconnected: suspend () -> Unit = {},
+ ) {
+ while (!closed) {
+ try {
+ val ws = http.webSocketSession(url)
+ session = ws
+ onConnected()
+ try {
+ for (frame in ws.incoming) {
+ if (frame is Frame.Text) {
+ val text = frame.readText()
+ val msg = protocolJson.decodeFromString(HostToGlasses.serializer(), text)
+ onHostMessage(msg)
+ }
+ }
+ } finally {
+ session = null
+ onDisconnected()
+ ws.close()
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ log("ws-client", "соединение оборвано (${e.message}), реконнект через 3с")
+ }
+ if (closed) break
+ delay(3_000)
+ }
+ }
+
+ /** Отправить сообщение хосту. Без активного соединения — no-op. */
+ suspend fun send(msg: GlassesToHost) {
+ val text = protocolJson.encodeToString(GlassesToHost.serializer(), msg)
+ session?.send(text)
+ }
+
+ /** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
+ fun close() {
+ closed = true
+ session?.cancel()
+ }
+}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesSender.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesSender.kt
new file mode 100644
index 0000000..45786f2
--- /dev/null
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesSender.kt
@@ -0,0 +1,8 @@
+package pw.binom.viewmate.core.phone
+
+import pw.binom.viewmate.core.protocol.HostToGlasses
+
+/** Куда слать сообщения очкам (реализуется WS-хабом телефона/хоста). */
+interface GlassesSender {
+ suspend fun send(msg: HostToGlasses)
+}
diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneActions.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt
similarity index 76%
rename from app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneActions.kt
rename to lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt
index 3dc91f4..1aa11eb 100644
--- a/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneActions.kt
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt
@@ -1,46 +1,39 @@
-package pw.binom.viewmate.host
+package pw.binom.viewmate.core.phone
-import kotlinx.serialization.json.Json
import pw.binom.viewmate.core.GlassesMode
-import pw.binom.viewmate.core.media.ApiClientException
+import pw.binom.viewmate.core.MediaCommand
+import pw.binom.viewmate.core.log
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.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.SetMode
+import pw.binom.viewmate.core.protocol.ShowText
/**
* Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки.
- * Каждый метод возвращает человекочитаемый текст для консоли
- * и дёргает hub для реальных команд очкам.
+ * Каждый метод возвращает человекочитаемый текст для консоли/UI
+ * и дёргает [GlassesSender] для реальных команд очкам.
*/
class PhoneActions(
private val jellyfin: JellyfinClient,
private val mirror: MirrorClient,
- private val hub: GlassesHub,
+ private val sender: GlassesSender,
private val state: PhoneState,
- private val json: Json,
+ private val userId: String, // первый пользователь Jellyfin
) {
- 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 items = if (parentId == null) jellyfin.rootItems(userId) else jellyfin.items(userId, parentId)
val mirrors = mirror.list(limit = 500).associateBy { it.itemId }
val rows = items.map { item -> catalogLine(item, mirrors[item.Id]) }
@@ -77,8 +70,7 @@ class PhoneActions(
/** Инфо об элементе + статус зеркала + дорожки озвучки. */
suspend fun open(itemId: String): String {
- val uid = userId()
- val item = jellyfin.item(uid, itemId) ?: return "элемент $itemId не найден"
+ val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId)
val sb = StringBuilder()
@@ -93,8 +85,7 @@ class PhoneActions(
/** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */
suspend fun watch(itemId: String, audioIndex: Int = 0): String {
- val uid = userId()
- val item = jellyfin.item(uid, itemId) ?: return "элемент $itemId не найден"
+ val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId)
val files = status?.files
if (status == null || status.status != "done" || files == null) {
@@ -102,7 +93,7 @@ class PhoneActions(
}
val title = item.Name ?: itemId
- hub.broadcast(PlayVideo(itemId = itemId, title = title, videoUrl = files.video.url, audioIndex = audioIndex))
+ sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = files.video.url, audioIndex = audioIndex))
state.mode = GlassesMode.MOVIE
state.currentItem = item
state.currentAudioIndex = audioIndex
@@ -176,7 +167,7 @@ class PhoneActions(
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))
+ sender.send(ChatSessionMsg(sessionId = id, title = session.title))
return "выбрана сессия: ${session.title} (${session.id})"
}
@@ -197,5 +188,32 @@ class PhoneActions(
return sb.toString().trimEnd()
}
+ // --- прямые команды очкам (шлются через GlassesSender) ---
+
+ /** Переключить режим очков: SetMode + обновить стейт. */
+ suspend fun applyMode(mode: GlassesMode): String {
+ state.mode = mode
+ sender.send(SetMode(mode))
+ return "режим → $mode"
+ }
+
+ /** Состояние ассистента: AssistantStateMsg на очки. */
+ suspend fun applyAssistantState(assistantState: AssistantState, sec: Int): String {
+ sender.send(AssistantStateMsg(state = assistantState, recordingSeconds = sec))
+ return "ассистент → $assistantState (${sec}с)"
+ }
+
+ /** Показать текст на очках: ShowText. */
+ suspend fun applyText(text: String): String {
+ sender.send(ShowText(text = text))
+ return "показан текст: $text"
+ }
+
+ /** Команда медиа: MediaCommandMsg на очки. */
+ suspend fun applyMediaCommand(cmd: MediaCommand, seekMs: Long = 0): String {
+ sender.send(MediaCommandMsg(command = cmd, seekMs = seekMs))
+ return "команда медиа: $cmd${if (seekMs != 0L) " ($seekMs мс)" else ""}"
+ }
+
private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60
}
diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneState.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt
similarity index 88%
rename from app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneState.kt
rename to lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt
index 48a304d..5aaf803 100644
--- a/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneState.kt
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt
@@ -1,9 +1,9 @@
-package pw.binom.viewmate.host
+package pw.binom.viewmate.core.phone
import pw.binom.viewmate.core.GlassesMode
+import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorStatus
-import java.util.concurrent.ConcurrentHashMap
/** Сессия чата (эмуляция) — простой DTO. */
data class SessionInfo(
@@ -60,13 +60,12 @@ class PhoneState {
}
/** Эмуляция сессий чата: 3 фейковых + текущая. */
- val sessions: List = listOf(
+ val sessions: MutableList = mutableListOf(
SessionInfo("glasses-movie", "Очки/кино"),
SessionInfo("work", "Работа"),
SessionInfo("general", "Общее"),
- SessionInfo("current", "Текущая"),
)
/** Эмуляция скачанного: itemId → куда (очки|телефон). */
- val downloaded: ConcurrentHashMap = ConcurrentHashMap()
+ val downloaded: MutableMap = mutableMapOf()
}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Json.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Json.kt
new file mode 100644
index 0000000..97c3972
--- /dev/null
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Json.kt
@@ -0,0 +1,13 @@
+package pw.binom.viewmate.core.protocol
+
+import kotlinx.serialization.json.Json
+
+/**
+ * Общий Json для WS-протокола: полиморфизм через дискриминатор "type"
+ * (как описано в TASK.md и используется в app-host).
+ */
+val protocolJson: Json = Json {
+ classDiscriminator = "type"
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+}
diff --git a/app-host/src/test/kotlin/pw/binom/viewmate/host/PhoneActionsTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt
similarity index 75%
rename from app-host/src/test/kotlin/pw/binom/viewmate/host/PhoneActionsTest.kt
rename to lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt
index b66f2fb..7e44e27 100644
--- a/app-host/src/test/kotlin/pw/binom/viewmate/host/PhoneActionsTest.kt
+++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt
@@ -1,4 +1,4 @@
-package pw.binom.viewmate.host
+package pw.binom.viewmate.core.phone
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
@@ -32,7 +32,6 @@ class PhoneActionsTest {
private val json = Json { ignoreUnknownKeys = true }
- private val usersJson = """[{"Id":"u1","Name":"Admin"}]"""
private val rootItemsJson = """
{"Items":[
{"Id":"f1","Name":"Фильмы","Type":"CollectionFolder"},
@@ -69,7 +68,6 @@ class PhoneActionsTest {
private fun simpleJellyfin(): JellyfinClient = jellyfin { request ->
when (request.url.encodedPath) {
- "/Users" -> jsonResponse(usersJson)
"/Users/u1/Items" -> jsonResponse(rootItemsJson)
else -> jsonResponse("""{"Id":"m1","Name":"Интерстеллар","Type":"Movie","Overview":"Лучший фильм","RunTimeTicks":148800000000}""")
}
@@ -86,10 +84,9 @@ class PhoneActionsTest {
}
}
- private class HubStub(val phoneState: PhoneState = PhoneState()) :
- GlassesHub(Json { ignoreUnknownKeys = true }, phoneState) {
+ private class SenderStub(val phoneState: PhoneState = PhoneState()) : GlassesSender {
val sent = mutableListOf()
- override suspend fun broadcast(msg: HostToGlasses) {
+ override suspend fun send(msg: HostToGlasses) {
sent += msg
}
}
@@ -97,13 +94,13 @@ class PhoneActionsTest {
private fun actions(
jf: JellyfinClient,
mirror: MirrorClient,
- hub: HubStub,
+ sender: SenderStub,
): PhoneActions = PhoneActions(
jellyfin = jf,
mirror = mirror,
- hub = hub,
- state = hub.phoneState,
- json = json,
+ sender = sender,
+ state = sender.phoneState,
+ userId = "u1",
)
@Test
@@ -115,7 +112,7 @@ class PhoneActionsTest {
"""{"id":"job2","itemId":"m2","status":"processing","progress":45}]"""
)
}
- val out = actions(jf, mirrorClient, HubStub()).catalog()
+ val out = actions(jf, mirrorClient, SenderStub()).catalog()
assertTrue(out.contains("Каталог Jellyfin (корень)"))
assertTrue(out.contains("Фильмы (f1)"))
@@ -130,7 +127,6 @@ class PhoneActionsTest {
fun catalogParentShowsFolderContents() = runTest {
val jf = jellyfin { request ->
when (request.url.encodedPath) {
- "/Users" -> jsonResponse(usersJson)
"/Users/u1/Items" -> {
assertEquals("f1", request.url.parameters["ParentId"])
jsonResponse("""{"Items":[{"Id":"e1","Name":"Пилот","Type":"Episode","SeriesName":"Друзья"}]}""")
@@ -139,7 +135,7 @@ class PhoneActionsTest {
}
}
val mirrorClient = mirror { request -> jsonResponse("""[]""") }
- val out = actions(jf, mirrorClient, HubStub()).catalog("f1")
+ val out = actions(jf, mirrorClient, SenderStub()).catalog("f1")
assertTrue(out.contains("папка f1"))
assertTrue(out.contains("Пилот (e1)"))
@@ -148,7 +144,7 @@ class PhoneActionsTest {
@Test
fun openShowsInfoMirrorAndTracks() = runTest {
- val out = actions(simpleJellyfin(), doneMirror(), HubStub()).open("m1")
+ val out = actions(simpleJellyfin(), doneMirror(), SenderStub()).open("m1")
assertTrue(out.contains("Интерстеллар (m1)"))
assertTrue(out.contains("Лучший фильм"))
@@ -161,49 +157,48 @@ class PhoneActionsTest {
@Test
fun watchSendsPlayVideoWithUrlAndAudioIndex() = runTest {
- val hub = HubStub()
- val out = actions(simpleJellyfin(), doneMirror(), hub).watch("m1", audioIndex = 1)
+ val sender = SenderStub()
+ val out = actions(simpleJellyfin(), doneMirror(), sender).watch("m1", audioIndex = 1)
assertTrue(out.contains("включил «Интерстеллар»"))
- assertEquals(1, hub.sent.size)
- val msg = hub.sent.single()
+ assertEquals(1, sender.sent.size)
+ val msg = sender.sent.single()
assertIs(msg)
assertEquals("m1", msg.itemId)
assertEquals("Интерстеллар", msg.title)
assertEquals("https://s3/media/m1/video.mp4", msg.videoUrl)
assertEquals(1, msg.audioIndex)
- assertEquals("m1", hub.phoneState.currentItem?.Id)
- assertEquals(1, hub.phoneState.currentAudioIndex)
- assertTrue(hub.phoneState.playing)
- assertEquals("done", hub.phoneState.mirrorStatus?.status)
+ assertEquals("m1", sender.phoneState.currentItem?.Id)
+ assertEquals(1, sender.phoneState.currentAudioIndex)
+ assertTrue(sender.phoneState.playing)
+ assertEquals("done", sender.phoneState.mirrorStatus?.status)
}
@Test
fun watchWhenMirrorNotReadyReturnsHint() = runTest {
- val hub = HubStub()
+ val sender = SenderStub()
val mirrorClient = mirror { request ->
respond("", HttpStatusCode.NotFound)
}
- val out = actions(simpleJellyfin(), mirrorClient, hub).watch("m1")
+ val out = actions(simpleJellyfin(), mirrorClient, sender).watch("m1")
assertTrue(out.contains("не готово"))
- assertTrue(hub.sent.isEmpty())
+ assertTrue(sender.sent.isEmpty())
}
@Test
fun watchUnknownItemReturnsNotFound() = runTest {
- val hub = HubStub()
+ val sender = SenderStub()
val jf = jellyfin { request ->
when (request.url.encodedPath) {
- "/Users" -> jsonResponse(usersJson)
"/Users/u1/Items/missing" -> respond("", HttpStatusCode.NotFound)
else -> jsonResponse("""{"Items":[]}""")
}
}
- val out = actions(jf, doneMirror(), hub).watch("missing")
+ val out = actions(jf, doneMirror(), sender).watch("missing")
assertTrue(out.contains("не найден"))
- assertTrue(hub.sent.isEmpty())
+ assertTrue(sender.sent.isEmpty())
}
@Test
@@ -222,7 +217,7 @@ class PhoneActionsTest {
)
}
- val out = actions(simpleJellyfin(), mirrorClient, HubStub()).makeMirror("m1")
+ val out = actions(simpleJellyfin(), mirrorClient, SenderStub()).makeMirror("m1")
assertEquals("/api/mirror", capturedPath)
assertEquals(HttpMethod.Post, capturedMethod)
@@ -234,23 +229,23 @@ class PhoneActionsTest {
@Test
fun mirrorStatusReturnsText() = runTest {
- val done = actions(simpleJellyfin(), doneMirror(), HubStub()).mirrorStatus("m1")
+ val done = actions(simpleJellyfin(), doneMirror(), SenderStub()).mirrorStatus("m1")
assertTrue(done.contains("done"))
assertTrue(done.contains("✓"))
val missing = mirror { request -> respond("", HttpStatusCode.NotFound) }
- val none = actions(simpleJellyfin(), missing, HubStub()).mirrorStatus("m1")
+ val none = actions(simpleJellyfin(), missing, SenderStub()).mirrorStatus("m1")
assertTrue(none.contains("Зеркало: нет"))
}
@Test
fun selectSessionBroadcastsChatSessionMsg() = runTest {
- val hub = HubStub()
- val out = actions(simpleJellyfin(), doneMirror(), hub).selectSession("work")
+ val sender = SenderStub()
+ val out = actions(simpleJellyfin(), doneMirror(), sender).selectSession("work")
assertTrue(out.contains("Работа"))
- assertEquals("work", hub.phoneState.activeSessionId)
- val msg = hub.sent.single()
+ assertEquals("work", sender.phoneState.activeSessionId)
+ val msg = sender.sent.single()
assertIs(msg)
assertEquals("work", msg.sessionId)
assertEquals("Работа", msg.title)
@@ -258,18 +253,18 @@ class PhoneActionsTest {
@Test
fun selectSessionUnknownReturnsError() = runTest {
- val hub = HubStub()
- val out = actions(simpleJellyfin(), doneMirror(), hub).selectSession("nope")
+ val sender = SenderStub()
+ val out = actions(simpleJellyfin(), doneMirror(), sender).selectSession("nope")
assertTrue(out.contains("не найдена"))
- assertTrue(hub.sent.isEmpty())
- assertNull(hub.phoneState.activeSessionId)
+ assertTrue(sender.sent.isEmpty())
+ assertNull(sender.phoneState.activeSessionId)
}
@Test
fun sessionsListsAllSessions() = runTest {
- val hub = HubStub()
- hub.phoneState.activeSessionId = "work"
- val out = actions(simpleJellyfin(), doneMirror(), hub).sessions()
+ val sender = SenderStub()
+ sender.phoneState.activeSessionId = "work"
+ val out = actions(simpleJellyfin(), doneMirror(), sender).sessions()
assertTrue(out.contains("glasses-movie — Очки/кино"))
assertTrue(out.contains("work — Работа"))
assertTrue(out.contains("general — Общее"))
@@ -278,16 +273,16 @@ class PhoneActionsTest {
@Test
fun downloadAndDeleteDownloadedEmulate() {
- val hub = HubStub()
- val actions = actions(simpleJellyfin(), doneMirror(), hub)
+ val sender = SenderStub()
+ val actions = actions(simpleJellyfin(), doneMirror(), sender)
val dl = actions.download("m1", "очки")
assertTrue(dl.contains("скачано m1"))
- assertEquals("очки", hub.phoneState.downloaded["m1"])
+ assertEquals("очки", sender.phoneState.downloaded["m1"])
val rm = actions.deleteDownloaded("m1")
assertTrue(rm.contains("удалено скачанное"))
- assertFalse(hub.phoneState.downloaded.containsKey("m1"))
+ assertFalse(sender.phoneState.downloaded.containsKey("m1"))
val rm2 = actions.deleteDownloaded("m1")
assertTrue(rm2.contains("не было скачано"))
@@ -295,8 +290,8 @@ class PhoneActionsTest {
@Test
fun statusShowsCurrentState() = runTest {
- val hub = HubStub()
- val actions = actions(simpleJellyfin(), doneMirror(), hub)
+ val sender = SenderStub()
+ val actions = actions(simpleJellyfin(), doneMirror(), sender)
val empty = actions.status()
assertTrue(empty.contains("Режим: MOVIE"))
@@ -309,4 +304,16 @@ class PhoneActionsTest {
assertTrue(after.contains("играет"))
assertTrue(after.contains("Озвучка: #0"))
}
+
+ @Test
+ fun applyModeSendsSetModeAndUpdatesState() = runTest {
+ val sender = SenderStub()
+ val out = actions(simpleJellyfin(), doneMirror(), sender).applyMode(pw.binom.viewmate.core.GlassesMode.CHAT)
+
+ assertTrue(out.contains("CHAT"))
+ assertEquals(pw.binom.viewmate.core.GlassesMode.CHAT, sender.phoneState.mode)
+ val msg = sender.sent.single()
+ assertIs(msg)
+ assertEquals(pw.binom.viewmate.core.GlassesMode.CHAT, msg.mode)
+ }
}
diff --git a/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/net/GlassesWsClientTest.kt b/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/net/GlassesWsClientTest.kt
new file mode 100644
index 0000000..0b5eff2
--- /dev/null
+++ b/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/net/GlassesWsClientTest.kt
@@ -0,0 +1,238 @@
+package pw.binom.viewmate.core.net
+
+import io.ktor.server.application.install
+import io.ktor.server.cio.CIO
+import io.ktor.server.engine.EmbeddedServer
+import io.ktor.server.engine.embeddedServer
+import io.ktor.server.routing.routing
+import io.ktor.server.websocket.DefaultWebSocketServerSession
+import io.ktor.server.websocket.WebSockets
+import io.ktor.server.websocket.webSocket
+import io.ktor.websocket.Frame
+import io.ktor.websocket.readText
+import io.ktor.websocket.send
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import pw.binom.viewmate.core.GlassesMode
+import pw.binom.viewmate.core.protocol.Gesture
+import pw.binom.viewmate.core.protocol.GlassesToHost
+import pw.binom.viewmate.core.protocol.Hello
+import pw.binom.viewmate.core.protocol.HostToGlasses
+import pw.binom.viewmate.core.protocol.SetMode
+import pw.binom.viewmate.core.protocol.Welcome
+import pw.binom.viewmate.core.protocol.protocolJson
+import java.util.concurrent.ConcurrentLinkedQueue
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class GlassesWsClientTest {
+
+ companion object {
+ private const val PATH = "/ws/glasses"
+ }
+
+ /** Локальный WS-сервер: Hello → Welcome, всё входящее собирает в список. */
+ private class TestServer(private val port: Int = 0) {
+ val received = ConcurrentLinkedQueue()
+ private val sessions = ConcurrentLinkedQueue()
+
+ private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
+ install(WebSockets) {
+ pingPeriodMillis = 5_000
+ timeoutMillis = 5_000
+ maxFrameSize = Long.MAX_VALUE
+ masking = false
+ }
+ routing {
+ webSocket(PATH) {
+ sessions.add(this)
+ try {
+ for (frame in incoming) {
+ if (frame is Frame.Text) {
+ val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), frame.readText())
+ received.add(msg)
+ if (msg is Hello) {
+ send(protocolJson.encodeToString(
+ HostToGlasses.serializer(),
+ Welcome(mode = GlassesMode.MOVIE),
+ ))
+ }
+ }
+ }
+ } finally {
+ sessions.remove(this)
+ }
+ }
+ }
+ }
+
+ init {
+ server.start(wait = false)
+ }
+
+ suspend fun boundPort(): Int = server.engine.resolvedConnectors().first().port
+
+ suspend fun url(): String = "ws://127.0.0.1:${boundPort()}$PATH"
+
+ val sessionCount: Int
+ get() = sessions.size
+
+ suspend fun broadcast(msg: HostToGlasses) {
+ val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
+ for (s in sessions) {
+ try {
+ s.send(text)
+ } catch (_: Exception) {
+ // клиент мог отвалиться
+ }
+ }
+ }
+
+ fun stop() {
+ server.stop(500, 1_000)
+ }
+ }
+
+ private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
+ val deadline = System.currentTimeMillis() + timeoutMs
+ while (!condition()) {
+ if (System.currentTimeMillis() > deadline) {
+ error("условие не выполнено за ${timeoutMs}мс")
+ }
+ delay(50)
+ }
+ }
+
+ @Test
+ fun helloGetsWelcomeRoundTrip() {
+ val server = TestServer()
+ try {
+ runBlocking {
+ val client = GlassesWsClient(server.url())
+ val messages = mutableListOf()
+ val connected = CompletableDeferred()
+ val job = launch {
+ client.connect(
+ onHostMessage = { messages.add(it) },
+ onConnected = { connected.complete(Unit) },
+ )
+ }
+
+ withTimeout(10_000) { connected.await() }
+ client.send(Hello(appVersion = "0.1.0"))
+
+ awaitTrue { messages.any { it is Welcome } }
+ val welcome = messages.filterIsInstance().first()
+ assertEquals(GlassesMode.MOVIE, welcome.mode)
+
+ client.close()
+ job.cancelAndJoin()
+ }
+ } finally {
+ server.stop()
+ }
+ }
+
+ @Test
+ fun sendGestureReachesServer() {
+ val server = TestServer()
+ try {
+ runBlocking {
+ val client = GlassesWsClient(server.url())
+ val connected = CompletableDeferred()
+ val job = launch {
+ client.connect(
+ onHostMessage = {},
+ onConnected = { connected.complete(Unit) },
+ )
+ }
+
+ withTimeout(10_000) { connected.await() }
+ client.send(Gesture(gesture = "DoubleClick"))
+
+ awaitTrue { server.received.any { it is Gesture } }
+ val gesture = server.received.filterIsInstance().first()
+ assertEquals("DoubleClick", gesture.gesture)
+
+ client.close()
+ job.cancelAndJoin()
+ }
+ } finally {
+ server.stop()
+ }
+ }
+
+ @Test
+ fun receivesHostMessage() {
+ val server = TestServer()
+ try {
+ runBlocking {
+ val client = GlassesWsClient(server.url())
+ val messages = mutableListOf()
+ val connected = CompletableDeferred()
+ val job = launch {
+ client.connect(
+ onHostMessage = { messages.add(it) },
+ onConnected = { connected.complete(Unit) },
+ )
+ }
+
+ withTimeout(10_000) { connected.await() }
+ awaitTrue { server.sessionCount == 1 }
+
+ server.broadcast(SetMode(GlassesMode.CHAT))
+
+ awaitTrue { messages.any { it is SetMode } }
+ val setMode = messages.filterIsInstance().first()
+ assertEquals(GlassesMode.CHAT, setMode.mode)
+
+ client.close()
+ job.cancelAndJoin()
+ }
+ } finally {
+ server.stop()
+ }
+ }
+
+ @Test
+ fun reconnectsAfterServerDrop() {
+ val server = TestServer()
+ try {
+ runBlocking {
+ val port = server.boundPort()
+ var connects = 0
+ val client = GlassesWsClient(server.url())
+ val job = launch {
+ client.connect(
+ onHostMessage = {},
+ onConnected = { connects++ },
+ )
+ }
+
+ awaitTrue { connects >= 1 }
+ server.stop()
+
+ // сервер упал → клиент должен продолжать цикл (не выходить) и реконнектиться
+ delay(4_000)
+ kotlin.test.assertTrue(job.isActive, "клиент должен пережить обрыв сервера")
+
+ // поднимаем сервер на том же порту → клиент подключается заново
+ val server2 = TestServer(port)
+ try {
+ awaitTrue { connects >= 2 }
+ client.close()
+ job.cancelAndJoin()
+ } finally {
+ server2.stop()
+ }
+ }
+ } finally {
+ server.stop()
+ }
+ }
+}