Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions apps/web/src/components/player/AudioPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ export function AudioPlayer({ className }: AudioPlayerProps) {
const track = usePlayerStore((state) => state.track);
const status = usePlayerStore((state) => state.status);
const positionMs = usePlayerStore((state) => state.positionMs);
const previewDurationMs = usePlayerStore((state) => state.previewDurationMs);
const volume = usePlayerStore((state) => state.volume);
const isMuted = usePlayerStore((state) => state.isMuted);
const toggle = usePlayerStore((state) => state.toggle);
const seek = usePlayerStore((state) => state.seek);
const tick = usePlayerStore((state) => state.tick);
const setPreviewDuration = usePlayerStore((state) => state.setPreviewDuration);
const pause = usePlayerStore((state) => state.pause);
const play = usePlayerStore((state) => state.play);
const setVolume = usePlayerStore((state) => state.setVolume);
Expand Down Expand Up @@ -59,7 +61,10 @@ export function AudioPlayer({ className }: AudioPlayerProps) {

const isUnavailable = status === 'unavailable' || track.previewUrl === null;
const isPlaying = status === 'playing';
const progress = track.durationMs > 0 ? positionMs / track.durationMs : 0;
// The <audio> element plays the 30s preview, not the full track, so drive the
// seek bar and progress from the measured preview length when available.
const effectiveDurationMs = previewDurationMs ?? track.durationMs;
const progress = effectiveDurationMs > 0 ? positionMs / effectiveDurationMs : 0;

return (
<div
Expand All @@ -79,11 +84,15 @@ export function AudioPlayer({ className }: AudioPlayerProps) {
src={track.previewUrl}
preload="metadata"
onLoadedMetadata={() => {
const seconds = audioRef.current?.duration ?? 0;
if (Number.isFinite(seconds) && seconds > 0) {
setPreviewDuration(Math.round(seconds * 1000));
}
if (status === 'loading') play();
}}
onEnded={() => {
pause();
seek(track.durationMs);
seek(effectiveDurationMs);
}}
onTimeUpdate={() => {
if (audioRef.current === null) return;
Expand Down Expand Up @@ -156,7 +165,7 @@ export function AudioPlayer({ className }: AudioPlayerProps) {
<input
type="range"
min={0}
max={track.durationMs}
max={effectiveDurationMs}
step={100}
value={positionMs}
disabled={isUnavailable}
Expand All @@ -165,12 +174,12 @@ export function AudioPlayer({ className }: AudioPlayerProps) {
}
className="w-full cursor-pointer accent-section-accent focus-visible:outline-none"
aria-label="Seek position"
aria-valuetext={`${formatPosition(positionMs)} of ${formatPosition(track.durationMs)}`}
aria-valuetext={`${formatPosition(positionMs)} of ${formatPosition(effectiveDurationMs)}`}
/>
</label>
<div className="flex justify-between font-mono text-[11px] text-fg-faint tabular-nums">
<span>{formatPosition(positionMs)}</span>
<span>{formatPosition(track.durationMs)}</span>
<span>{formatPosition(effectiveDurationMs)}</span>
</div>
</div>

Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/player/PlayHistoryReporter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import { useEffect, useRef } from 'react';
import { recordPlay } from '@/lib/history/api';
import { usePlayerStore } from './player-store';

// Previews are 30-second clips. If the real preview length has not been measured
// yet, cap the recorded play at this fallback so analytics are not inflated by
// the full track duration.
const PREVIEW_FALLBACK_MS = 30_000;

function reportedDurationMs(trackDurationMs: number, previewDurationMs: number | null): number {
return Math.min(previewDurationMs ?? PREVIEW_FALLBACK_MS, trackDurationMs);
}

export function PlayHistoryReporter() {
const reportedRef = useRef<{ trackId: number; key: string } | null>(null);

Expand Down Expand Up @@ -34,7 +43,7 @@ export function PlayHistoryReporter() {
{
trackId: state.track.trackId,
source: 'preview',
durationPlayedMs: state.track.durationMs,
durationPlayedMs: reportedDurationMs(state.track.durationMs, state.previewDurationMs),
},
{ idempotencyKey: key },
).catch(() => {
Expand Down
20 changes: 15 additions & 5 deletions apps/web/src/components/player/player-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export interface PlayerState {
track: PlayerTrack | null;
status: PlayerStatus;
positionMs: number;
// The real length of the loaded preview (~30s), measured from audio metadata.
// Falls back to the full track duration until metadata loads.
previewDurationMs: number | null;
volume: number;
isMuted: boolean;
load(track: PlayerTrack): void;
Expand All @@ -23,6 +26,7 @@ export interface PlayerState {
toggle(): void;
seek(positionMs: number): void;
tick(positionMs: number): void;
setPreviewDuration(durationMs: number | null): void;
setVolume(volume: number): void;
setMuted(isMuted: boolean): void;
reset(): void;
Expand Down Expand Up @@ -50,13 +54,15 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
track: null,
status: 'idle',
positionMs: 0,
previewDurationMs: null,
volume: INITIAL_VOLUME,
isMuted: false,
load(track) {
set({
track,
status: track.previewUrl === null ? 'unavailable' : 'loading',
positionMs: 0,
previewDurationMs: null,
});
},
play() {
Expand Down Expand Up @@ -84,18 +90,22 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
}
},
seek(positionMs) {
const { track } = get();
const { track, previewDurationMs } = get();
if (track === null) {
return;
}
set({ positionMs: clampPosition(positionMs, track.durationMs) });
set({ positionMs: clampPosition(positionMs, previewDurationMs ?? track.durationMs) });
},
tick(positionMs) {
const { track, status } = get();
const { track, status, previewDurationMs } = get();
if (track === null || status !== 'playing') {
return;
}
set({ positionMs: clampPosition(positionMs, track.durationMs) });
set({ positionMs: clampPosition(positionMs, previewDurationMs ?? track.durationMs) });
},
setPreviewDuration(durationMs) {
const valid = durationMs !== null && Number.isFinite(durationMs) && durationMs > 0;
set({ previewDurationMs: valid ? durationMs : null });
},
setVolume(volume) {
set({ volume: clampVolume(volume), isMuted: false });
Expand All @@ -104,6 +114,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
set({ isMuted });
},
reset() {
set({ track: null, status: 'idle', positionMs: 0 });
set({ track: null, status: 'idle', positionMs: 0, previewDurationMs: null });
},
}));
Loading