diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..746b848 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,50 @@ +name: build +on: + release: + types: [published] +jobs: + android: + name: Android APK to F-Droid + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: https://github.com/actions/checkout@v4 + + - name: Setup Java 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: adopt + + - name: Setup Android SDK + shell: bash + run: | + set -euo pipefail + export ANDROID_HOME=$HOME/android-sdk + mkdir -p "$ANDROID_HOME/cmdline-tools" + if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then + curl -sSL -o /tmp/cmdtools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip + unzip -q -o /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools" + mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" + fi + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;36.0.0" > /dev/null + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + + - name: Build APKs + shell: bash + run: | + chmod +x gradlew + ./gradlew :app-phone:assembleRelease :app-glasses:assembleRelease \ + -PversionCode=${{ gitea.ref_name }} \ + --no-daemon --stacktrace + + - name: Publish phone to F-Droid + uses: https://git.binom.pw/subochev/devops/fdroid-publish@main + with: + apk_path: "app-phone/build/outputs/apk/release/app-phone-release-unsigned.apk" + + - name: Publish glasses to F-Droid + uses: https://git.binom.pw/subochev/devops/fdroid-publish@main + with: + apk_path: "app-glasses/build/outputs/apk/release/app-glasses-release-unsigned.apk" diff --git a/TASK.md b/TASK.md new file mode 100644 index 0000000..51f2a90 --- /dev/null +++ b/TASK.md @@ -0,0 +1,106 @@ +# Задача: масштаб на всех экранах очков + сохранение скролла каталога + +## Баг 1: ConnectionScreen игнорирует масштаб + +**Симптом (пользователь):** «в режиме НЕ просмотра видео (пока ничего не включено) +очки игнорируют выставленный масштаб». Плюс «масштаб будто бы не сохраняется — +сразу после старта масштаб 1». + +**Корень:** масштаб (`GlassesApp.instance.scale`, 0.5..1) применяется ТОЛЬКО в +`GlassesMovieScreen.kt` (контейнер `width((screenWidthPx * scale).toInt().px)` + +offset к центру). А `ConnectionScreen()` в +`app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt` (строка ~354) +рисует контент на ПОЛНУЮ половину глаза (`Modifier.width(screenWidthPx.px)`) — +scale не читает вообще. Поэтому и «не сохраняется»: сохранённый scale в prefs есть, +но на не-видео экране он не виден. + +**Фикс:** в `ConnectionScreen()` применять scale так же, как в GlassesMovieScreen: + +```kotlin +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() + val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ + + Binocular(scale = false) { + val screenWidthPx = BinocularScreenSize.current.width + val screenHeightPx = BinocularScreenSize.current.height + // масштаб через layout — как в GlassesMovieScreen + val containerW = (screenWidthPx * scale).toInt() + val containerH = (screenHeightPx * scale).toInt() + val offsetX = (screenWidthPx * (1f - scale)).toInt() // сдвиг к носу (левый глаз) + Box( + modifier = Modifier + .width(containerW.px) + .height(containerH.px) + .offset(x = offsetX.px) + .background(Color.Black), + ) { + ... существующий контент ... + } + } +} +``` + +Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose, +оверлей распознавания — но всё это теперь в уменьшенном контейнере. +Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть. + +⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри +`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер +уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192. +Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen +(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в +GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent, +там есть `Side.LEFT/RIGHT` через CompositionLocal). + +## Баг 2: скролл каталога теряется при навигации + +**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад +(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл +не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в +папку, потом ещё — и т.д.» + +**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`: +`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится. +`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит. + +**Фикс:** добавить в `CatalogState`: +- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция +- `private val savedScroll = mutableMapOf>()` — сохранённые позиции по пути +- в `openSeries`/`openSeason`/`openFolder`: **сначала** `savedScroll[path] = scrollIndex to scrollOffset`, **потом** менять path +- в `back()`: после смены path — восстановить `(scrollIndex, scrollOffset) = savedScroll[path] ?: (0,0)` +- методы `setScroll(index, offset)` (для snapshotFlow из UI) и `getSavedScroll(): Pair` (текущий path) + +В `CatalogScreen` composable: +```kotlin +val gridState = rememberLazyGridState() +// восстанавливаем позицию при смене пути/загрузке +LaunchedEffect(state.path, state.items) { + val (idx, off) = state.savedScrollForCurrentPath() + gridState.scrollToItem(idx, off) +} +// сохраняем позицию при скролле +LaunchedEffect(gridState) { + snapshotFlow { + gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset + }.collect { (idx, off) -> state.setScroll(idx, off) } +} +``` +и `LazyVerticalGrid(state = gridState, ...)`. + +Ключ сохранения — РОДИТЕЛЬСКИЙ path (тот, откуда ушли). При back() path меняется +на родителя — достаём сохранённое. Вложенные папки работают автоматически: +каждый уровень сохраняет свою позицию. + +Импорты: `androidx.compose.foundation.lazy.grid.rememberLazyGridState`, +`androidx.compose.runtime.snapshotFlow`. + +## Критерии приёмки + +1. Сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — успех. +2. Юнит-тесты: `./gradlew :lib-core:testDebugUnitTest :app-phone:testDebugUnitTest :app-glasses:testDebugUnitTest` — зелёные. +3. Ручная проверка на очках: выставить масштаб 0.7 → ConnectionScreen (без видео) тоже уменьшен (чёрные поля по краям, картинка/текст сдвинуты к носу). +4. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок. diff --git a/app-glasses/build.gradle.kts b/app-glasses/build.gradle.kts index 578737a..6ba0b17 100644 --- a/app-glasses/build.gradle.kts +++ b/app-glasses/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "pw.binom.viewmate.glasses" minSdk = 26 targetSdk = 34 - versionCode = 1 - versionName = "0.1.0" + versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1 + versionName = (project.findProperty("versionName") as? String) ?: "0.1.0" } buildTypes { 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 index 5ff05ec..9f871b8 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -45,6 +45,9 @@ class GlassesApp : Application() { /** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */ val assistantText = MutableStateFlow("") + /** Текущая Activity (для закрытия приложения по команде с телефона). */ + var mainActivity: MainActivity? = null + /** Применить и сохранить масштаб. */ fun applyScale(value: Float) { val v = value.coerceIn(0.5f, 1f) 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 index a37039e..a44a642 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -31,6 +31,7 @@ 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.SetScale +import pw.binom.viewmate.core.protocol.Shutdown import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.SttDone @@ -288,6 +289,14 @@ class HostConnection( log("stt", "отменено") GlassesApp.instance.listening.value = false } + + is Shutdown -> { + addMessage("Shutdown: закрываю приложение") + // как в pair-view: finish() Activity очков + GlassesApp.instance.mainActivity?.finishAffinity() + // страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo) + android.os.Process.killProcess(android.os.Process.myPid()) + } } } 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 b9e1d91..c7f5605 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 @@ -19,10 +19,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -56,9 +56,12 @@ import pw.binom.viewmate.glasses.gestures.templeActionToCommand import pw.binom.viewmate.glasses.status.StatusPanelCompose import pw.binom.viewmate.glasses.stt.SttMicStream import pw.binom.viewmate.glasses.ui.Binocular +import pw.binom.viewmate.glasses.ui.BinocularScreenSide import pw.binom.viewmate.glasses.ui.BinocularScreenSize import pw.binom.viewmate.glasses.ui.GlassesMovieScreen +import pw.binom.viewmate.glasses.ui.Side import pw.binom.viewmate.glasses.ui.px +import pw.binom.viewmate.glasses.ui.scaleOffsetX class MainActivity : BaseEventActivity() { private var resumeAfterScreenOn = false @@ -112,6 +115,7 @@ class MainActivity : BaseEventActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + GlassesApp.instance.mainActivity = this collectTempleActions() disableTrackpadSounds() WindowCompat.setDecorFitsSystemWindows(window, false) @@ -311,6 +315,7 @@ class MainActivity : BaseEventActivity() { } override fun onDestroy() { + GlassesApp.instance.mainActivity = null super.onDestroy() unregisterReceiver(screenReceiver) unregisterReceiver(debugReceiver) @@ -354,12 +359,27 @@ fun ConnectionScreen() { val connected by connection.connected.collectAsState() val status by connection.status.collectAsState() val messages by connection.messages.collectAsState() + val scale by GlassesApp.instance.scale.collectAsState() // Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой // половины (явная ширина половины, иначе fillMaxSize растянет на 1280). Binocular(scale = false) { val screenWidthPx = BinocularScreenSize.current.width - Box(modifier = Modifier.width(screenWidthPx.px).fillMaxHeight().background(Color.Black)) { + val screenHeightPx = BinocularScreenSize.current.height + val side = BinocularScreenSide.current + + // Масштаб через layout: контейнер каждого глаза — scale от половины экрана, + // сдвинут к центру (к носу) на (1 - scale) своей ширины. + val containerW = (screenWidthPx * scale).toInt() + val containerH = (screenHeightPx * scale).toInt() + + Box( + modifier = Modifier + .width(containerW.px) + .height(containerH.px) + .offset(x = scaleOffsetX(side, screenWidthPx, scale).px) + .background(Color.Black), + ) { Column( modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -377,6 +397,7 @@ fun ConnectionScreen() { StatusPanelCompose( modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), + scale = scale, ) // Оверлей распознавания: затемнение + накопленный текст (поверх всего). diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts index 4dedaaa..93ec3cf 100644 --- a/app-phone/build.gradle.kts +++ b/app-phone/build.gradle.kts @@ -37,8 +37,8 @@ android { applicationId = "pw.binom.viewmate.phone" minSdk = 26 targetSdk = 34 - versionCode = 1 - versionName = "0.1.0" + versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1 + versionName = (project.findProperty("versionName") as? String) ?: "0.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" // LLM-ключ из local.properties (llm.apiKey) — вне git; пустой = ещё не задан diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt index 7ddb5ae..d179291 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -26,6 +27,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -77,6 +79,13 @@ class CatalogState( private set var path by mutableStateOf(Path.Root) private set + var scrollIndex by mutableStateOf(0) + private set + var scrollOffset by mutableStateOf(0) + private set + + /** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */ + private val savedScroll = mutableMapOf>() private var searchJob: Job? = null private var loadJob: Job? = null @@ -102,16 +111,19 @@ class CatalogState( } fun openSeries(item: JellyfinItem) { + savedScroll[path] = scrollIndex to scrollOffset path = Path.Series(item) refresh() } fun openSeason(season: JellyfinItem, series: JellyfinItem) { + savedScroll[path] = scrollIndex to scrollOffset path = Path.Season(season, series) refresh() } fun openFolder(item: JellyfinItem) { + savedScroll[path] = scrollIndex to scrollOffset path = Path.Folder(item) refresh() } @@ -123,9 +135,27 @@ class CatalogState( is Path.Season -> Path.Series(p.series) is Path.Folder -> Path.Root } + val (idx, off) = savedScroll[path] ?: Pair(0, 0) + scrollIndex = idx + scrollOffset = off refresh() } + /** Сохранить позицию для текущего пути (перед уходом на детали и т.п.). */ + fun saveScrollNow() { + savedScroll[path] = scrollIndex to scrollOffset + } + + /** Сохранить текущую позицию (вызывается при скролле из UI). */ + fun setScroll(index: Int, offset: Int) { + scrollIndex = index + scrollOffset = offset + } + + /** Позиция скролла для текущего пути (восстановление после навигации). */ + fun savedScrollForCurrentPath(): Pair = + savedScroll[path] ?: Pair(0, 0) + /** Заголовок под шапкой: текущая вкладка или хлебная крошка внутри сериала/папки. */ fun subtitle(): String = when (val p = path) { Path.Root -> when (tab) { @@ -200,6 +230,21 @@ fun CatalogScreen( ) { val app = PhoneApp.instance val userId by app.userId.collectAsState() + + // Позиция скролла каталога: сохраняется/восстанавливается между навигациями. + val gridState = rememberLazyGridState() + + // Восстановить позицию при смене пути/загрузке. + LaunchedEffect(state.path, state.items) { + val (idx, off) = state.savedScrollForCurrentPath() + gridState.scrollToItem(idx, off) + } + // Сохранять позицию при скролле. + LaunchedEffect(gridState) { + snapshotFlow { + gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset + }.collect { (idx, off) -> state.setScroll(idx, off) } + } val downloaded by app.state.downloadedFlow.collectAsState() val localDownloaded by app.downloadManager.downloadedIds.collectAsState() @@ -218,6 +263,7 @@ fun CatalogScreen( ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто") } else { LazyVerticalGrid( + state = gridState, columns = GridCells.Fixed(2), contentPadding = PaddingValues(14.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -246,7 +292,11 @@ fun CatalogScreen( state.openSeason(item, series) } item.Type in CatalogState.CONTAINER_TYPES -> state.openFolder(item) - else -> onItemClick(item) + else -> { + // уходим на детали — сохранить позицию, чтобы назад вернул скролл + state.saveScrollNow() + onItemClick(item) + } } }, ) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt index 9197c65..ca02e94 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt @@ -213,6 +213,15 @@ fun GlassesScreen( Spacer(modifier = Modifier.height(12.dp)) + RemoteButton( + text = "⏻ Выключить очки", + big = true, + onClick = { + scope.launch { app.actions?.shutdownGlasses() } + }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp), + ) + RemoteButton( text = "Скачанное на очках →", big = true, diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt index 8105b9e..8e976eb 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt @@ -18,6 +18,7 @@ import pw.binom.viewmate.core.protocol.PlayVideo import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.SetScale import pw.binom.viewmate.core.protocol.ShowText +import pw.binom.viewmate.core.protocol.Shutdown /** * Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки. @@ -267,5 +268,11 @@ class PhoneActions( return "масштаб → $clamped" } + /** Закрыть приложение на очках (телефон остаётся открытым). */ + suspend fun shutdownGlasses(): String { + sender.send(Shutdown()) + return "выключение очков" + } + private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60 } diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt index 1a34482..cb20668 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt @@ -95,3 +95,8 @@ data class SttDone(val full: String) : HostToGlasses @Serializable @SerialName("stt_cancel") class SttCancel : HostToGlasses + +/** Полное выключение приложения на очках (по команде с телефона). */ +@Serializable +@SerialName("shutdown") +class Shutdown : HostToGlasses