commit 5268a12d20294f4590440468e5c1b3ade7db1e6d Author: Hermes Agent Date: Tue Aug 11 11:34:50 2026 +0300 view-mate: болванка + концепт + эскизы + сценарии (единый коммит, история очищена от hprof/build) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46f87c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +build/ +local.properties +*.hprof diff --git a/README.md b/README.md new file mode 100644 index 0000000..93c7063 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# ViewMate + +Очки RayNeo X2 + компаньон на телефон: кино и чат с ассистентом. + +## Структура + +``` +app-glasses/ RayNeo X2 — AR-приложение: видео (ExoPlayer), жесты, связь по WiFi +app-phone/ компаньон на телефон — каталог, дубль-управление, чат +app-host/ отладочный хост — «бэк» без UI, к которому цепляются очки +lib-core/ общий слой (KMP): режимы, состояния, команды, DTO +sketches/ эскизы UI: раскладка экранов + сценарии использования +``` + +## Эскизы интерфейса + +- `sketches/phone-ui/index.html` — эскизы 4 экранов телефона (раскладка компонентов, открыть в браузере) +- `sketches/phone-ui/preview.png` — скриншот эскизов +- `sketches/phone-ui/FLOW.md` — **сценарии использования**: как посмотреть фильм, проверить очки, поговорить с ассистентом, что видно при не-сконвертированном/частично скачанном контенте + +## Режимы + +### Два режима на очках + +| Режим | Состояния | Что происходит | +|-------|-----------|----------------| +| **MOVIE** (кино) | `PLAYING`, `PAUSED` | видео на очках (ExoPlayer), звук выбранной дорожки на телефоне, синхронизация по позиции | +| **CHAT** (общение) | `SESSION_SELECT`, `TALKING` | выбор сессии Hermes, затем разговор в рамках сессии | + +Переключение режимов — **с телефона** (и жестом на очках). Оба дёргают один локальный агент (lib-core). + +### Каталог — НЕ режим очков + +Каталог контента (Jellyfin) живёт **только на телефоне** (и в будущем — у голосового ассистента, который тоже будет по нему ходить). Выбрал в каталоге → «включить» → очки переходят в режим MOVIE с готовым контентом. + +Очки — «экран кинотеатра»: они ждут, когда им скажут «включи», и не листают каталог сами. + +### Локальный голосовой ассистент (в будущем) + +Вызывается **в любом режиме** (двойной клик в чате), поверх всего. Умеет: ходить по каталогу, включать/ставить кино, переключать режимы, переключать сессии, отвечать на вопросы (локально, без сервера). + +## Жесты (распознаёт RayNeo SDK, `TempleAction`) + +| Жест (SDK) | Режим КИНО | Режим ЧАТ | +|------------|-----------|-----------| +| `Click` | пауза ↔ плей | старт / стоп диктовки | +| `DoubleClick` | игнор | отмена диктовки; вне диктовки — вызов локального ассистента | +| `SlideBackward` / `SlideForward` | перемотка | в `SESSION_SELECT` — листание сессий; в `TALKING` — скролл текста ответа | +| `LongClick` | (резерв) | показать / спрятать текст последнего ответа | +| `TripleClick` | (резерв) | (резерв) | + +> Жесты распознаёт сам SDK (`TempleAction.Click/DoubleClick/TripleClick/SlideBackward/SlideForward/LongClick`) — своя детекция не нужна (проверено в pair-view, `MainActivity.kt`). + +## Состояния ассистента (единые для обоих ассистентов) + +``` +IDLE ──► LISTENING ──► THINKING ──► SPEAKING ──► IDLE + (слушает) (думает) (говорит) +``` + +| Состояние | Индикатор | +|-----------|-----------| +| IDLE | ничего | +| LISTENING | красная точка ● мигает + время записи (0:07, 0:23…) | +| THINKING | спиннер / точки «думает» | +| SPEAKING | «говорит» (другим цветом / без мигания) | + +Один и тот же индикатор для Порфирия и локального ассистента — очкам неважно, кто за ним стоит. + +## Панель индикаторов + +**Всегда видна, по центру снизу.** Содержимое меняется от режима: + +| Режим/состояние | Содержимое панели | +|-----------------|-------------------| +| КИНО | `просмотрено / осталось` (12:34 / 56:00) + 🔋 батарея + ⏰ часы | +| ЧАТ, диктовка | `● 0:07` (красная точка + время записи) + 🔋 + ⏰ | +| ЧАТ, думает/говорит | индикатор состояния (⏳/🔊) + 🔋 + ⏰ | +| **Потеря связи с телефоном** | **вместо времени — «связь потеряна» (⚠️)** + 🔋 | + +- Часы — **время очков** (часы очков, просто время) +- Батарея — очков +- Просмотрено/осталось — из `PlaybackState` (positionMs/durationMs) +- Если связи с телефоном нет — панель показывает ⚠️ вместо времени (см. Автоматика) + +## Текст ответа на экране + +Длинные ответы можно показать текстом (по `LongClick`) и скроллить свайпами. Полезно, когда ответ многословный — перечитать спокойно. + +## Автоматика + +| Событие | Действие | +|---------|----------| +| Очки выключились / экран погас | **пауза** (видео и аудио) | +| Потеря связи очки ↔ телефон (WiFi) | **пауза** (и на очках, и на телефоне) + на панели ⚠️ «связь потеряна» вместо времени | +| Связь восстановилась | продолжить с того же места, панель — как обычно | + +## Связь и поток + +``` +очки (RayNeo X2) телефон (компаньон) сервер +┌─────────────────┐ ┌──────────────────┐ +│ режимы: │ │ каталог Jellyfin │ Jellyfin +│ MOVIE/CHAT │◄──WiFi────►│ статусы зеркал │──media-mirror API──► координатор +│ жест = switch │ │ заказ конвертации │ → воркеры → S3 +│ видео ExoPlayer │ │ переключение │ +│ │ │ режимов очков │ +│ │ │ чат с Hermes │──assistent/chat──► Hermes API +└─────────────────┘ └──────────────────┘ → TTS → в ухо +``` + +- **Один локальный агент** (на телефоне) — очки и телефон два рендера одного состояния +- **WiFi-связь** очки ↔ телефон (вместо Bluetooth в старом pair-view) +- **Кино**: видео 480p H.264 из S3 (media-mirror) на очках, звук выбранной дорожки на телефоне, синхронизация по позиции (очки — мастер, телефон подстраивает аудио) +- **Чат**: с Hermes через сервер; медиа-интенты («включи сериал X») — напрямую в media-mirror API, без LLM + +## Медиа-конвейер (внешний, уже живой) + +- Координатор: `https://mirror.binom.pw` (X-API-Key) — заказ конвертации, статусы, файлы +- Формат зеркала: `video.mkv` (H.264/NVENC, 480p, без звука) + `audio-N.ogg` (все дорожки, мета title/language) +- S3: `https://s3.binom.pw`, bucket `media`, ключи `mirror/{itemId}/...`; клиентский ключ (read-only) — в конфиге + +Полное описание API: `media-mirror-api/README.md` (https://git.binom.pw/subochev/media-mirror-api). diff --git a/app-glasses/build.gradle.kts b/app-glasses/build.gradle.kts new file mode 100644 index 0000000..d5f5615 --- /dev/null +++ b/app-glasses/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "pw.binom.viewmate.glasses" + compileSdk = 35 + + defaultConfig { + applicationId = "pw.binom.viewmate.glasses" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + +} + +dependencies { + // RayNeo SDK (локальный aar) + implementation(fileTree("libs")) + implementation("androidx.appcompat:appcompat:1.3.0") + implementation("androidx.core:core-ktx:1.7.0") + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.android.material3) + implementation(libs.exoplayer) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + implementation(project(":lib-core")) +} diff --git a/app-glasses/libs/RayNeoARSDK-For-Android-V0.1.3-20240524145848_a64b0b97.aar b/app-glasses/libs/RayNeoARSDK-For-Android-V0.1.3-20240524145848_a64b0b97.aar new file mode 100644 index 0000000..d3c8588 Binary files /dev/null and b/app-glasses/libs/RayNeoARSDK-For-Android-V0.1.3-20240524145848_a64b0b97.aar differ diff --git a/app-glasses/src/main/AndroidManifest.xml b/app-glasses/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0aa9b1e --- /dev/null +++ b/app-glasses/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + 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 new file mode 100644 index 0000000..b23b1a3 --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -0,0 +1,12 @@ +package pw.binom.viewmate.glasses + +import android.app.Activity +import android.os.Bundle + +class MainActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Точка входа на очках: инициализация RayNeo SDK, режимы, связь. + // Пока — заглушка, заполняется на следующем шаге. + } +} diff --git a/app-host/build.gradle.kts b/app-host/build.gradle.kts new file mode 100644 index 0000000..831e5a4 --- /dev/null +++ b/app-host/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "pw.binom.viewmate.host" + compileSdk = 35 + + defaultConfig { + applicationId = "pw.binom.viewmate.host" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + +} + +dependencies { + implementation("androidx.appcompat:appcompat:1.3.0") + implementation("androidx.core:core-ktx:1.7.0") + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.android.material3) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + implementation(project(":lib-core")) +} diff --git a/app-host/src/main/AndroidManifest.xml b/app-host/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e0205c0 --- /dev/null +++ b/app-host/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/MainActivity.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/MainActivity.kt new file mode 100644 index 0000000..6c2599e --- /dev/null +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/MainActivity.kt @@ -0,0 +1,12 @@ +package pw.binom.viewmate.host + +import android.app.Activity +import android.os.Bundle + +class MainActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Отладочный хост: к нему цепляются очки по WiFi, он эмулирует + // телефон (дубль-управление, логи, тест каналов). Пока — заглушка. + } +} diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts new file mode 100644 index 0000000..96f130c --- /dev/null +++ b/app-phone/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "pw.binom.viewmate.phone" + compileSdk = 35 + + defaultConfig { + applicationId = "pw.binom.viewmate.phone" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + +} + +dependencies { + implementation("androidx.appcompat:appcompat:1.3.0") + implementation("androidx.core:core-ktx:1.7.0") + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.android.material3) + implementation(libs.exoplayer) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + implementation(project(":lib-core")) +} diff --git a/app-phone/src/main/AndroidManifest.xml b/app-phone/src/main/AndroidManifest.xml new file mode 100644 index 0000000..718e3ae --- /dev/null +++ b/app-phone/src/main/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + + 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 new file mode 100644 index 0000000..8dc5b4d --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt @@ -0,0 +1,11 @@ +package pw.binom.viewmate.phone + +import android.app.Activity +import android.os.Bundle + +class MainActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Компаньон: дубль-управление, чат, каталог. Пока — заглушка. + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0106559 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlinx.serialization) apply false + alias(libs.plugins.compose.compiler) apply false +} + +allprojects { + if (version == "unspecified") { + version = "1.0.0-SNAPSHOT" + } + group = "pw.binom.viewmate" +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..1f5a100 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.mpp.androidSourceSetLayoutVersion=2 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..6e1d655 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,28 @@ +[versions] +kotlin = "2.2.0" +kotlinx-serialization = "1.8.1" +kotlinx-coroutines = "1.10.2" +android = "8.9.2" +compose-plugin = "1.8.0" +androidx-compose-bom = "2025.05.00" +androidx-activity-compose = "1.10.1" +exoplayer = "2.17.1" + +[libraries] +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +android-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "android" } +android-library = { id = "com.android.library", version.ref = "android" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c1f93d8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sat Apr 15 17:57:35 CST 2023 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib-core/build.gradle.kts b/lib-core/build.gradle.kts new file mode 100644 index 0000000..a11dc2f --- /dev/null +++ b/lib-core/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.kotlinx.serialization) +} + +kotlin { + androidTarget { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + jvm() + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + } + androidMain.dependencies { + implementation(libs.kotlinx.coroutines.android) + } + } +} + +android { + namespace = "pw.binom.viewmate.core" + compileSdk = 35 + defaultConfig { + minSdk = 26 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/GlassesMode.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/GlassesMode.kt new file mode 100644 index 0000000..27fedf8 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/GlassesMode.kt @@ -0,0 +1,62 @@ +package pw.binom.viewmate.core + +import kotlinx.serialization.Serializable + +/** + * Режимы очков. Переключаются с телефона (и жестом на очках). + * Каталог контента — НЕ режим: он живёт на телефоне (и в будущем — + * у голосового ассистента), а очки только показывают выбранное. + */ +@Serializable +enum class GlassesMode { + /** Кино: видео на очках (ExoPlayer), звук на телефоне. */ + MOVIE, + + /** Общение с ассистентом (Hermes): выбор сессии + разговор. */ + CHAT, +} + +/** + * Состояния режима кино. BROWSING здесь нет — выбор контента + * происходит на телефоне (каталог Jellyfin), очки получают готовое. + */ +@Serializable +enum class MovieState { + PLAYING, + PAUSED, +} + +/** + * Состояния режима общения. + */ +@Serializable +enum class ChatState { + /** Выбор сессии (список сессий Hermes). */ + SESSION_SELECT, + + /** Разговор в активной сессии. */ + TALKING, +} + +/** Команды управления медиа — единые для очков и телефона. */ +@Serializable +enum class MediaCommand { + PLAY, + PAUSE, + TOGGLE, + SEEK_FORWARD, + SEEK_BACKWARD, + NEXT_AUDIO, + PREV_AUDIO, + CLOSE, +} + +/** Состояние воспроизведения (синхронизация очки ↔ телефон). */ +@Serializable +data class PlaybackState( + val itemId: String? = null, + val positionMs: Long = 0, + val durationMs: Long = 0, + val audioIndex: Int = 0, + val playing: Boolean = false, +) diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..2c6d5fe --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + repositories { + mavenLocal() + mavenCentral() + maven(url = "https://repo.binom.pw") + gradlePluginPortal() + google() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenLocal() + maven(url = "https://repo.binom.pw") + maven(url = "https://jitpack.io") + mavenCentral() + google() + } +} + +rootProject.name = "view-mate" + +include(":app-glasses") // RayNeo X2 — AR, видео, жесты, связь +include(":app-phone") // компаньон на телефон — дубль-управление, чат, каталог +include(":app-host") // отладочный хост: «бэк» без UI, к которому цепляются очки +include(":lib-core") // общий слой: связь, стейт-машина, клиенты Hermes/media-mirror diff --git a/sketches/phone-ui/FLOW.md b/sketches/phone-ui/FLOW.md new file mode 100644 index 0000000..7ac9942 --- /dev/null +++ b/sketches/phone-ui/FLOW.md @@ -0,0 +1,138 @@ +# ViewMate — сценарии использования телефонного интерфейса + +Описание по эскизам `sketches/phone-ui/index.html`. Дизайн — эскизный (раскладка компонентов), но сценарии — рабочие. + +--- + +## Сценарий 1: «Посмотреть фильм» (главный путь) + +### Шаг 1 — Открыл приложение → Экран «Каталог» + +Вижу: поиск (вверху), вкладки **Фильмы / Сериалы / Папки**, ниже — сетка карточек. + +Каждая карточка — элемент библиотеки Jellyfin: постер + название + **бейдж статуса зеркала**: + +| Бейдж | Значение | +|-------|----------| +| ✓ (зелёный) | зеркало **готово** — можно смотреть сразу | +| ⟳ 45% (жёлтый) | **конвертируется** прямо сейчас (воркер молотит) | +| «нет» (серый) | **не сконвертировано** — надо заказать | +| ↓ часть (синий) | **частично скачано** на устройство (видео есть, звук качается) | + +> **Вкладка «Папки»** — это вход в иерархию Jellyfin: коллекции, папки, подпапки. «Фильмы» и «Сериалы» — удобные фильтры-ярлыки поверх неё. + +### Шаг 2 — Тапнул по карточке → Экран «Детали фильма» + +Открылся экран: постер, название, метаданные (год, жанр, длительность). + +Ниже — **три блока:** + +**Блок «Выбор озвучки»** — список аудиодорожек из зеркала (title + language), радио-выбор: +``` +◉ Дубляж (Reanimedia) rus +○ Оригинал eng +○ Профессиональный многоголосый rus +``` +Выбор запоминается — это дорожка, которая заиграет при старте. + +**Блок «Статус конвертации»** — если зеркала нет или оно готовится: +``` +Конвертация зеркала ⟳ 45% +[██████████░░░░░░░░░░░░] +``` +- «нет» в каталоге → здесь будет кнопка/статус «конвертировать» (заказ через media-mirror) +- идёт → прогресс, обновляется +- готово → блок исчезает или пишет «✓ готово» + +**Блок «Скачивание на устройства»** — что и где лежит (видео и звук отдельно!): +``` +🕶️ Очки — видео ✓ скачано +🕶️ Очки — звук ↓ качается… +📱 Телефон — видео нет +📱 Телефон — звук ✓ скачано +``` +Это и есть ответ на «частично скачано»: **видео и звук — два независимых файла** (видео.mkv на очках, звук.ogg на телефоне). Каждый может быть в своём состоянии. + +Под блоком — **кнопка «Удалить скачанное (очки + телефон)»** — освободить память обоих устройств разом. + +### Шаг 3 — Нажал «▶ Смотреть на очках» + +**Если зеркало готово и скачано/скачается:** очки переходят в режим КИНО, видео играет на очках, выбранная озвучка — на телефоне (синхронизация по позиции). + +**Если зеркало ещё не сконвертировано:** кнопка показывает реальное состояние: +- «Конвертировать» (если «нет») → заказ в media-mirror → кнопка становится «⟳ 45%» (прогресс) +- когда done — кнопка становится «▶ Смотреть» и можно играть + +### Шаг 4 — Управление воспроизведением + +- с телефона: экран «Статус очков» → пульт (перемотка ±30с, плей/пауза) +- с очков: жест-клик — пауза/плей, свайп ← → — перемотка + +--- + +## Сценарий 2: «Проверить очки и память» + +Экран «Статус очков» (вкладка 🕶️ внизу): + +``` +🕶️ Связь с очками подключено (WiFi) ● (зелёный) +🔋 Батарея очков 68% [████████░░] +💾 Память очков 41,2 ГБ из 128 ГБ [█████░░░░░] +🎬 Сейчас на очках Дюна 2 · 12:34/56:00 (пауза) +``` + +- **память очков** — сколько всего и сколько занято (то, что вы просили) +- **«Сейчас на очках»** — живое состояние: какой фильм, позиция, играет/пауза +- кнопка **«Скачанное на очках →»** — список файлов на очках, оттуда можно удалять (управление памятью очков) + +Ниже — **пульт**: перемотка, плей/пауза, **переключение режима очков** (Кино ⇄ Чат). + +--- + +## Сценарий 3: «Поговорить с Порфирием» + +Экран «Чат» (вкладка 💬): + +1. **Лента разговора** — сообщения туда-сюда +2. **Индикаторы состояния**: + - «Порфирий думает…» (три точки) — когда я соображаю + - «● слушаю… 0:07» (красная точка + таймер) — когда вы диктуете +3. **Список сессий** — внизу: «Очки/кино», «Работа», «Общее». Тапнул — переключился на другую сессию (параллельная работа с ассистентом) +4. **🎙** — запись голоса + +То же самое доступно с очков: режим ЧАТ, клик — начать/закончить диктовку, двойной клик — отмена/локальный агент, свайп — скролл текста ответа. + +--- + +## Сценарий 4: «Фильм не сконвертирован — что я увижу?» + +1. В каталоге карточка с бейджем **«нет»** (серый) +2. Тапнул → в деталях блок конвертации: «Конвертировать» +3. Тапнул «Конвертировать» → бейдж в каталоге становится **⟳ x%** (жёлтый), в деталях — прогресс-бар +4. Пока конвертится — «Смотреть» недоступна (или показывает «идёт конвертация») +5. Готово → бейдж **✓**, кнопка «▶ Смотреть» + +--- + +## Сценарий 5: «Скачал, но не до конца» (частично) + +Ситуация: телефон скачал видео, а звук ещё качается (или наоборот). + +- В каталоге — бейдж **↓ часть** (синий) +- В деталях — блок скачивания показывает покомпонентно: + ``` + 🕶️ Очки — видео ✓ (есть) + 🕶️ Очки — звук ↓ качается… + ``` +- Кнопка «Смотреть» работает, если **видео на очках готово** — звук докачается в фоне, синхронизация догонит +- «Удалить скачанное» — очищает и очки, и телефон + +--- + +## Принципы (кратко) + +1. **Каталог — только телефон.** Очки не листают библиотеку — они «экран кинотеатра», ждут команду «включи». +2. **Видео и звук — разные файлы** (видео.mkv на очках, звук.ogg на телефоне), поэтому состояния у них свои — отсюда «частично скачано». +3. **Всё начинается с вопроса «есть ли зеркало?»** — бейдж в каталоге показывает ответ ещё до входа в карточку. +4. **Один локальный агент** — телефон и очки два рендера одного состояния: что видно на экране «Статус очков», то и на очках. +5. **Сессии** — параллельная работа с ассистентом: переключил сессию — разговор продолжается в другом контексте. diff --git a/sketches/phone-ui/index.html b/sketches/phone-ui/index.html new file mode 100644 index 0000000..556e4e3 --- /dev/null +++ b/sketches/phone-ui/index.html @@ -0,0 +1,291 @@ + + + + +ViewMate — эскизы телефонного интерфейса + + + + +

ViewMate — эскизы интерфейса телефона (раскладка компонентов)

+
Не дизайн, а расположение элементов. Четыре экрана: каталог, детали фильма, статус очков, чат. Номера = легенда под каждым экраном.
+ +
+ + +
+
Экран 1 — Каталог (Jellyfin)
+
+
21:47🔋 84%
+ 1 + + 2 +
+
Фильмы
+
Сериалы
+
Папки
+
+
+
постер
Дюна: Часть вторая
✓
+
постер
Интерстеллар
⟳ 45%
+
постер
Бегущий по лезвию 2049
нет
+
постер
Гостья из будущего
↓ часть
+
+
+
🎬Каталог
+
🕶️Очки
+
💬Чат
+
+
+
+
1поиск по каталогу (строка вверху)
+
2вкладки: Фильмы / Сериалы / Папки (вход в папки Jellyfin)
+
3карточка элемента: постер + название
+
4статус зеркала: ✓ готово / ⟳ конвертируется / нет / ↓ частично скачано
+
5нижняя навигация: Каталог / Очки / Чат
+
+
+ + +
+
Экран 2 — Детали фильма + озвучка + скачивание
+
+
‹ назад🔋 84%
+
постер / обложка
+
Дюна: Часть вторая
+
2024 · фантастика · 2ч 46м · 1080p
+ +
+ +
Дубляж (Reanimedia)rus
+
Оригиналeng
+
Профессиональный многоголосыйrus
+
+ +
+ +
+
Конвертация зеркала⟳ 45%
+
+
+
+ +
+ +
+
🕶️ Очки — видео✓ скачано
+
🕶️ Очки — звук↓ качается…
+
📱 Телефон — видеонет
+
📱 Телефон — звук✓ скачано
+
+
Удалить скачанное (очки + телефон)
+
+ +
+
▶ Смотреть на очках
+
Скачать на телефон
+
+
+
+
1список дорожек из зеркала (title + language), радио-выбор
+
2статус/прогресс конвертации зеркала
+
3что скачано на очках и на телефоне: видео/звук отдельно (в т.ч. «частично»)
+
4удаление скачанного с обоих устройств
+
5главные действия: смотреть / скачать
+
+
+ + +
+
Экран 3 — Статус очков + пульт
+
+
21:47🔋 84%
+ +
+
+
🕶️
+
Связь с очками
подключено (WiFi)
+ ● +
+
+
🔋
+
Батарея очков
68%
+
+
+
+
💾
+
Память очков
41,2 ГБ из 128 ГБ занято
+
+
+
+
🎬
+
Сейчас на очках
Дюна 2 · 12:34 / 56:00
+ пауза +
+
+ +
+
⏪ −30с
+
⏩ +30с
+
▶ Плей
+
⏸ Пауза
+
🔀 Режим: Кино
+
💬 Режим: Чат
+
Скачанное на очках →
+
+
+
+
1карточка очков: связь / батарея / память (всего и занято) / что сейчас на экране
+
2пульт: перемотка, плей/пауза
+
3переключение режима очков (Кино ⇄ Чат)
+
4список скачанного на очках (управление памятью очков)
+
+
+ + +
+
Экран 4 — Чат с Порфирием (сессии)
+
+
21:47🔋 84%
+ +
+
Порфирий думает…
+
Дорогой друг, зеркало для «Дюны» готово, можно смотреть. Озвучка — дубляж Reanimedia, русская дорожка.
+
Отлично, включай на очках с дубляжом.
+
Включаю. 12:34 / 56:00, звук на телефоне.
+
слушаю… 0:07
+
Переключи на сессию про работу.
+
+ +
+
🕶️
Очки / кино
активная
+
💼
Работа
3 сообщения
+
🏠
Общее
вчера
+
+ +
🎙
+
+
+
1лента разговора: сообщения ассистента и пользователя
+
2индикаторы состояния: «думает…» / «слушаю… + таймер записи» (красная точка)
+
3список сессий Hermes — переключение активной (параллельная работа)
+
4кнопка записи голоса (диктовка)
+
+
+ +
+ + diff --git a/sketches/phone-ui/preview.png b/sketches/phone-ui/preview.png new file mode 100644 index 0000000..a06bcc4 Binary files /dev/null and b/sketches/phone-ui/preview.png differ