ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
128 lines
3.8 KiB
TypeScript
128 lines
3.8 KiB
TypeScript
import { unwrap, type AuthSession, type PlayContext, type RepeatMode } from '@pipu/core';
|
|
import type { PlayerState } from './types';
|
|
|
|
const LOCAL_KEY = 'pipu.player';
|
|
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
|
|
const MAX_SAVED_QUEUE = 500;
|
|
|
|
export interface Saved {
|
|
queue: string[];
|
|
index: number;
|
|
positionMs: number;
|
|
context: PlayContext | null;
|
|
volume: number;
|
|
shuffle: boolean;
|
|
repeat: RepeatMode;
|
|
savedAt: string;
|
|
}
|
|
|
|
function deviceId(): string {
|
|
try {
|
|
let id = localStorage.getItem('pipu.device');
|
|
if (!id) {
|
|
id = crypto.randomUUID();
|
|
localStorage.setItem('pipu.device', id);
|
|
}
|
|
return id;
|
|
} catch {
|
|
return 'web';
|
|
}
|
|
}
|
|
|
|
export class PlaybackPersistence {
|
|
private readonly device = deviceId();
|
|
lastServerSave = 0;
|
|
constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {}
|
|
snapshot(state: PlayerState): Saved {
|
|
const ids = state.queue.map((t) => t.id);
|
|
const start = Math.max(0, Math.min(state.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
|
|
return {
|
|
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
|
|
index: state.index - start,
|
|
positionMs: state.positionMs,
|
|
context: state.context,
|
|
volume: state.volume,
|
|
shuffle: state.shuffle,
|
|
repeat: state.repeat,
|
|
savedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
saveLocal() {
|
|
if (!this.getState().track) return;
|
|
try {
|
|
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot(this.getState())));
|
|
} catch {
|
|
// недоступное хранилище не мешает воспроизведению
|
|
}
|
|
}
|
|
|
|
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
saveSoon() {
|
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
|
|
}
|
|
|
|
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
|
|
saveServer(keepalive = false) {
|
|
const cur = this.getState().track;
|
|
if (!cur) return;
|
|
this.lastServerSave = Date.now();
|
|
this.saveLocal();
|
|
const s = this.snapshot(this.getState());
|
|
void this.session.client
|
|
.PUT('/api/v1/me/playback-state', {
|
|
body: {
|
|
track_id: cur.id,
|
|
position_ms: s.positionMs,
|
|
queue: s.queue,
|
|
queue_index: s.index,
|
|
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
|
|
shuffle: s.shuffle,
|
|
repeat_mode: s.repeat,
|
|
volume: s.volume,
|
|
device_id: this.device,
|
|
},
|
|
keepalive,
|
|
})
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
async restore(): Promise<Saved | null> {
|
|
let local: Saved | null = null;
|
|
try {
|
|
const raw = localStorage.getItem(LOCAL_KEY);
|
|
local = raw ? (JSON.parse(raw) as Saved) : null;
|
|
} catch {
|
|
local = null;
|
|
}
|
|
let saved = local;
|
|
try {
|
|
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
|
|
if (
|
|
server.track_id &&
|
|
server.updated_at &&
|
|
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
|
|
) {
|
|
saved = {
|
|
queue: server.queue,
|
|
index: server.queue_index,
|
|
positionMs: server.position_ms,
|
|
context: (server.context?.type ? server.context : null) as PlayContext | null,
|
|
volume: server.volume,
|
|
shuffle: server.shuffle,
|
|
repeat: server.repeat_mode,
|
|
savedAt: server.updated_at,
|
|
};
|
|
}
|
|
} catch {
|
|
// офлайн — остаёмся с локальной копией
|
|
}
|
|
return saved;
|
|
}
|
|
dispose() {
|
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
this.saveTimer = null;
|
|
}
|
|
}
|