Files
pipu-music/apps/mobile/src/player/controller.ts
T
mrleo1nid e158558da3
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
refactor: isolate playback and upload use cases
2026-10-22 13:52:16 +03:00

457 lines
18 KiB
TypeScript

import NetInfo from '@react-native-community/netinfo';
import {
EventBuffer,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveInfo,
type WaveOutcome,
type WaveFeedback,
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, sizedCover } from '../offline/downloads';
import { PersistentEvents } from '../offline/events';
import { offlineWave } from './offlineWave';
import { eventId } from './eventId';
export { offlineWave };
export type { WaveInfo } from '@pipu/core';
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;
}
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 readonly wavePolicy = new WaveCoordinator({
next: async (id: string, feedback: WaveFeedback[]) => unwrap(
await this.session!.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id } }, body: { feedback },
}),
),
});
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) => {
const cover = absolute(this.server, t.album.cover_url);
return {
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) ?? (cover ? sizedCover(cover, 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();
const version = this.wavePolicy.version;
let res;
try {
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
} catch (e) {
if (!this.wavePolicy.isCurrent(version)) return;
// Нет связи с сервером — офлайн-волна из скачанного.
const local = offlineWave(downloads.downloaded());
if (!local.length) throw e;
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
}
if (!this.wavePolicy.isCurrent(version)) return;
const tracks = res.tracks.filter(playable);
if (!tracks.length) return;
this.finishAttempt(this.lastPositionMs / 1000);
this.wavePolicy.activate(res.session_id);
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 version = this.wavePolicy.changeContext(wave.sessionId);
const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } },
body: { activity, mood },
}),
);
if (!this.wavePolicy.isCurrent(version)) return;
await this.replaceUpcoming(res.tracks, () => this.wavePolicy.isCurrent(version));
if (!this.wavePolicy.isCurrent(version)) return;
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;
this.wavePolicy.activate(null);
if (!wave) return;
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) {
this.wavePolicy.note(trackId, outcome);
}
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
private async replaceUpcoming(tracks: Track[], current: () => boolean = () => true) {
const keep = this.state.queue.slice(0, this.state.index + 1);
const add = tracks.filter(playable);
const native = await this.toRntp(add);
if (!current()) return;
await TrackPlayer.removeUpcomingTracks();
if (!current()) return;
await TrackPlayer.add(native);
if (current()) this.update({ queue: [...keep, ...add] });
}
private async refillWave(refresh = false) {
if (!this.session) return;
const remaining = this.state.queue.length - this.state.index - 1;
await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
if (refresh) await this.replaceUpcoming(res.tracks, current);
else {
const add = res.tracks.filter(playable);
const native = await this.toRntp(add);
if (!current()) return;
await TrackPlayer.add(native);
if (!current()) return;
this.update({ queue: [...this.state.queue, ...add] });
}
if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
});
}
// --- состояние ---
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.wavePolicy.activate(null);
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();