455 lines
18 KiB
TypeScript
455 lines
18 KiB
TypeScript
import NetInfo from '@react-native-community/netinfo';
|
|
import {
|
|
EventBuffer,
|
|
PlayAttempt,
|
|
normalizeGain,
|
|
unwrap,
|
|
type AuthSession,
|
|
type PlayContext,
|
|
type Track,
|
|
} from '@pipu/core';
|
|
import TrackPlayer, {
|
|
AppKilledPlaybackBehavior,
|
|
Capability,
|
|
Event,
|
|
State,
|
|
type Track as RntpTrack,
|
|
} from 'react-native-track-player';
|
|
|
|
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
|
import { downloads } from '../offline/downloads';
|
|
import { PersistentEvents } from '../offline/events';
|
|
import { offlineWave } from './offlineWave';
|
|
import { eventId } from './eventId';
|
|
|
|
export { offlineWave };
|
|
|
|
export interface WaveInfo {
|
|
sessionId: string;
|
|
activity: string | null;
|
|
mood: string | null;
|
|
fallback: boolean;
|
|
}
|
|
|
|
export interface PlayerState {
|
|
ready: boolean;
|
|
track: Track | null;
|
|
queue: Track[];
|
|
index: number;
|
|
playing: boolean;
|
|
buffering: boolean;
|
|
positionMs: number;
|
|
durationMs: number;
|
|
context: PlayContext | null;
|
|
wave: WaveInfo | null;
|
|
}
|
|
|
|
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
|
|
|
|
const WAVE_REFILL_AT = 2;
|
|
const EARLY_SKIP_MS = 30_000;
|
|
const SAVE_EVERY_MS = 10_000;
|
|
|
|
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
|
|
|
|
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */
|
|
export function absolute(server: string, path: string | null | undefined): string | undefined {
|
|
if (!path) return undefined;
|
|
return /^https?:/.test(path) ? path : server + path;
|
|
}
|
|
|
|
/** Качество по сети (docs/playback.md#качество): Wi-Fi — high, сотовая — medium. */
|
|
/** Качество стрима по типу сети — из настроек профиля. */
|
|
async function quality(): Promise<Quality> {
|
|
const prefs = await qualityPrefs.load();
|
|
try {
|
|
const net = await NetInfo.fetch();
|
|
return net.type === 'cellular' ? prefs.cellular : prefs.wifi;
|
|
} catch {
|
|
return prefs.wifi;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Плеер Android. Очередь живёт в ExoPlayer (react-native-track-player) — играет и с выгруженным UI;
|
|
* здесь — зеркало очереди с DTO треков, учёт прослушиваний (PlayAttempt из @pipu/core), события,
|
|
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
|
|
*/
|
|
class Controller {
|
|
private session: AuthSession | null = null;
|
|
private server = '';
|
|
private events: EventBuffer | null = null;
|
|
private persistent: PersistentEvents | null = null;
|
|
private unsubscribeNet: (() => void) | null = null;
|
|
private attempt: PlayAttempt | null = null;
|
|
private lastPositionMs = 0;
|
|
private lastSave = 0;
|
|
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
|
private waveLoading = false;
|
|
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
|
|
private setupDone: Promise<void> | null = null;
|
|
private listeners = new Set<() => void>();
|
|
private state: PlayerState = {
|
|
ready: false, track: null, queue: [], index: 0, playing: false, buffering: false, positionMs: 0, durationMs: 0,
|
|
context: null, wave: null,
|
|
};
|
|
|
|
subscribe = (fn: () => void) => {
|
|
this.listeners.add(fn);
|
|
return () => this.listeners.delete(fn);
|
|
};
|
|
|
|
getState = () => this.state;
|
|
|
|
private update(patch: Partial<PlayerState>) {
|
|
this.state = { ...this.state, ...patch };
|
|
for (const fn of this.listeners) fn();
|
|
}
|
|
|
|
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
|
|
attach(session: AuthSession | null, server: string, userId: string | null) {
|
|
if (this.session === session) return;
|
|
void this.events?.flush();
|
|
this.session = session;
|
|
this.server = server;
|
|
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
|
const sink = this.persistent;
|
|
this.events = sink ? new EventBuffer({ send: sink.send }, Date.now, undefined, eventId) : null;
|
|
void sink?.drain();
|
|
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
|
if (st.isConnected) void this.persistent?.drain();
|
|
});
|
|
if (!session) void this.reset();
|
|
}
|
|
|
|
setup(): Promise<void> {
|
|
this.setupDone ??= (async () => {
|
|
// Очистка кэша из профиля — только пока сервис плеера не запущен (иначе SimpleCache держит файлы).
|
|
if (!(await TrackPlayer.isServiceRunning().catch(() => true))) await playerCache.clearIfRequested();
|
|
try {
|
|
// 500 МБ кэша ExoPlayer для недавних стримов (значение — в КБ).
|
|
await TrackPlayer.setupPlayer({ maxCacheSize: 500 * 1024, autoHandleInterruptions: true });
|
|
} catch {
|
|
// уже настроен (повторный запуск JS при живом сервисе)
|
|
}
|
|
await TrackPlayer.updateOptions({
|
|
capabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext, Capability.SkipToPrevious, Capability.SeekTo],
|
|
compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext],
|
|
progressUpdateEventInterval: 1,
|
|
android: { appKilledPlaybackBehavior: AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification },
|
|
});
|
|
this.bind();
|
|
this.update({ ready: true });
|
|
})();
|
|
return this.setupDone;
|
|
}
|
|
|
|
private bind() {
|
|
TrackPlayer.addEventListener(Event.PlaybackState, ({ state }) => {
|
|
this.update({ playing: state === State.Playing, buffering: state === State.Buffering || state === State.Loading });
|
|
if (state === State.Paused) this.save();
|
|
});
|
|
TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, ({ position, duration }) => {
|
|
const ms = Math.round(position * 1000);
|
|
this.attempt?.tick(ms);
|
|
this.lastPositionMs = ms;
|
|
this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs });
|
|
if (this.state.playing && Date.now() - this.lastSave > SAVE_EVERY_MS) this.save();
|
|
const cf = Math.min(6, Math.max(0, this.userPrefs()?.crossfade_s ?? 0));
|
|
const left = duration - position;
|
|
const fade = cf > 0 && left < cf ? Math.max(0, left / cf) : 1;
|
|
void this.applyVolume(this.state.track, fade);
|
|
});
|
|
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => {
|
|
this.finishAttempt(lastPosition);
|
|
if (index === undefined || index === null) return;
|
|
const track = this.state.queue[index];
|
|
if (!track) return;
|
|
this.attempt = new PlayAttempt(track, 0);
|
|
this.update({ track, index, positionMs: 0, durationMs: track.duration_ms });
|
|
void this.applyVolume(track, 1);
|
|
this.events?.push({
|
|
type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type,
|
|
context_id: this.state.context?.id,
|
|
});
|
|
this.save();
|
|
void this.refillWave();
|
|
});
|
|
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => {
|
|
this.finishAttempt(this.lastPositionMs / 1000, true);
|
|
void this.events?.flush();
|
|
});
|
|
TrackPlayer.addEventListener(Event.PlaybackError, () => {
|
|
// Ссылка могла устареть или трек пропал — переходим дальше, ExoPlayer не застрянет на ошибке.
|
|
void TrackPlayer.skipToNext().catch(() => undefined);
|
|
});
|
|
}
|
|
|
|
/** Завершение попытки: доиграно (позиция у конца) или пропущено. */
|
|
private finishAttempt(lastPositionS: number, ended = false) {
|
|
const a = this.attempt;
|
|
if (!a) return;
|
|
this.attempt = null;
|
|
const pos = Math.round(lastPositionS * 1000);
|
|
if (this.neutralSwitch) {
|
|
this.neutralSwitch = false;
|
|
if (a.playedMs >= EARLY_SKIP_MS) {
|
|
this.events?.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
|
void this.events?.flush();
|
|
}
|
|
return;
|
|
}
|
|
const kind = ended ? 'play_end' : a.outcome(pos);
|
|
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
|
void this.events?.flush();
|
|
if (kind === 'play_end') this.noteWave(a.track.id, 'complete');
|
|
else {
|
|
const early = a.playedMs < EARLY_SKIP_MS;
|
|
this.noteWave(a.track.id, early ? 'skip_early' : 'skip');
|
|
if (early) void this.refillWave(true);
|
|
}
|
|
}
|
|
|
|
private userPrefs() {
|
|
const st = this.session?.state;
|
|
return st?.status === 'signed_in' ? st.user.ui_prefs : undefined;
|
|
}
|
|
|
|
/** Громкость 0..1: нормализация вниз к −14 LUFS и затухание кроссфейда в конце трека. */
|
|
private async applyVolume(track: Track | null, fade: number) {
|
|
const gain = Math.min(1, normalizeGain(track?.features?.loudness_lufs, this.userPrefs()?.normalize !== false) * fade);
|
|
await TrackPlayer.setVolume(gain).catch(() => undefined);
|
|
}
|
|
|
|
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
|
|
const q = await quality();
|
|
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
|
return tracks.map((t) => ({
|
|
id: t.id,
|
|
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
|
title: t.title,
|
|
artist: t.artists.map((a) => a.name).join(', '),
|
|
album: t.album.title,
|
|
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
|
|
duration: t.duration_ms / 1000,
|
|
}));
|
|
}
|
|
|
|
async playList(tracks: Track[], index: number, context: PlayContext) {
|
|
const list = tracks.filter(playable);
|
|
const target = tracks[index];
|
|
if (!target || !list.length) return;
|
|
await this.setup();
|
|
this.leaveWave();
|
|
this.finishAttempt(this.lastPositionMs / 1000);
|
|
const start = Math.max(list.indexOf(target), 0);
|
|
this.update({ queue: list, context, index: start, track: list[start] ?? null });
|
|
await TrackPlayer.reset();
|
|
await TrackPlayer.add(await this.toRntp(list));
|
|
await TrackPlayer.skip(start);
|
|
await TrackPlayer.play();
|
|
}
|
|
|
|
toggle() {
|
|
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
|
|
}
|
|
|
|
next() {
|
|
void TrackPlayer.skipToNext().catch(() => undefined);
|
|
}
|
|
|
|
async prev() {
|
|
const { position } = await TrackPlayer.getProgress();
|
|
if (position > 3) await TrackPlayer.seekTo(0);
|
|
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
|
|
}
|
|
|
|
seek(ms: number) {
|
|
const t = this.state.track;
|
|
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
|
|
this.attempt?.seek(ms);
|
|
this.lastPositionMs = ms;
|
|
void TrackPlayer.seekTo(ms / 1000);
|
|
}
|
|
|
|
/** Реакция на трек: сразу в UI; без сети запоминается и уходит на сервер при появлении связи. */
|
|
async react(track: Track, reaction: 'like' | 'dislike' | null) {
|
|
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 {
|
|
await this.persistent.react(track.id, reaction);
|
|
} catch (e) {
|
|
apply(prev);
|
|
throw e;
|
|
}
|
|
if (reaction === 'like' && downloads.state.autoLikes) void downloads.enqueue([{ ...track, reaction }]);
|
|
if (reaction) {
|
|
this.noteWave(track.id, reaction);
|
|
void this.refillWave(true);
|
|
}
|
|
if (reaction === 'dislike' && this.state.track?.id === track.id) this.next();
|
|
}
|
|
|
|
// --- «Моя волна» ---
|
|
|
|
async startWave(activity: string | null, mood: string | null) {
|
|
if (!this.session) return;
|
|
await this.setup();
|
|
this.leaveWave();
|
|
let res;
|
|
try {
|
|
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
|
|
} catch (e) {
|
|
// Нет связи с сервером — офлайн-волна из скачанного.
|
|
const local = offlineWave(downloads.downloaded());
|
|
if (!local.length) throw e;
|
|
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
|
|
}
|
|
const tracks = res.tracks.filter(playable);
|
|
if (!tracks.length) return;
|
|
this.finishAttempt(this.lastPositionMs / 1000);
|
|
this.update({
|
|
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
|
|
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
|
|
});
|
|
await TrackPlayer.reset();
|
|
await TrackPlayer.add(await this.toRntp(tracks));
|
|
await TrackPlayer.play();
|
|
}
|
|
|
|
async setWaveContext(activity: string | null, mood: string | null) {
|
|
const wave = this.state.wave;
|
|
if (!wave || !this.session) return this.startWave(activity, mood);
|
|
const res = unwrap(
|
|
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
|
params: { path: { id: wave.sessionId } },
|
|
body: { activity, mood },
|
|
}),
|
|
);
|
|
await this.replaceUpcoming(res.tracks);
|
|
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
|
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
|
if (res.tracks.some(playable)) {
|
|
this.neutralSwitch = true;
|
|
await TrackPlayer.skipToNext().catch(() => {
|
|
this.neutralSwitch = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
private leaveWave() {
|
|
const wave = this.state.wave;
|
|
if (!wave) return;
|
|
this.waveFeedback = [];
|
|
this.update({ wave: null });
|
|
void this.session?.client
|
|
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
private noteWave(trackId: string, outcome: WaveOutcome) {
|
|
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
|
|
}
|
|
|
|
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
|
|
private async replaceUpcoming(tracks: Track[]) {
|
|
const keep = this.state.queue.slice(0, this.state.index + 1);
|
|
await TrackPlayer.removeUpcomingTracks();
|
|
const add = tracks.filter(playable);
|
|
await TrackPlayer.add(await this.toRntp(add));
|
|
this.update({ queue: [...keep, ...add] });
|
|
}
|
|
|
|
private async refillWave(refresh = false) {
|
|
const wave = this.state.wave;
|
|
if (!wave || !this.session || this.waveLoading) return;
|
|
const remaining = this.state.queue.length - this.state.index - 1;
|
|
if (!refresh && remaining > WAVE_REFILL_AT) return;
|
|
this.waveLoading = true;
|
|
const feedback = this.waveFeedback;
|
|
this.waveFeedback = [];
|
|
try {
|
|
const res = unwrap(
|
|
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
|
|
params: { path: { id: wave.sessionId } },
|
|
body: { feedback },
|
|
}),
|
|
);
|
|
if (this.state.wave?.sessionId !== wave.sessionId) return;
|
|
if (refresh) await this.replaceUpcoming(res.tracks);
|
|
else {
|
|
const add = res.tracks.filter(playable);
|
|
await TrackPlayer.add(await this.toRntp(add));
|
|
this.update({ queue: [...this.state.queue, ...add] });
|
|
}
|
|
this.update({ wave: { ...wave, fallback: res.fallback } });
|
|
} catch {
|
|
this.waveFeedback = [...feedback, ...this.waveFeedback];
|
|
} finally {
|
|
this.waveLoading = false;
|
|
}
|
|
}
|
|
|
|
// --- состояние ---
|
|
|
|
private save() {
|
|
const t = this.state.track;
|
|
if (!t || !this.session) return;
|
|
this.lastSave = Date.now();
|
|
void this.session.client
|
|
.PUT('/api/v1/me/playback-state', {
|
|
body: {
|
|
track_id: t.id,
|
|
position_ms: this.lastPositionMs,
|
|
queue: this.state.queue.slice(0, 500).map((x) => x.id),
|
|
queue_index: Math.min(this.state.index, 499),
|
|
context: this.state.context ? { type: this.state.context.type, id: this.state.context.id } : undefined,
|
|
device_id: 'android',
|
|
},
|
|
})
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
|
|
async restore() {
|
|
if (!this.session || this.state.track) return;
|
|
await this.setup();
|
|
try {
|
|
const st = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
|
|
if (!st.track_id || !st.queue.length) return;
|
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
|
|
const tracks = res.items.filter(playable);
|
|
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
|
|
if (!tracks.length) return;
|
|
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
|
|
await TrackPlayer.reset();
|
|
await TrackPlayer.add(await this.toRntp(tracks));
|
|
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
|
|
} catch {
|
|
// офлайн или нет сохранённого состояния
|
|
}
|
|
}
|
|
|
|
async reset() {
|
|
this.attempt = null;
|
|
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
|
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
|
|
}
|
|
|
|
flush() {
|
|
this.save();
|
|
void this.events?.flush();
|
|
}
|
|
}
|
|
|
|
export const player = new Controller();
|