feat(player): implement crossfade from settings (C1)
This commit is contained in:
parent
590cede6c2
commit
ca1739fe08
4 changed files with 99 additions and 5 deletions
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue