ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
Search hits omit album covers and artist images. The worker now reads the full song and artist, stores the description, and leaves Genius image URLs unchanged on mobile. Co-authored-by: Cursor <cursoragent@cursor.com>
458 lines
18 KiB
TypeScript
458 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, sizedCover } 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) => {
|
|
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();
|
|
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();
|