{t('uploads.set_note')}
{t('uploads.resume_hint', { count: resume })}
+{t('uploads.skipped', { count: st.skipped })}
} {st.items.length > 0 && ( @@ -215,7 +226,7 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean )} {note && {note.text}} - {formatBytes(item.file.size, i18n.language)} + {formatBytes(item.size, i18n.language)} {locked ? ( ) : ( diff --git a/apps/web/src/player/player.ts b/apps/web/src/player/player.ts index 5594232..097faf7 100644 --- a/apps/web/src/player/player.ts +++ b/apps/web/src/player/player.ts @@ -1,5 +1,6 @@ import { EventBuffer, + normalizeGain, PlayAttempt, PlayQueue, unwrap, @@ -80,6 +81,9 @@ function deviceId(): string { */ export class Player { private readonly audio: HTMLAudioElement; + private readonly overlap: HTMLAudioElement; + private fading = false; + private ignoreEnded = false; private readonly queue = new PlayQueue(); private readonly events: EventBuffer; private attempt: PlayAttempt | null = null; @@ -102,6 +106,8 @@ export class Player { ) { this.audio = audio ?? new Audio(); this.audio.preload = 'auto'; + this.overlap = new Audio(); + this.overlap.preload = 'auto'; this.events = new EventBuffer({ send: async (events) => { unwrap(await this.session.client.POST('/api/v1/events', { body: { events } })); @@ -324,15 +330,17 @@ export class Player { } setVolume(v: number) { - this.audio.volume = Math.min(Math.max(v, 0), 1); + const volume = Math.min(Math.max(v, 0), 1); this.audio.muted = false; - this.update({ volume: this.audio.volume, muted: false }); + this.update({ volume, muted: false }); + this.applyLoudness(); this.saveLocal(); } toggleMute() { this.audio.muted = !this.audio.muted; this.update({ muted: this.audio.muted }); + this.applyLoudness(); } toggleShuffle() { @@ -382,6 +390,7 @@ export class Player { this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms }); this.audio.src = track.stream_url; if (startMs > 0) this.audio.currentTime = startMs / 1000; + this.applyLoudness(); this.attempt = new PlayAttempt(track, startMs); this.preloaded = null; this.events.push({ @@ -429,6 +438,107 @@ export class Player { } } + private prefs() { + const user = this.session.state.status === 'signed_in' ? this.session.state.user : null; + return user?.ui_prefs; + } + + private normalizeOn() { + return this.prefs()?.normalize !== false; + } + + private crossfadeSeconds() { + return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0)); + } + + /** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */ + private gain: GainNode | null = null; + private wired = false; + + private applyLoudness(mix = 1) { + const track = this.state.track; + const user = this.state.muted ? 0 : this.state.volume; + const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix; + if (!this.wired && typeof AudioContext !== 'undefined') { + try { + const ctx = new AudioContext(); + const src = ctx.createMediaElementSource(this.audio); + this.gain = ctx.createGain(); + src.connect(this.gain); + this.gain.connect(ctx.destination); + this.audio.volume = 1; + this.wired = true; + } catch { + this.wired = true; + } + } + if (this.gain) this.gain.gain.value = level; + else this.audio.volume = Math.min(1, level); + } + + /** В конце трека накладывает следующий на crossfade_s секунд. */ + private maybeCrossfade(positionMs: number) { + const cf = this.crossfadeSeconds(); + if (this.fading || cf <= 0 || this.state.repeat === 'one') return; + const dur = this.audio.duration; + if (!Number.isFinite(dur)) return; + const left = dur * 1000 - positionMs; + if (left <= 0 || left > cf * 1000) return; + const next = this.state.queue[this.state.index + 1]; + if (!next?.stream_url || !next.available) return; + this.fading = true; + this.overlap.src = next.stream_url; + this.overlap.volume = 0; + void this.overlap.play().catch(() => { + this.fading = false; + }); + const started = performance.now(); + const step = () => { + if (!this.fading) return; + const p = Math.min(1, (performance.now() - started) / (cf * 1000)); + this.applyLoudness(1 - p); + this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p); + if (p < 1) { + requestAnimationFrame(step); + return; + } + this.finishCrossfade(next); + }; + requestAnimationFrame(step); + } + + private finishCrossfade(next: Track) { + const at = this.overlap.currentTime; + this.ignoreEnded = true; + this.audio.pause(); + this.overlap.pause(); + this.finishAttempt('play_end'); + if (!this.queue.next(true)) { + this.fading = false; + this.ignoreEnded = false; + this.update({ playing: false, positionMs: 0 }); + return; + } + this.syncQueue(); + const src = next.stream_url; + if (!src) { + this.fading = false; + this.ignoreEnded = false; + this.update({ playing: false }); + return; + } + this.audio.src = src; + if (at > 0) this.audio.currentTime = at; + this.applyLoudness(); + this.attempt = new PlayAttempt(next, Math.round(at * 1000)); + this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms }); + this.updateMediaMetadata(next); + void this.audio.play().finally(() => { + this.fading = false; + this.ignoreEnded = false; + }); + } + private bindAudio() { const a = this.audio; a.addEventListener('playing', () => { @@ -451,10 +561,12 @@ export class Player { this.update({ positionMs: ms }); this.updatePositionState(); this.maybePreload(ms); + this.maybeCrossfade(ms); if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer(); this.saveLocal(); }); a.addEventListener('ended', () => { + if (this.ignoreEnded || this.fading) return; this.finishAttempt('play_end'); if (this.queue.next(true)) { this.syncQueue(); @@ -465,7 +577,6 @@ export class Player { } }); a.addEventListener('error', () => void this.onError()); - a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted })); } /** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */ diff --git a/apps/web/src/test/e2eBridge.ts b/apps/web/src/test/e2eBridge.ts new file mode 100644 index 0000000..f97f7a8 --- /dev/null +++ b/apps/web/src/test/e2eBridge.ts @@ -0,0 +1,15 @@ +import { fakeApi, makeUser } from './fakeApi'; + +/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */ +export function installE2EFetch() { + const api = fakeApi([ + makeUser('admin', 'admin', { must_change_credentials: true }), + makeUser('alice', 'user'), + ]); + const orig = window.fetch.bind(window); + window.fetch = async (input, init) => { + const req = new Request(input, init); + if (new URL(req.url, window.location.origin).pathname.startsWith('/api/')) return api.fetch(req); + return orig(input, init); + }; +} diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index 708b857..b9339e7 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -153,6 +153,16 @@ export function fakeApi(initial: FakeUser[]) { return tokens(user); case 'GET /api/v1/me/sessions': return json({ items: [] }); + case 'GET /api/v1/uploads/unseen': + return json({ count: 0 }); + case 'POST /api/v1/uploads/seen': + return new Response(null, { status: 204 }); + case 'GET /api/v1/onboarding': + return json({ required: false, artists: [], genres: [] }); + case 'GET /api/v1/me/likes': + case 'GET /api/v1/me/dislikes': + case 'GET /api/v1/me/history': + return json({ items: [], next_cursor: null, total: 0 }); case 'GET /api/v1/admin/users': { if (user.role === 'user') return problem(403, 'auth.forbidden'); const q = (url.searchParams.get('q') ?? '').toLowerCase(); diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx index 16bb11b..fcf884b 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -1,7 +1,7 @@ import { unwrap } from '@pipu/core'; import { useQuery } from '@tanstack/react-query'; import { Link, Outlet, useNavigate } from '@tanstack/react-router'; -import { useState, type ReactNode } from 'react'; +import { Suspense, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuthState, useSession, useUser } from '../app/session'; @@ -151,9 +151,10 @@ function Shell() { const staff = user.role !== 'user'; const island = user.ui_prefs.web_player === 'island'; const searchAt = user.ui_prefs.search_position ?? 'center'; - const search =