ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m30s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m57s
ci / ts / ts (packages + web) (push) Successful in 1m16s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 33s
- My Wave is rendered with react-native-skia on the UI thread; physics and colour helpers moved to @pipu/core (wave-anim) and shared with web. - Full-screen player honours ui_prefs.mobile_player (cover / wave). - Theme switching (dark / light / system) via a theme context and useColors() across all screens. - Profile: theme, wave variant, player view, sound quality sheet (Wi-Fi / mobile / downloads), recent-tracks cache clearing on next cold start, and a change-password sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import NetInfo, { type NetInfoState } from '@react-native-community/netinfo';
|
|
import { unwrap, type AuthSession, type Track } from '@pipu/core';
|
|
import * as FileSystem from 'expo-file-system';
|
|
import * as SecureStore from 'expo-secure-store';
|
|
|
|
import { qualityPrefs } from '../lib/prefs';
|
|
import { shortHash } from '../lib/storage';
|
|
import { getDb } from './db';
|
|
|
|
export type DownloadStatus = 'queued' | 'downloading' | 'done' | 'error';
|
|
|
|
export interface DownloadItem {
|
|
track: Track;
|
|
status: DownloadStatus;
|
|
progress: number;
|
|
bytes: number;
|
|
addedAt: number;
|
|
}
|
|
|
|
export interface DownloadsState {
|
|
items: ReadonlyMap<string, DownloadItem>;
|
|
wifiOnly: boolean;
|
|
autoLikes: boolean;
|
|
/** Сеть разрешает загрузку прямо сейчас (с учётом «только Wi-Fi»). */
|
|
allowed: boolean;
|
|
}
|
|
|
|
const PARALLEL = 2;
|
|
const WIFI_KEY = 'pipu.dl.wifi';
|
|
const AUTO_LIKES_KEY = 'pipu.dl.autolikes';
|
|
|
|
/**
|
|
* Загрузки для офлайна: файлы — в documentDirectory/offline/<owner>/, метаданные (DTO трека) — в SQLite.
|
|
* Не более двух загрузок параллельно; по умолчанию только по Wi-Fi; прерванные продолжаются при запуске.
|
|
*/
|
|
class Downloads {
|
|
private session: AuthSession | null = null;
|
|
private server = '';
|
|
private owner = '';
|
|
private active = new Map<string, FileSystem.DownloadResumable | null>();
|
|
private net: NetInfoState | null = null;
|
|
private unsubscribeNet: (() => void) | null = null;
|
|
private listeners = new Set<() => void>();
|
|
private current: DownloadsState = { items: new Map(), wifiOnly: true, autoLikes: false, allowed: false };
|
|
|
|
get state(): DownloadsState {
|
|
return this.current;
|
|
}
|
|
|
|
subscribe = (fn: () => void) => {
|
|
this.listeners.add(fn);
|
|
return () => this.listeners.delete(fn);
|
|
};
|
|
|
|
private set(patch: Partial<DownloadsState>) {
|
|
this.current = { ...this.current, ...patch };
|
|
for (const fn of this.listeners) fn();
|
|
}
|
|
|
|
private setItem(id: string, patch: Partial<DownloadItem> | null) {
|
|
const items = new Map(this.current.items);
|
|
if (patch === null) items.delete(id);
|
|
else {
|
|
const prev = items.get(id);
|
|
if (!prev && !patch.track) return;
|
|
items.set(id, { ...(prev as DownloadItem), ...patch });
|
|
}
|
|
this.set({ items });
|
|
}
|
|
|
|
private get dir() {
|
|
return `${FileSystem.documentDirectory}offline/${this.owner}/`;
|
|
}
|
|
|
|
private file(trackId: string) {
|
|
return `${this.dir}${trackId}.opus`;
|
|
}
|
|
|
|
private cover(albumId: string) {
|
|
return `${this.dir}covers/${albumId}.webp`;
|
|
}
|
|
|
|
/** Привязка к пользователю: загружает список скачанного и продолжает незавершённые загрузки. */
|
|
async attach(session: AuthSession | null, server: string, userId: string | null) {
|
|
const owner = session && userId ? shortHash(`${server}|${userId}`) : '';
|
|
if (owner === this.owner && session === this.session) return;
|
|
for (const d of this.active.values()) void d?.pauseAsync().catch(() => undefined);
|
|
this.active.clear();
|
|
this.session = session;
|
|
this.server = server;
|
|
this.owner = owner;
|
|
if (!owner) {
|
|
this.set({ items: new Map() });
|
|
return;
|
|
}
|
|
const [wifi, autoLikes] = await Promise.all([SecureStore.getItemAsync(WIFI_KEY), SecureStore.getItemAsync(AUTO_LIKES_KEY)]);
|
|
await FileSystem.makeDirectoryAsync(`${this.dir}covers/`, { intermediates: true }).catch(() => undefined);
|
|
const db = await getDb();
|
|
await db.runAsync(`UPDATE downloads SET status = 'queued' WHERE owner = ? AND status = 'downloading'`, owner);
|
|
const rows = await db.getAllAsync<{ track_id: string; track: string; status: DownloadStatus; bytes: number; added_at: number }>(
|
|
'SELECT track_id, track, status, bytes, added_at FROM downloads WHERE owner = ? ORDER BY added_at',
|
|
owner,
|
|
);
|
|
const items = new Map<string, DownloadItem>();
|
|
for (const r of rows) {
|
|
items.set(r.track_id, { track: JSON.parse(r.track) as Track, status: r.status, progress: r.status === 'done' ? 1 : 0, bytes: r.bytes, addedAt: r.added_at });
|
|
}
|
|
this.set({ items, wifiOnly: wifi !== '0', autoLikes: autoLikes === '1' });
|
|
|
|
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
|
this.net = st;
|
|
this.set({ allowed: this.networkAllowed() });
|
|
this.pump();
|
|
});
|
|
this.net = await NetInfo.fetch();
|
|
this.set({ allowed: this.networkAllowed() });
|
|
this.pump();
|
|
}
|
|
|
|
private networkAllowed(): boolean {
|
|
if (!this.net?.isConnected) return false;
|
|
return !this.current.wifiOnly || this.net.type === 'wifi' || this.net.type === 'ethernet';
|
|
}
|
|
|
|
async setWifiOnly(v: boolean) {
|
|
await SecureStore.setItemAsync(WIFI_KEY, v ? '1' : '0');
|
|
this.set({ wifiOnly: v });
|
|
this.set({ allowed: this.networkAllowed() });
|
|
this.pump();
|
|
}
|
|
|
|
async setAutoLikes(v: boolean) {
|
|
await SecureStore.setItemAsync(AUTO_LIKES_KEY, v ? '1' : '0');
|
|
this.set({ autoLikes: v });
|
|
}
|
|
|
|
isDownloaded(trackId: string): boolean {
|
|
return this.current.items.get(trackId)?.status === 'done';
|
|
}
|
|
|
|
/** Локальный файл трека, если он скачан. */
|
|
localTrack(trackId: string): string | undefined {
|
|
return this.isDownloaded(trackId) ? this.file(trackId) : undefined;
|
|
}
|
|
|
|
localCover(track: Track): string | undefined {
|
|
return this.isDownloaded(track.id) && track.album.cover_url ? this.cover(track.album.id) : undefined;
|
|
}
|
|
|
|
/** Обложка альбома на устройстве, если из него что-то скачано. */
|
|
localAlbumCover(albumId: string): string | undefined {
|
|
for (const it of this.current.items.values()) {
|
|
if (it.status === 'done' && it.track.album.id === albumId && it.track.album.cover_url) return this.cover(albumId);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Скачанные треки в порядке добавления. */
|
|
downloaded(): Track[] {
|
|
return [...this.current.items.values()].filter((i) => i.status === 'done').map((i) => i.track);
|
|
}
|
|
|
|
async enqueue(tracks: Track[]) {
|
|
if (!this.owner) return;
|
|
const db = await getDb();
|
|
const now = Date.now();
|
|
for (const [n, t] of tracks.entries()) {
|
|
if (!t.available || this.current.items.has(t.id)) continue;
|
|
const addedAt = now + n;
|
|
await db.runAsync(
|
|
`INSERT OR IGNORE INTO downloads (owner, track_id, track, status, added_at) VALUES (?, ?, ?, 'queued', ?)`,
|
|
this.owner,
|
|
t.id,
|
|
JSON.stringify(t),
|
|
addedAt,
|
|
);
|
|
this.setItem(t.id, { track: t, status: 'queued', progress: 0, bytes: 0, addedAt });
|
|
}
|
|
this.pump();
|
|
}
|
|
|
|
async remove(trackIds: string[]) {
|
|
const db = await getDb();
|
|
for (const id of trackIds) {
|
|
const d = this.active.get(id);
|
|
this.active.delete(id);
|
|
await d?.pauseAsync().catch(() => undefined);
|
|
await FileSystem.deleteAsync(this.file(id), { idempotent: true });
|
|
await FileSystem.deleteAsync(`${this.file(id)}.part`, { idempotent: true });
|
|
await db.runAsync('DELETE FROM downloads WHERE owner = ? AND track_id = ?', this.owner, id);
|
|
this.setItem(id, null);
|
|
}
|
|
this.pump();
|
|
}
|
|
|
|
/** Реакция в сохранённом DTO: офлайн-волна и списки скачанного видят её без сети. */
|
|
async setReaction(trackId: string, reaction: Track['reaction']) {
|
|
const it = this.current.items.get(trackId);
|
|
if (!it || it.track.reaction === reaction) return;
|
|
const track = { ...it.track, reaction };
|
|
this.setItem(trackId, { track });
|
|
const db = await getDb();
|
|
await db.runAsync('UPDATE downloads SET track = ? WHERE owner = ? AND track_id = ?', JSON.stringify(track), this.owner, trackId);
|
|
}
|
|
|
|
/** Повторить загрузки с ошибкой. */
|
|
async retry() {
|
|
const db = await getDb();
|
|
await db.runAsync(`UPDATE downloads SET status = 'queued' WHERE owner = ? AND status = 'error'`, this.owner);
|
|
for (const [id, it] of this.current.items) if (it.status === 'error') this.setItem(id, { status: 'queued', progress: 0 });
|
|
this.pump();
|
|
}
|
|
|
|
/** Занято на устройстве и свободно в телефоне (байты). */
|
|
async usage(): Promise<{ used: number; free: number }> {
|
|
let used = 0;
|
|
for (const it of this.current.items.values()) used += it.bytes;
|
|
const free = await FileSystem.getFreeDiskStorageAsync().catch(() => 0);
|
|
return { used, free };
|
|
}
|
|
|
|
private pump() {
|
|
if (!this.owner || !this.current.allowed) return;
|
|
for (const [id, it] of this.current.items) {
|
|
if (this.active.size >= PARALLEL) return;
|
|
if (it.status !== 'queued' || this.active.has(id)) continue;
|
|
this.active.set(id, null);
|
|
void this.run(id).finally(() => {
|
|
this.active.delete(id);
|
|
this.pump();
|
|
});
|
|
}
|
|
}
|
|
|
|
private async run(id: string) {
|
|
const session = this.session;
|
|
const owner = this.owner;
|
|
if (!session) return;
|
|
const db = await getDb();
|
|
const status = async (s: DownloadStatus, patch: Partial<DownloadItem> = {}, track?: Track) => {
|
|
if (this.owner !== owner) return;
|
|
await db.runAsync(
|
|
'UPDATE downloads SET status = ?, bytes = ?, track = COALESCE(?, track) WHERE owner = ? AND track_id = ?',
|
|
s,
|
|
patch.bytes ?? 0,
|
|
track ? JSON.stringify(track) : null,
|
|
owner,
|
|
id,
|
|
);
|
|
this.setItem(id, { status: s, ...patch, ...(track ? { track } : {}) });
|
|
};
|
|
try {
|
|
await status('downloading', { progress: 0 });
|
|
// Подписанные ссылки живут 12 ч — перед загрузкой берём свежий DTO.
|
|
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: [id] } }));
|
|
const track = res.items[0];
|
|
if (!track?.available || !track.stream_url) {
|
|
await status('error');
|
|
return;
|
|
}
|
|
const part = `${this.file(id)}.part`;
|
|
const q = (await qualityPrefs.load()).download;
|
|
const dl = FileSystem.createDownloadResumable(`${this.abs(track.stream_url)}&q=${q}`, part, {}, (p) => {
|
|
if (p.totalBytesExpectedToWrite > 0) this.setItem(id, { progress: p.totalBytesWritten / p.totalBytesExpectedToWrite });
|
|
});
|
|
this.active.set(id, dl);
|
|
const out = await dl.downloadAsync();
|
|
if (this.active.get(id) !== dl) return; // отменено (удалили или сменился пользователь)
|
|
if (!out || out.status !== 200) throw new Error(`status ${out?.status}`);
|
|
await FileSystem.moveAsync({ from: part, to: this.file(id) });
|
|
|
|
if (track.album.cover_url) {
|
|
const cover = this.cover(track.album.id);
|
|
const info = await FileSystem.getInfoAsync(cover);
|
|
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined);
|
|
}
|
|
const info = await FileSystem.getInfoAsync(this.file(id));
|
|
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
|
|
} catch {
|
|
await status('error', { progress: 0 });
|
|
}
|
|
}
|
|
|
|
private abs(path: string) {
|
|
return /^https?:/.test(path) ? path : this.server + path;
|
|
}
|
|
}
|
|
|
|
export const downloads = new Downloads();
|