feat(desktop): add offline cache and verified app updates
ci / module boundaries / boundaries (push) Successful in 7s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 3m8s
ci / desktop / rust (desktop proxy) (push) Successful in 4m21s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m0s
release / desktop (Linux и Windows в релиз) (push) Successful in 11m49s
release / prune (старые файлы и прогоны) (push) Successful in 5s
ci / module boundaries / boundaries (push) Successful in 7s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 3m8s
ci / desktop / rust (desktop proxy) (push) Successful in 4m21s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m0s
release / desktop (Linux и Windows в релиз) (push) Successful in 11m49s
release / prune (старые файлы и прогоны) (push) Successful in 5s
Clean release notes in Android and desktop update dialogs. Persist downloaded audio and offline playback events per server and user; add desktop update checks, checksum verification and installer launch.
This commit is contained in:
1 parent
75ae2092ad
commit
ab49e22357
27 files changed
+785
-39
No files matched your search
@@ -18,6 +18,7 @@ import { MediaControls } from './media';
|
||||
import type { PlayerState } from './types';
|
||||
import { AudioEngine } from './engine';
|
||||
import { PlaybackPersistence } from './persistence';
|
||||
import { DesktopOffline, desktopEventSink, offlineOwner } from '../features/desktop/offline';
|
||||
|
||||
export type { PlayContext, Track };
|
||||
|
||||
@@ -34,6 +35,8 @@ const PRELOAD_BEFORE_MS = 20_000;
|
||||
* Media Session и сохранение «трек и момент» (локально и на сервере).
|
||||
*/
|
||||
export class Player {
|
||||
readonly offline: DesktopOffline | null;
|
||||
private retryEvents: (() => Promise<void>) | null = null;
|
||||
private readonly engine: AudioEngine;
|
||||
private get audio() { return this.engine.audio; }
|
||||
private get overlap() { return this.engine.overlap; }
|
||||
@@ -65,8 +68,16 @@ export class Player {
|
||||
audio?: HTMLAudioElement,
|
||||
) {
|
||||
this.engine = new AudioEngine(audio);
|
||||
const owner = offlineOwner(session);
|
||||
this.offline = owner ? new DesktopOffline(owner, session) : null;
|
||||
if (owner) {
|
||||
const send = desktopEventSink(session, owner);
|
||||
this.retryEvents = () => send([]);
|
||||
this.sendEvents = send;
|
||||
}
|
||||
this.events = new EventBuffer({
|
||||
send: async (events) => {
|
||||
if (this.sendEvents) return this.sendEvents(events);
|
||||
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||
},
|
||||
});
|
||||
@@ -82,9 +93,16 @@ export class Player {
|
||||
|
||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||
start() {
|
||||
this.offline?.activate();
|
||||
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
|
||||
if (this.retryEvents) {
|
||||
void this.retryEvents().catch(() => undefined);
|
||||
this.timers.push(setInterval(() => void this.retryEvents?.().catch(() => undefined), FLUSH_EVERY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
private sendEvents: ((events: import('@pipu/core').PlayEvent[]) => Promise<void>) | null = null;
|
||||
|
||||
// --- подписка (useSyncExternalStore) ---
|
||||
|
||||
subscribe = (fn: () => void) => {
|
||||
@@ -137,11 +155,20 @@ export class Player {
|
||||
this.finishAttempt('skip');
|
||||
this.leaveWave();
|
||||
const version = this.wavePolicy.version;
|
||||
const res = unwrap(
|
||||
await this.session.client.POST('/api/v1/wave/sessions', {
|
||||
let res;
|
||||
try {
|
||||
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', {
|
||||
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
|
||||
}),
|
||||
);
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!this.offline) throw error;
|
||||
await this.offline.ready;
|
||||
if (!this.wavePolicy.isCurrent(version)) return;
|
||||
const tracks = this.offline.tracks().filter((t) => t.reaction !== 'dislike');
|
||||
if (!tracks.length) throw error;
|
||||
this.playList(tracks.sort(() => Math.random() - 0.5), 0, { type: 'collection', id: 'offline' });
|
||||
return;
|
||||
}
|
||||
if (!this.wavePolicy.isCurrent(version)) return;
|
||||
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
|
||||
if (!tracks.length) {
|
||||
@@ -339,12 +366,20 @@ export class Player {
|
||||
private async load(autoplay: boolean, startMs = 0) {
|
||||
const track = this.queue.current;
|
||||
if (!track?.stream_url) return;
|
||||
const version = this.restoreVersion;
|
||||
if (this.offline) {
|
||||
await this.offline.ready.catch(() => undefined);
|
||||
if (this.queue.current !== track || version !== this.restoreVersion) return;
|
||||
}
|
||||
this.offline?.keep([track.id]);
|
||||
const source = this.offline?.local(track).stream_url ?? track.stream_url;
|
||||
this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||
// Keep the restored element's buffer when the user presses Play.
|
||||
if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
|
||||
if (this.audio.getAttribute('src') !== source) this.audio.src = source;
|
||||
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
|
||||
this.media.updateMediaMetadata(track);
|
||||
if (!autoplay) return;
|
||||
void this.offline?.save(track).catch(() => undefined);
|
||||
this.applyLoudness();
|
||||
this.attempt = new PlayAttempt(track, startMs);
|
||||
this.preloaded = null;
|
||||
@@ -425,7 +460,8 @@ export class Player {
|
||||
const next = this.state.queue[this.state.index + 1];
|
||||
if (!next?.stream_url || !next.available) return;
|
||||
this.fading = true;
|
||||
this.overlap.src = next.stream_url;
|
||||
this.offline?.keep([this.queue.current!.id, next.id]);
|
||||
this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url;
|
||||
this.overlap.volume = 0;
|
||||
void this.overlap.play().catch(() => {
|
||||
this.fading = false;
|
||||
@@ -458,7 +494,8 @@ export class Player {
|
||||
return;
|
||||
}
|
||||
this.syncQueue();
|
||||
const src = next.stream_url;
|
||||
const src = this.offline?.local(next).stream_url ?? next.stream_url;
|
||||
this.offline?.keep([next.id]);
|
||||
if (!src) {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
@@ -471,6 +508,7 @@ export class Player {
|
||||
this.attempt = new PlayAttempt(next, Math.round(at * 1000));
|
||||
this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
|
||||
this.media.updateMediaMetadata(next);
|
||||
void this.offline?.save(next).catch(() => undefined);
|
||||
void this.audio.play().finally(() => {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
@@ -560,7 +598,7 @@ export class Player {
|
||||
if (!cur || !next?.stream_url || this.preloaded === next.id) return;
|
||||
if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return;
|
||||
this.preloaded = next.id;
|
||||
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
|
||||
void fetch(this.offline?.local(next).stream_url ?? next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
|
||||
}
|
||||
|
||||
// --- сохранение состояния ---
|
||||
@@ -583,10 +621,18 @@ export class Player {
|
||||
const version = ++this.restoreVersion;
|
||||
const saved = await this.persistence.restore();
|
||||
if (version !== this.restoreVersion || !saved?.queue.length) return;
|
||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
||||
let items: Track[];
|
||||
try {
|
||||
items = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } })).items;
|
||||
} catch (error) {
|
||||
if (!this.offline) throw error;
|
||||
await this.offline.ready;
|
||||
const cached = new Map(this.offline.tracks().map((t) => [t.id, t]));
|
||||
items = saved.queue.flatMap((id) => cached.get(id) ?? []);
|
||||
}
|
||||
if (version !== this.restoreVersion) return;
|
||||
const wanted = saved.queue[saved.index];
|
||||
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
||||
const tracks = items.filter((t) => t.available && t.stream_url);
|
||||
if (!tracks.length) return;
|
||||
const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0);
|
||||
this.context = saved.context;
|
||||
@@ -608,6 +654,7 @@ export class Player {
|
||||
this.fading = false;
|
||||
this.finishAttempt('skip');
|
||||
this.engine.dispose();
|
||||
this.offline?.dispose();
|
||||
this.persistence.dispose();
|
||||
for (const t of this.timers) clearInterval(t);
|
||||
this.timers = [];
|
||||
|
||||
Reference in new issue
Block a user