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:
@@ -6,8 +6,19 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<application
|
||||
android:name=".PhoneApp"
|
||||
android:label="ViewMate"
|
||||
android:theme="@style/Theme.AppCompat"
|
||||
android:allowBackup="true">
|
||||
android:allowBackup="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -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<DefaultWebSocketServerSession, Unit>()
|
||||
|
||||
private val _connected = MutableStateFlow(0)
|
||||
val connected: StateFlow<Int> = _connected.asStateFlow()
|
||||
|
||||
private val _gesturesReceived = MutableStateFlow(0)
|
||||
val gesturesReceived: StateFlow<Int> = _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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
/** Единый лог телефона: простой println (работает и в unit-тестах). */
|
||||
fun log(tag: String, message: String) {
|
||||
println("[view-mate] $tag $message")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user