feat(player): implement crossfade from settings (C1)

This commit is contained in:
senke 2026-02-20 17:04:54 +01:00
parent 590cede6c2
commit ca1739fe08
4 changed files with 99 additions and 5 deletions

View file

@ -18,6 +18,7 @@ export function usePlayer(
const store = usePlayerStore();
const internalAudioRef = useRef<HTMLAudioElement | null>(null);
const audioRef = audioElementRef?.current || internalAudioRef.current;
const nextWillFadeInRef = useRef(false);
// Initialiser le service audio avec l'élément audio
useEffect(() => {
@ -62,7 +63,15 @@ export function usePlayer(
const syncPlayback = async () => {
try {
if (store.isPlaying) {
await audioPlayerService.play();
if (nextWillFadeInRef.current && (store.crossfadeSeconds ?? 0) > 0) {
nextWillFadeInRef.current = false;
const targetVol = store.muted ? 0 : store.volume / 100;
audioPlayerService.setVolume(0);
await audioPlayerService.play();
await audioPlayerService.fadeIn(store.crossfadeSeconds ?? 3, targetVol);
} else {
await audioPlayerService.play();
}
} else {
audioPlayerService.pause();
}
@ -79,9 +88,23 @@ export function usePlayer(
useEffect(() => {
if (!audioRef) return;
// Callback pour les mises à jour de temps
let crossfadeTriggered = false;
// Callback pour les mises à jour de temps (détection crossfade sortant)
audioPlayerService.onTimeUpdate((currentTime) => {
store.setCurrentTime(currentTime);
const cf = store.crossfadeSeconds ?? 0;
if (cf > 0 && store.repeat !== 'track' && !crossfadeTriggered) {
const duration = store.duration || audioPlayerService.getDuration();
if (duration > 0 && duration - currentTime <= cf) {
crossfadeTriggered = true;
nextWillFadeInRef.current = true;
audioPlayerService.fadeOut(cf, () => {
store.next();
crossfadeTriggered = false;
});
}
}
});
// Callback pour les changements de durée
@ -92,15 +115,14 @@ export function usePlayer(
// Callback quand la track se termine
audioPlayerService.onEnded(() => {
if (store.repeat === 'track') {
// Répéter la track actuelle
audioPlayerService.seek(0);
audioPlayerService
.play()
.catch((err) =>
logger.error('Failed to reply track:', { error: err }),
);
} else {
// Passer à la track suivante
} else if (!crossfadeTriggered) {
nextWillFadeInRef.current = (store.crossfadeSeconds ?? 0) > 0;
store.next();
}
});

View file

@ -188,6 +188,61 @@ export class AudioPlayerService {
}
}
/**
* Fade out over duration (seconds), then call onComplete
*/
async fadeOut(seconds: number, onComplete: () => void): Promise<void> {
if (!this.audioElement || seconds <= 0) {
onComplete();
return;
}
const startVolume = this.audioElement.volume;
const steps = 20;
const stepMs = (seconds * 1000) / steps;
const stepDelta = startVolume / steps;
let step = 0;
const id = setInterval(() => {
step++;
if (step >= steps) {
clearInterval(id);
this.audioElement!.volume = 0;
onComplete();
return;
}
if (this.audioElement) {
this.audioElement.volume = Math.max(0, startVolume - stepDelta * step);
}
}, stepMs);
}
/**
* Fade in from 0 over duration (seconds)
*/
async fadeIn(seconds: number, targetVolume: number): Promise<void> {
if (!this.audioElement || seconds <= 0) {
if (this.audioElement) this.audioElement.volume = targetVolume;
return;
}
const steps = 20;
const stepMs = (seconds * 1000) / steps;
const stepDelta = targetVolume / steps;
let step = 0;
return new Promise((resolve) => {
const id = setInterval(() => {
step++;
if (step >= steps) {
clearInterval(id);
if (this.audioElement) this.audioElement.volume = targetVolume;
resolve();
return;
}
if (this.audioElement) {
this.audioElement.volume = Math.min(targetVolume, stepDelta * step);
}
}, stepMs);
});
}
/**
* Met en pause l'audio
*/

View file

@ -9,6 +9,8 @@ import type { Track, PlayerState, PlayerControls } from '../types';
// FE-TYPE-011: Fully typed store interface
export interface PlayerStore extends PlayerState, PlayerControls {
crossfadeSeconds: number;
setCrossfadeSeconds: (seconds: number) => void;
setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void;
removeFromQueue: (index: number) => void;
@ -30,6 +32,11 @@ export const usePlayerStore = create<PlayerStore>()(
currentIndex: -1,
repeat: 'off',
shuffle: false,
crossfadeSeconds: 3,
setCrossfadeSeconds: (seconds: number) => {
set({ crossfadeSeconds: Math.max(0, Math.min(12, seconds)) });
},
// Actions de contrôle
play: (track?: Track) => {
@ -249,6 +256,7 @@ export const usePlayerStore = create<PlayerStore>()(
version: 1,
partialize: (state) => ({
volume: state.volume,
crossfadeSeconds: state.crossfadeSeconds,
muted: state.muted,
playbackSpeed: state.playbackSpeed,
repeat: state.repeat,
@ -269,6 +277,7 @@ export const usePlayerStore = create<PlayerStore>()(
: (rawRepeat === 'track' || rawRepeat === 'playlist' ? rawRepeat : 'off');
return {
volume: (s?.volume as number | undefined) ?? 100,
crossfadeSeconds: (s?.crossfadeSeconds as number | undefined) ?? 3,
muted: (s?.muted as boolean | undefined) ?? false,
playbackSpeed: (s?.playbackSpeed as number | undefined) ?? 1,
repeat,

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useUser } from '@/features/auth/hooks/useUser';
import { usePlayerStore } from '@/features/player/store/playerStore';
import { usersApi } from '@/services/api/users';
import { UserSettings } from '../types/settings';
import { SettingsTabs } from '../components/SettingsTabs';
@ -66,6 +67,7 @@ function SettingsPageSkeleton() {
export function SettingsPage() {
const { data: user } = useUser();
const setCrossfadeSeconds = usePlayerStore((s) => s.setCrossfadeSeconds);
const [settings, setSettings] = useState<UserSettings | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
@ -92,6 +94,9 @@ export function SettingsPage() {
setQueryError(null);
const userSettings = await usersApi.getSettings(user.id);
setSettings(userSettings);
if (userSettings.playback?.crossfade != null) {
setCrossfadeSeconds(userSettings.playback.crossfade);
}
} catch (err) {
setQueryError(new Error(err instanceof Error ? err.message : 'Failed to load system configuration.'));
} finally {
@ -111,6 +116,9 @@ export function SettingsPage() {
const performMutation = async () => {
await usersApi.updateSettings(user.id, settings);
if (settings.playback?.crossfade != null) {
setCrossfadeSeconds(settings.playback.crossfade);
}
toast.success('System configuration updated.');
setMutationError(null);
setRetryCount(0);