Files
ds-galo/openspec/changes/archive/2026-10-13-add-mobile-photo-zoom/design.md
T

75 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design
## Context
См. `proposal.md` — Why. Текущее устройство просмотра:
- `ViewerScreen.kt`: `ViewerContent` держит `HorizontalPager`; `pagerScrollEnabled =
current?.isPanorama != true`. `MediaPage` разветвляет видео / панораму / изображение.
- Изображение рисуется `GaloAsyncImage(... ContentScale.Fit)` — статично, без жестов.
- Панорама уже имеет pinch-зум через `PanoramaView` + `PanoramaCamera`.
- Веб-просмотрщик (`frontend/.../Viewer.svelte`) задаёт семантику зума: scale —
множитель к натуральному размеру, предел 200% (`MAX_ZOOM = 2`), минимум — fit
(`fitScale`), при зуме догружается `original_url`, зум сбрасывается при смене фото.
Тач-жестов в вебе нет — жесты проектируем заново.
- Прецедент ручных жестов: `AvatarCropDialog.kt` (`pointerInput { detectDragGestures }`).
## Goals / Non-Goals
**Goals:**
- Дать зум обычных изображений в Android-просмотрщике с веб-паритетом по семантике
(предел от натурального размера, догрузка оригинала).
- Сохранить корректную навигацию по альбому (свайп) вне зума.
**Non-Goals:**
- Зум видео (исключён требованием просмотра медиа).
- Изменения панорамы — её зум остаётся как есть.
- Изменения веб-клиента и сервера.
## Decisions
### Ручные жесты вместо библиотеки
Зум реализуется composable-обёрткой над изображением с `Modifier.pointerInput` +
`graphicsLayer` (scale/translation), без сторонних зависимостей. Альтернатива —
библиотека зума; отклонено: в проекте уже принят ручной подход (`AvatarCropDialog`),
а новых зависимостей избегаем.
### Масштаб как множитель к натуральному размеру (вариант A)
Храним `scale` относительно натурального размера, как в вебе:
`fitScale = min(viewW / natW, viewH / natH, 1)`, допустимый диапазон
`[fitScale, 2.0]` (200% натурального). Натуральные размеры берём из `Photo.width/height`
(уже есть в модели), при отсутствии — из измеренного `intrinsicSize` изображения.
Альтернатива (множитель от fit) отклонена в пользу веб-паритета.
### Двойной тап -> 100% натурального с якорем в точку тапа
Двойной тап переключает fit <-> 100% натурального размера, центрируя на точке нажатия.
Это тач-замена веб-`Space`, но с якорем в палец вместо центра.
### Состояние зума живёт в `ViewerContent`
`zoomed` поднимается в `ViewerContent`, а zoomable-обёртка сообщает его вверх через
колбэк. `pagerScrollEnabled = current?.isPanorama != true && !zoomed` — по той же логике,
что уже применяется к панораме. Альтернатива (держать состояние внутри `MediaPage`) не
даёт управлять pager'ом.
### Догрузка оригинала через Coil по стабильному ключу
При входе в зум источник изображения переключается с `previewUrl` на `originalUrl`;
Coil кэширует по устойчивому ключу объекта (`ObjectCacheKey`), поэтому подмена не ломает
кэш. Пока оригинал грузится, на экране остаётся превью: запрос оригинала получает
`placeholderMemoryCacheKey` превью, чтобы вход в зум не мигал пустым (чёрным) фоном.
Сбрасывается при смене фото.
### Сброс при смене фото
Состояние зума живёт на страницу pager (ключ — id фото); при переходе на другой элемент
оно пересоздаётся во вписанном виде, что соответствует требованию сброса.
## Risks / Trade-offs
- [Свайп pager перехватывает горизонтальный жест увеличенного фото] -> `pagerScrollEnabled`
выключается, пока `zoomed`.
- [`Photo.width/height` может быть 0 до извлечения EXIF] -> fallback на `intrinsicSize`
загруженного изображения; при 0/0 зум временно недоступен.
- [Догрузка оригинала расходует трафик при каждом входе в зум] -> загрузка только по факту
зума и с дисковым кэшем Coil.
- [Точность якоря при двойном тапе и pinch] -> покрываем юнит-тестом на пересчёт
scale/offset.