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 { 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 | 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) { 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 { 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 { 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();