diff --git a/apps/mobile/src/offline/db.ts b/apps/mobile/src/offline/db.ts index de6edd0..4bddd67 100644 --- a/apps/mobile/src/offline/db.ts +++ b/apps/mobile/src/offline/db.ts @@ -4,7 +4,7 @@ let db: Promise | null = null; /** * Локальная база офлайн-режима (docs/mobile.md#офлайн). `owner` — хэш «сервер + пользователь»: - * скачанное и неотправленные события одного аккаунта не видны другому. + * скачанное, неотправленные события и реакции одного аккаунта не видны другому. */ export function getDb(): Promise { db ??= (async () => { @@ -25,6 +25,12 @@ export function getDb(): Promise { owner TEXT NOT NULL, body TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS pending_reactions ( + owner TEXT NOT NULL, + track_id TEXT NOT NULL, + reaction TEXT, + PRIMARY KEY (owner, track_id) + ); `); return d; })(); diff --git a/apps/mobile/src/offline/downloads.ts b/apps/mobile/src/offline/downloads.ts index 6a9b308..b8bef65 100644 --- a/apps/mobile/src/offline/downloads.ts +++ b/apps/mobile/src/offline/downloads.ts @@ -192,6 +192,16 @@ class Downloads { this.pump(); } + /** Реакция в сохранённом DTO: офлайн-волна и списки скачанного видят её без сети. */ + async setReaction(trackId: string, reaction: Track['reaction']) { + const it = this.current.items.get(trackId); + if (!it || it.track.reaction === reaction) return; + const track = { ...it.track, reaction }; + this.setItem(trackId, { track }); + const db = await getDb(); + await db.runAsync('UPDATE downloads SET track = ? WHERE owner = ? AND track_id = ?', JSON.stringify(track), this.owner, trackId); + } + /** Повторить загрузки с ошибкой. */ async retry() { const db = await getDb(); diff --git a/apps/mobile/src/offline/events.ts b/apps/mobile/src/offline/events.ts index 06444d0..5136cdb 100644 --- a/apps/mobile/src/offline/events.ts +++ b/apps/mobile/src/offline/events.ts @@ -5,9 +5,12 @@ import { getDb } from './db'; const BATCH = 500; +type Reaction = 'like' | 'dislike' | null; + /** - * Отправка событий прослушивания с запасом на офлайн: что не ушло из-за сети — в SQLite, - * досылается при появлении сети (`POST /events` идемпотентен по id события). + * Отправка событий прослушивания и реакций с запасом на офлайн: что не ушло из-за сети — в SQLite, + * досылается при появлении сети (`POST /events` идемпотентен по id события, реакция — PUT/DELETE, + * для трека хранится только последняя). */ export class PersistentEvents { private readonly owner: string; @@ -37,12 +40,57 @@ export class PersistentEvents { } }; - /** Досылает сохранённые пачки; останавливается на первой сетевой ошибке. */ + private async putReaction(trackId: string, reaction: Reaction) { + const params = { params: { path: { id: trackId } } }; + if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } })); + else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params)); + } + + /** Реакция на трек. Без сети — откладывается и возвращает `queued`; ошибку сервера пробрасывает. */ + async react(trackId: string, reaction: Reaction): Promise<'sent' | 'queued'> { + const db = await getDb(); + try { + await this.putReaction(trackId, reaction); + } catch (e) { + if (!isNetwork(e)) throw e; + await db.runAsync( + 'INSERT OR REPLACE INTO pending_reactions (owner, track_id, reaction) VALUES (?, ?, ?)', + this.owner, + trackId, + reaction, + ); + return 'queued'; + } + // Более свежая реакция ушла — отложенная устарела. + await db.runAsync('DELETE FROM pending_reactions WHERE owner = ? AND track_id = ?', this.owner, trackId); + void this.drain(); + return 'sent'; + } + + /** Досылает отложенные реакции и пачки событий; останавливается на первой сетевой ошибке. */ async drain() { if (this.draining) return; this.draining = true; try { const db = await getDb(); + const reactions = await db.getAllAsync<{ track_id: string; reaction: Reaction }>( + 'SELECT track_id, reaction FROM pending_reactions WHERE owner = ?', + this.owner, + ); + for (const r of reactions) { + try { + await this.putReaction(r.track_id, r.reaction); + } catch (e) { + if (isNetwork(e)) return; + // трек удалён из библиотеки и т. п. — отбрасываем + } + await db.runAsync( + 'DELETE FROM pending_reactions WHERE owner = ? AND track_id = ? AND reaction IS ?', + this.owner, + r.track_id, + r.reaction, + ); + } for (;;) { const rows = await db.getAllAsync<{ id: number; body: string }>( 'SELECT id, body FROM pending_events WHERE owner = ? ORDER BY id LIMIT 20', diff --git a/apps/mobile/src/player/controller.ts b/apps/mobile/src/player/controller.ts index 3070427..d72cea6 100644 --- a/apps/mobile/src/player/controller.ts +++ b/apps/mobile/src/player/controller.ts @@ -246,21 +246,21 @@ class Controller { void TrackPlayer.seekTo(ms / 1000); } + /** Реакция на трек: сразу в UI; без сети запоминается и уходит на сервер при появлении связи. */ async react(track: Track, reaction: 'like' | 'dislike' | null) { - if (!this.session) return; + if (!this.persistent) return; const prev = track.reaction; const apply = (r: Track['reaction']) => { this.update({ queue: this.state.queue.map((t) => (t.id === track.id ? { ...t, reaction: r } : t)), track: this.state.track?.id === track.id ? { ...this.state.track, reaction: r } : this.state.track, }); + void downloads.setReaction(track.id, r); }; apply(reaction); this.events?.push({ type: reaction ?? (prev === 'like' ? 'unlike' : 'undislike'), track_id: track.id }); try { - const params = { params: { path: { id: track.id } } }; - if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } })); - else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params)); + await this.persistent.react(track.id, reaction); } catch (e) { apply(prev); throw e; diff --git a/docs/mobile.md b/docs/mobile.md index e278421..d8f6874 100644 --- a/docs/mobile.md +++ b/docs/mobile.md @@ -67,10 +67,11 @@ `documentDirectory/offline//`, обложка 300 px рядом; `owner` — хэш «сервер + пользователь». Перед загрузкой берётся свежий DTO (`POST /tracks/batch`), т.к. подписанная ссылка живёт 12 ч. - `events.ts` — sink для `EventBuffer`: при сетевой ошибке пачка уходит в `pending_events` и досылается при появлении сети. + Реакции так же: без сети — в `pending_reactions` (по треку хранится последняя), UI и DTO скачанного обновляются сразу; + ответ сервера 4xx откатывает реакцию в UI, при досылке — отбрасывает отложенную. - Запуск без сети: `AuthSession.restore()` берёт последнего пользователя из `RefreshStore.getUser` (состояние `signed_in` с `offline: true`); access-токен получается перед первым запросом, когда сеть вернётся. - Офлайн-волна: если `POST /wave/sessions` не удался — перемешанные скачанные треки без дизлайков, лайки ближе к началу. -- Пока не сделано: очередь мутаций реакций без сети (лайк офлайн откатывается). ## Самообновление