veza/apps/web/src/features/player/services/playerService.ts

354 lines
8.3 KiB
TypeScript

/**
* Service pour gérer les opérations liées au player
*/
import type { Track } from '../types';
/**
* Formate le temps en format MM:SS
*/
export function formatTime(seconds: number): string {
if (isNaN(seconds) || !isFinite(seconds) || seconds < 0) {
return '0:00';
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
/**
* Calcule le pourcentage de progression
*/
export function calculateProgress(
currentTime: number,
duration: number,
): number {
if (!duration || duration === 0) {
return 0;
}
return (currentTime / duration) * 100;
}
/**
* Valide qu'une piste est valide
*/
export function isValidTrack(track: Track | null): boolean {
if (!track) {
return false;
}
return !!(track.id && track.title && track.url);
}
/**
* Trouve l'index d'une piste dans la queue
*/
export function findTrackIndex(queue: Track[], trackId: string): number {
return queue.findIndex((track) => track.id === trackId);
}
/**
* Mélange un tableau de pistes
*/
export function shuffleTracks(tracks: Track[]): Track[] {
const shuffled = [...tracks];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
/**
* Types pour les callbacks d'événements audio
*/
export type AudioEventCallback = () => void;
export type TimeUpdateCallback = (currentTime: number) => void;
export type DurationChangeCallback = (duration: number) => void;
export type ErrorCallback = (error: Error) => void;
/**
* Classe pour gérer l'élément audio HTML
*/
export class AudioPlayerService {
private audioElement: HTMLAudioElement | null = null;
private timeUpdateCallback: TimeUpdateCallback | null = null;
private durationChangeCallback: DurationChangeCallback | null = null;
private endedCallback: AudioEventCallback | null = null;
private errorCallback: ErrorCallback | null = null;
private playCallback: AudioEventCallback | null = null;
private pauseCallback: AudioEventCallback | null = null;
/**
* Initialise le service avec un élément audio
*/
initialize(audioElement: HTMLAudioElement): void {
this.audioElement = audioElement;
this.setupEventListeners();
}
/**
* Configure les écouteurs d'événements
*/
private setupEventListeners(): void {
if (!this.audioElement) return;
this.audioElement.addEventListener('timeupdate', this.handleTimeUpdate);
this.audioElement.addEventListener(
'loadedmetadata',
this.handleLoadedMetadata,
);
this.audioElement.addEventListener(
'durationchange',
this.handleDurationChange,
);
this.audioElement.addEventListener('ended', this.handleEnded);
this.audioElement.addEventListener('error', this.handleError);
this.audioElement.addEventListener('play', this.handlePlay);
this.audioElement.addEventListener('pause', this.handlePause);
}
/**
* Nettoie les écouteurs d'événements
*/
cleanup(): void {
if (!this.audioElement) return;
this.audioElement.removeEventListener('timeupdate', this.handleTimeUpdate);
this.audioElement.removeEventListener(
'loadedmetadata',
this.handleLoadedMetadata,
);
this.audioElement.removeEventListener(
'durationchange',
this.handleDurationChange,
);
this.audioElement.removeEventListener('ended', this.handleEnded);
this.audioElement.removeEventListener('error', this.handleError);
this.audioElement.removeEventListener('play', this.handlePlay);
this.audioElement.removeEventListener('pause', this.handlePause);
this.audioElement = null;
}
/**
* Charge une track dans l'élément audio
*/
async loadTrack(track: Track | null): Promise<void> {
if (!this.audioElement) {
throw new Error('Audio element not initialized');
}
if (!track) {
this.audioElement.src = '';
return;
}
if (!isValidTrack(track)) {
throw new Error('Invalid track');
}
this.audioElement.src = track.url;
this.audioElement.load();
}
/**
* Joue l'audio
*/
async play(): Promise<void> {
if (!this.audioElement) {
throw new Error('Audio element not initialized');
}
try {
await this.audioElement.play();
} catch (error) {
throw new Error(`Failed to play audio: ${error}`);
}
}
/**
* Met en pause l'audio
*/
pause(): void {
if (!this.audioElement) {
throw new Error('Audio element not initialized');
}
this.audioElement.pause();
}
/**
* Arrête l'audio et remet à zéro
*/
stop(): void {
if (!this.audioElement) {
throw new Error('Audio element not initialized');
}
this.audioElement.pause();
this.audioElement.currentTime = 0;
}
/**
* Va à une position spécifique
*/
seek(time: number): void {
if (!this.audioElement) {
throw new Error('Audio element not initialized');
}
const duration = this.audioElement.duration || 0;
const clampedTime = Math.max(0, Math.min(time, duration));
this.audioElement.currentTime = clampedTime;
}
/**
* Définit le volume (0-1)
*/
setVolume(volume: number): void {
if (!this.audioElement) {
throw new Error('Audio element not initialized');
}
const clampedVolume = Math.max(0, Math.min(1, volume));
this.audioElement.volume = clampedVolume;
}
/**
* Active ou désactive le mute
*/
setMuted(muted: boolean): void {
if (!this.audioElement) {
throw new Error('Audio element not initialized');
}
this.audioElement.muted = muted;
}
/**
* Obtient le temps actuel
*/
getCurrentTime(): number {
if (!this.audioElement) {
return 0;
}
return this.audioElement.currentTime || 0;
}
/**
* Obtient la durée
*/
getDuration(): number {
if (!this.audioElement) {
return 0;
}
return this.audioElement.duration || 0;
}
/**
* Obtient le volume
*/
getVolume(): number {
if (!this.audioElement) {
return 1;
}
return this.audioElement.volume;
}
/**
* Vérifie si l'audio est en cours de lecture
*/
isPlaying(): boolean {
if (!this.audioElement) {
return false;
}
return !this.audioElement.paused && !this.audioElement.ended;
}
/**
* Vérifie si l'audio est en mute
*/
isMuted(): boolean {
if (!this.audioElement) {
return false;
}
return this.audioElement.muted;
}
// Handlers d'événements
private handleTimeUpdate = (): void => {
if (this.timeUpdateCallback && this.audioElement) {
this.timeUpdateCallback(this.audioElement.currentTime);
}
};
private handleLoadedMetadata = (): void => {
if (this.durationChangeCallback && this.audioElement) {
this.durationChangeCallback(this.audioElement.duration);
}
};
private handleDurationChange = (): void => {
if (this.durationChangeCallback && this.audioElement) {
this.durationChangeCallback(this.audioElement.duration);
}
};
private handleEnded = (): void => {
if (this.endedCallback) {
this.endedCallback();
}
};
private handleError = (): void => {
if (this.errorCallback && this.audioElement) {
const error = new Error(
this.audioElement.error?.message || 'Unknown audio error',
);
this.errorCallback(error);
}
};
private handlePlay = (): void => {
if (this.playCallback) {
this.playCallback();
}
};
private handlePause = (): void => {
if (this.pauseCallback) {
this.pauseCallback();
}
};
// Méthodes pour enregistrer les callbacks
onTimeUpdate(callback: TimeUpdateCallback | null): void {
this.timeUpdateCallback = callback;
}
onDurationChange(callback: DurationChangeCallback | null): void {
this.durationChangeCallback = callback;
}
onEnded(callback: AudioEventCallback | null): void {
this.endedCallback = callback;
}
onError(callback: ErrorCallback | null): void {
this.errorCallback = callback;
}
onPlay(callback: AudioEventCallback | null): void {
this.playCallback = callback;
}
onPause(callback: AudioEventCallback | null): void {
this.pauseCallback = callback;
}
}
/**
* Instance singleton du service audio
*/
export const audioPlayerService = new AudioPlayerService();