Changing the activity or mood used to keep the current track playing and only replace the upcoming ones, so nothing seemed to happen. Now the first track of the new batch starts immediately (web and Android). The interrupted track is not reported to the wave as a skip and is recorded in history only if it played for at least 30 s, so a context change does not teach the recommender a false dislike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
import NetInfo from '@react-native-community/netinfo';
|
|
import {
|
|
EventBuffer,
|
|
PlayAttempt,
|
|
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';
|
|
|
|
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;
|
|
|
|
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
|
|
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
|
|
return tracks
|
|
.filter((t) => t.reaction !== 'dislike')
|
|
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
|
|
.sort((a, b) => a.key - b.key)
|
|
.map((x) => x.t);
|
|
}
|
|
|
|
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 }) : 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();
|
|
});
|
|
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 });
|
|
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 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();
|