feat/web-support #76

Merged
EgorEast merged 9 commits from feat/web-support into main 2026-09-04 14:09:27 +00:00
Owner

фикс багов в веб версии. Реализован workflow релиза web

фикс багов в веб версии. Реализован workflow релиза web
react-native-web has no Appearance.setColorScheme, MediaSession, or
expo-file-system Directory; the onLayout event has no currentTarget.
Each of these threw during boot on web.

- theme model: Appearance.setColorScheme?.() optional call
- PlayerProgressBar: measure() via a View ref, not event.currentTarget
- WebPlayerService: add setLockScreenMetadata /
reassertLockScreenMetadata
  / getVolume / setVolume / getStatus as web stubs
(webPlayerStubControls)
- getAudioCacheDirectory: throw a clear error on web (safety net; the
real
  web cache lives in AudioCacheService.web)

Signed-off-by: egoreast <egor.east@yandex.ru>
WebPlayerService kept isPlaying / isBuffering / position only in its
local webPlayerState; the UI (usePlayerState) reads the Reatom atoms,
so on web the play/pause button never flipped and the progress bar
stayed at zero.

createWebPlayerState.update() now dispatches setIsPlayingAction /
setIsBufferingAction / setPositionAction on change, the same way the
native PlayerService does via its expo-audio status listeners. Duration
stays owned by webDurationWriter.

Signed-off-by: egoreast <egor.east@yandex.ru>
The only dynamic import() in the codebase - import('expo-notifications')
in ensureNotifications.ts - crashed the web dev bundle with "Requiring
unknown module" (Metro serves the lazy async chunk without the requested
module). expo-notifications does nothing useful on web anyway (only
push-token "not supported" warnings).

ensureNotifications.web.ts resolves to null immediately, keeping the
package (and the import()) out of the web graph entirely.

The `yarn web` script also sets EXPO_NO_METRO_LAZY=1 as a second layer -
added alongside the other web scripts in the following commit.

Signed-off-by: egoreast <egor.east@yandex.ru>
Web audio caching, backed by the Cache Storage API instead of
expo-file-system:

- AudioCacheService.web.ts - same public API as native (isCached /
  cacheAudio / clearCache / removeFromCache / getCacheInfo); shares the
  inflightCache for download dedup
- webAudioDownload.ts - CORS fetch first (real 0..1 progress via
  Content-Length), falls back to opaque no-cors when the host sends no
  CORS headers
- webCacheApi.ts - low-level Cache Storage ops, AUDIO_CACHE_NAME kept in
  sync with public/sw.js

PWA:

- public/index.html - custom shell (manifest link, theme-color, apple
  meta, icons, SW registration; skipped on localhost so it never fights
  Metro), themed-scrollbar CSS vars
- public/sw.js - hand-rolled service worker (@ts-check + webworker lib):
  offline app shell (prod hosts only) + cache-first offline audio with
  Range support; the audio bucket survives SW updates
- public/manifest.webmanifest + icons generated from assets/

Tooling:

- app.json web: lang/name/shortName/description
- package.json: web (EXPO_NO_METRO_LAZY=1), web:build, web:preview
- tsconfig / eslint / prettier / knip: exclude the public/ folder and
  the web-only source variants

Signed-off-by: egoreast <egor.east@yandex.ru>
`{someString && <Text>...}` renders the empty string as a bare text
node when the value is '', which react-native-web rejects as a child
of <View> ("Unexpected text node"). Switched the string/icon-name
guards to `{value ? <Text> : null}` in the playlist list, playlist
header, track list item, settings items and slider description.

Signed-off-by: egoreast <egor.east@yandex.ru>
On wide viewports the phone-style chrome stretched edge to edge.

shared/ui/layout/appMaxWidth.ts adds APP_MAX_CONTENT_WIDTH (600px, web
only - undefined on native) and a worklet-safe getColumnSideInset().

Centered in that column on desktop:

- tab bar (floatingIsland)
- collapsed mini player + collapsed ContainerView geometry (only the
  progress -> 0 endpoint of getRestingContainerStyle and the
  useExpandAnimation worklet; the fullscreen view is untouched)
- fullscreen player: the three-dots row (trackInfoRow) and the controls
  block (controlsArea); the progress bar and header stay full width

Native is unaffected: getColumnSideInset returns the min inset when
there is no cap.

Signed-off-by: egoreast <egor.east@yandex.ru>
PlayerMenu's menuWrapper used overflow: 'scroll', which on web forces
permanent empty scrollbars on both axes (native ignores it). The menu
animates to its full content height, so 'hidden' is enough.

ThemeProvider now feeds the active theme into the CSS scrollbar vars
declared in public/index.html (thumb = textMuted, hover = text), so the
webkit/Firefox scrollbars recolour with the light/dark theme. The CSS
fallback is a theme-neutral grey.

Signed-off-by: egoreast <egor.east@yandex.ru>
- docs/features/web.md - new: PWA, service worker, offline audio cache,
  web player, desktop layout, scrollbars, the Metro lazy-bundling
  workaround, deploy notes
- decisions.md - web build model; vite-plugin-pwa and axios rejected
- debt.md - Web / PWA section with every corner cut this round
  (theme-dialog error not reproduced, opaque cross-origin caching,
  no prod hosting, output:single deep links, hand-rolled SW, etc.)
- audio-cache / player / theme / architecture - web pointers
- AGENTS.md web scripts (CHANGELOG left alone - bump-version.mjs
  auto-generates it from commit subjects)

Signed-off-by: egoreast <egor.east@yandex.ru>
ci(web): deploy the web PWA, split release into per-target jobs
Some checks failed
CI / dco (pull_request) Has been cancelled
CI / checks (pull_request) Has been cancelled
ca10c29aed
release.yml was one monolithic job (APK only). Split into:

- create-release: waits for CI, verifies package.json / app.json /
  build.gradle against the tag, cuts the Forgejo Release, outputs
  `version`
- web: needs create-release only. `yarn web:build`, attaches a zip of
  dist/ to the release, then (if VPS_HOST + WEB_HOSTNAME are configured)
  deploys to the VPS
- android: needs create-release only. The existing APK build, unchanged
  apart from reading `version` from create-release and resolving the
  release id by tag instead of creating it

`web` and `android` don't depend on each other and share the
`memory-heavy-jobs` concurrency group (queued, not parallel, on the
4 GB host) so they can finish in either order.

Web deploy mirrors slovo-propovedi-docs' scripts/vps-deploy.sh — self-
provisioning, buildx build on the VPS, no registry — since the playbook
(slovo-propovedi-playbook) already provisions Docker/buildx/Traefik/the
slovo user; this just adds one more Traefik-routed container
(`slovo-web`), the same way slovo-frontend/slovo-docs do:

- Dockerfile — nginx:alpine serving the exported dist/
- nginx.conf — SPA fallback (try_files -> index.html), sw.js
  no-cache, manifest.webmanifest MIME, /_expo/ hashed assets cached
  1y immutable (needs `location ^~ /_expo/` to beat the extension
  regex location — verified with a local docker build + curl)
- scripts/vps-deploy-web.sh — writes the systemd unit + Traefik label
  file, restarts slovo-web.service

Needs new Forgejo repo secrets/var to actually deploy:
VPS_SSH_PRIVATE_KEY,
VPS_HOST, VPS_SSH_USER, ACME_EMAIL (secrets), WEB_HOSTNAME (variable).
Without them the web job still builds and attaches the zip, just skips
the deploy step with a warning.

Verified: docker build + `nginx -t` + curl against a real `expo export`
output (SPA fallback, sw.js headers, manifest MIME, immutable caching).
release.yml itself is untested against a real Forgejo run (no runner
access here) — noted in docs/debt.md.

Signed-off-by: egoreast <egor.east@yandex.ru>
Sign in to join this conversation.
No reviewers
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Slovo_Propovedi/slovo-propovedi-mobile!76
No description provided.