feat(mobile): sign-in, tabs, search, collection and background player (M5)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m39s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 1m5s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 39s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m39s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 1m5s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 39s
- expo-router app in src/app: connect (server check, recent servers), login, mandatory credential / password change, tabs Home / Search / Collection / Profile, album and artist pages, full-screen player - AuthSession per server with the refresh token in expo-secure-store (Android Keystore); language from the profile - player on react-native-track-player: ExoPlayer queue with a notification and lock-screen controls via a playback service, 500 MB stream cache, quality by network (Wi-Fi high, cellular medium), play events with played time, reactions, playback state restore and the endless My Wave with feedback - home: pulsing wave circle driven by BPM / energy in the cover palette, activity and mood chips that switch the wave on the fly - New Architecture is off until react-native-track-player supports it; the release build runs codegen only when it is on; query-string is a direct dependency because expo-router 5.1 imports it undeclared Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eaedd1fc62
commit
9fd616da57
28 files changed
+2590
-197
No files matched your search
@@ -0,0 +1,395 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
|
||||
/** Ссылки 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<'high' | 'medium'> {
|
||||
try {
|
||||
const net = await NetInfo.fetch();
|
||||
return net.type === 'cellular' ? 'medium' : 'high';
|
||||
} catch {
|
||||
return 'high';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Плеер 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 attempt: PlayAttempt | null = null;
|
||||
private lastPositionMs = 0;
|
||||
private lastSave = 0;
|
||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||
private waveLoading = 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();
|
||||
}
|
||||
|
||||
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер. */
|
||||
attach(session: AuthSession | null, server: string) {
|
||||
if (this.session === session) return;
|
||||
void this.events?.flush();
|
||||
this.session = session;
|
||||
this.server = server;
|
||||
this.events = session
|
||||
? new EventBuffer({
|
||||
send: async (events) => {
|
||||
unwrap(await session.client.POST('/api/v1/events', { body: { events } }));
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (!session) void this.reset();
|
||||
}
|
||||
|
||||
setup(): Promise<void> {
|
||||
this.setupDone ??= (async () => {
|
||||
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);
|
||||
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: `${absolute(this.server, t.stream_url)}&q=${q}`,
|
||||
title: t.title,
|
||||
artist: t.artists.map((a) => a.name).join(', '),
|
||||
album: t.album.title,
|
||||
artwork: 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 playable = tracks.filter((t) => t.available && t.stream_url);
|
||||
const target = tracks[index];
|
||||
if (!target || !playable.length) return;
|
||||
await this.setup();
|
||||
this.leaveWave();
|
||||
this.finishAttempt(this.lastPositionMs / 1000);
|
||||
const start = Math.max(playable.indexOf(target), 0);
|
||||
this.update({ queue: playable, context, index: start, track: playable[start] ?? null });
|
||||
await TrackPlayer.reset();
|
||||
await TrackPlayer.add(await this.toRntp(playable));
|
||||
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);
|
||||
}
|
||||
|
||||
async react(track: Track, reaction: 'like' | 'dislike' | null) {
|
||||
if (!this.session) 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,
|
||||
});
|
||||
};
|
||||
apply(reaction);
|
||||
this.events?.push({ type: reaction ?? (prev === 'like' ? 'unlike' : 'undislike'), track_id: track.id });
|
||||
try {
|
||||
const params = { params: { path: { id: track.id } } };
|
||||
if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } }));
|
||||
else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params));
|
||||
} catch (e) {
|
||||
apply(prev);
|
||||
throw e;
|
||||
}
|
||||
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 res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
|
||||
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
|
||||
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 } });
|
||||
}
|
||||
|
||||
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((t) => t.available && t.stream_url);
|
||||
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((t) => t.available && t.stream_url);
|
||||
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((t) => t.available && t.stream_url);
|
||||
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();
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import { player, type PlayerState } from './controller';
|
||||
|
||||
export function usePlayerState(): PlayerState {
|
||||
return useSyncExternalStore(player.subscribe, player.getState);
|
||||
}
|
||||
|
||||
export { player };
|
||||
@@ -0,0 +1,21 @@
|
||||
import TrackPlayer, { Event } from 'react-native-track-player';
|
||||
|
||||
import { player } from './controller';
|
||||
|
||||
/**
|
||||
* Фоновый сервис: кнопки уведомления, экрана блокировки и гарнитуры. Работает и без UI —
|
||||
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
|
||||
*/
|
||||
export async function PlaybackService(): Promise<void> {
|
||||
TrackPlayer.addEventListener(Event.RemotePlay, () => void TrackPlayer.play());
|
||||
TrackPlayer.addEventListener(Event.RemotePause, () => void TrackPlayer.pause());
|
||||
TrackPlayer.addEventListener(Event.RemoteStop, () => void TrackPlayer.stop());
|
||||
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
|
||||
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
|
||||
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
|
||||
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
|
||||
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
|
||||
if (permanent) void TrackPlayer.pause();
|
||||
else if (paused) void TrackPlayer.pause();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user