339 lines
9.7 KiB
JavaScript
339 lines
9.7 KiB
JavaScript
/**
|
|
* Audio Player Composable
|
|
*
|
|
* Centralized audio playback functionality for consistent behavior across the app.
|
|
* This module handles:
|
|
* - Playback state (playing, paused, loading)
|
|
* - Time tracking (current time, duration)
|
|
* - Volume/mute control
|
|
* - Seeking with progress bar support
|
|
* - Server-side duration support (for formats like WebM that don't report duration)
|
|
*
|
|
* Usage:
|
|
* const player = useAudioPlayer(ref, computed);
|
|
* // In template: @loadedmetadata="player.handleLoadedMetadata"
|
|
* // When recording changes: player.setServerDuration(recording.audio_duration)
|
|
*/
|
|
|
|
export function useAudioPlayer(ref, computed) {
|
|
// --- State ---
|
|
const isPlaying = ref(false);
|
|
const currentTime = ref(0);
|
|
const duration = ref(0);
|
|
const isMuted = ref(false);
|
|
const isLoading = ref(false);
|
|
const volume = ref(1.0);
|
|
|
|
// Progress bar drag state
|
|
const isDragging = ref(false);
|
|
const dragPreviewPercent = ref(0);
|
|
|
|
// Track if we have a reliable server-side duration
|
|
let hasServerDuration = false;
|
|
|
|
// --- Computed ---
|
|
const progressPercent = computed(() => {
|
|
// Use preview position while dragging for smooth UI
|
|
if (isDragging.value) {
|
|
return dragPreviewPercent.value;
|
|
}
|
|
if (!duration.value) return 0;
|
|
return (currentTime.value / duration.value) * 100;
|
|
});
|
|
|
|
// Preview time display while dragging
|
|
const displayCurrentTime = computed(() => {
|
|
if (isDragging.value && duration.value) {
|
|
return (dragPreviewPercent.value / 100) * duration.value;
|
|
}
|
|
return currentTime.value;
|
|
});
|
|
|
|
// --- Duration Management ---
|
|
|
|
/**
|
|
* Set duration from server-side ffprobe value.
|
|
* This is more reliable than browser metadata for some formats (WebM, etc.)
|
|
*/
|
|
const setServerDuration = (serverDuration) => {
|
|
if (serverDuration && isFinite(serverDuration) && serverDuration > 0) {
|
|
duration.value = serverDuration;
|
|
hasServerDuration = true;
|
|
} else {
|
|
hasServerDuration = false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Try to set duration from browser, only if we don't have a server-side value.
|
|
* Browser duration can be Infinity for some WebM files.
|
|
*/
|
|
const trySetBrowserDuration = (browserDuration) => {
|
|
if (hasServerDuration) {
|
|
// Don't overwrite reliable server-side duration
|
|
return;
|
|
}
|
|
if (browserDuration && isFinite(browserDuration) && browserDuration > 0) {
|
|
duration.value = browserDuration;
|
|
}
|
|
};
|
|
|
|
// --- Event Handlers ---
|
|
|
|
const handlePlayPause = (event) => {
|
|
isPlaying.value = !event.target.paused;
|
|
};
|
|
|
|
const handleLoadedMetadata = (event) => {
|
|
trySetBrowserDuration(event.target.duration);
|
|
isLoading.value = false;
|
|
};
|
|
|
|
const handleDurationChange = (event) => {
|
|
// WebM and some formats may initially report Infinity duration
|
|
// This handler catches when the actual duration becomes available
|
|
trySetBrowserDuration(event.target.duration);
|
|
};
|
|
|
|
const handleTimeUpdate = (event) => {
|
|
currentTime.value = event.target.currentTime;
|
|
|
|
// Fallback: if duration wasn't set yet, try to get it now
|
|
if (!duration.value || duration.value === 0) {
|
|
trySetBrowserDuration(event.target.duration);
|
|
}
|
|
};
|
|
|
|
const handleEnded = () => {
|
|
isPlaying.value = false;
|
|
currentTime.value = 0;
|
|
};
|
|
|
|
const handleWaiting = () => {
|
|
isLoading.value = true;
|
|
};
|
|
|
|
const handleCanPlay = (event) => {
|
|
isLoading.value = false;
|
|
|
|
// Fallback: try to get duration if not set yet
|
|
if (!duration.value || duration.value === 0) {
|
|
trySetBrowserDuration(event.target.duration);
|
|
}
|
|
};
|
|
|
|
const handleVolumeChange = (event) => {
|
|
volume.value = event.target.volume;
|
|
isMuted.value = event.target.muted;
|
|
};
|
|
|
|
// --- Actions ---
|
|
|
|
/**
|
|
* Get the audio element. Override this for custom element selection.
|
|
*/
|
|
let getAudioElement = () => {
|
|
return document.querySelector('audio[ref="audioPlayerElement"]') ||
|
|
document.querySelector('video[ref="audioPlayerElement"]') ||
|
|
document.querySelector('audio') ||
|
|
document.querySelector('video');
|
|
};
|
|
|
|
/**
|
|
* Set custom audio element getter.
|
|
*/
|
|
const setAudioElementGetter = (getter) => {
|
|
getAudioElement = getter;
|
|
};
|
|
|
|
const play = () => {
|
|
const audio = getAudioElement();
|
|
if (audio) {
|
|
audio.play().catch(err => console.warn('Play failed:', err));
|
|
}
|
|
};
|
|
|
|
const pause = () => {
|
|
const audio = getAudioElement();
|
|
if (audio) {
|
|
audio.pause();
|
|
}
|
|
};
|
|
|
|
const togglePlayback = () => {
|
|
const audio = getAudioElement();
|
|
if (!audio) return;
|
|
|
|
if (audio.paused) {
|
|
audio.play().catch(err => console.warn('Play failed:', err));
|
|
} else {
|
|
audio.pause();
|
|
}
|
|
};
|
|
|
|
const seekTo = (time) => {
|
|
const audio = getAudioElement();
|
|
if (!audio || !isFinite(time)) return;
|
|
|
|
const maxTime = isFinite(audio.duration) ? audio.duration : time;
|
|
audio.currentTime = Math.max(0, Math.min(time, maxTime));
|
|
};
|
|
|
|
const seekByPercent = (percent) => {
|
|
const audio = getAudioElement();
|
|
if (!audio || !duration.value || !isFinite(duration.value)) return;
|
|
|
|
const time = (percent / 100) * duration.value;
|
|
audio.currentTime = time;
|
|
};
|
|
|
|
const setVolume = (value) => {
|
|
const audio = getAudioElement();
|
|
if (audio) {
|
|
audio.volume = Math.max(0, Math.min(1, value));
|
|
volume.value = audio.volume;
|
|
}
|
|
};
|
|
|
|
const toggleMute = () => {
|
|
const audio = getAudioElement();
|
|
if (!audio) return;
|
|
|
|
if (audio.muted || audio.volume === 0) {
|
|
audio.muted = false;
|
|
if (audio.volume === 0) {
|
|
audio.volume = 0.5;
|
|
}
|
|
isMuted.value = false;
|
|
} else {
|
|
audio.muted = true;
|
|
isMuted.value = true;
|
|
}
|
|
};
|
|
|
|
// --- Progress Bar Drag Support ---
|
|
|
|
const startProgressDrag = (event) => {
|
|
const bar = event.currentTarget.querySelector('.h-2') || event.currentTarget;
|
|
const rect = bar.getBoundingClientRect();
|
|
const isTouch = event.type === 'touchstart';
|
|
|
|
const getPercent = (evt) => {
|
|
const clientX = isTouch ? evt.touches[0].clientX : evt.clientX;
|
|
return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
};
|
|
|
|
// Start dragging - show preview
|
|
isDragging.value = true;
|
|
dragPreviewPercent.value = getPercent(event);
|
|
|
|
const onMove = (evt) => {
|
|
evt.preventDefault();
|
|
const clientX = isTouch ? evt.touches[0].clientX : evt.clientX;
|
|
dragPreviewPercent.value = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
};
|
|
|
|
const onUp = () => {
|
|
document.removeEventListener(isTouch ? 'touchmove' : 'mousemove', onMove);
|
|
document.removeEventListener(isTouch ? 'touchend' : 'mouseup', onUp);
|
|
// Seek to final position on release
|
|
seekByPercent(dragPreviewPercent.value);
|
|
isDragging.value = false;
|
|
};
|
|
|
|
document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onMove, { passive: false });
|
|
document.addEventListener(isTouch ? 'touchend' : 'mouseup', onUp);
|
|
};
|
|
|
|
// --- Utility ---
|
|
|
|
const formatTime = (seconds) => {
|
|
if (!seconds || isNaN(seconds)) return '0:00';
|
|
const hours = Math.floor(seconds / 3600);
|
|
const mins = Math.floor((seconds % 3600) / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
if (hours > 0) {
|
|
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
/**
|
|
* Reset all player state (call when changing recordings)
|
|
*/
|
|
const reset = () => {
|
|
isPlaying.value = false;
|
|
currentTime.value = 0;
|
|
duration.value = 0;
|
|
isMuted.value = false;
|
|
isLoading.value = false;
|
|
hasServerDuration = false;
|
|
};
|
|
|
|
/**
|
|
* Initialize with a recording object.
|
|
* Automatically uses server-side duration if available.
|
|
*/
|
|
const initWithRecording = (recording) => {
|
|
reset();
|
|
if (recording && recording.audio_duration) {
|
|
setServerDuration(recording.audio_duration);
|
|
}
|
|
};
|
|
|
|
return {
|
|
// State
|
|
isPlaying,
|
|
currentTime,
|
|
duration,
|
|
isMuted,
|
|
isLoading,
|
|
volume,
|
|
isDragging,
|
|
dragPreviewPercent,
|
|
|
|
// Computed
|
|
progressPercent,
|
|
displayCurrentTime,
|
|
|
|
// Duration management
|
|
setServerDuration,
|
|
trySetBrowserDuration,
|
|
|
|
// Event handlers (wire these to <audio> element)
|
|
handlePlayPause,
|
|
handleLoadedMetadata,
|
|
handleDurationChange,
|
|
handleTimeUpdate,
|
|
handleEnded,
|
|
handleWaiting,
|
|
handleCanPlay,
|
|
handleVolumeChange,
|
|
|
|
// Actions
|
|
play,
|
|
pause,
|
|
togglePlayback,
|
|
seekTo,
|
|
seekByPercent,
|
|
setVolume,
|
|
toggleMute,
|
|
startProgressDrag,
|
|
setAudioElementGetter,
|
|
|
|
// Utility
|
|
formatTime,
|
|
reset,
|
|
initWithRecording
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a standalone audio player instance.
|
|
* Use this for pages that don't have Vue's ref/computed (like share.html).
|
|
*/
|
|
export function createStandalonePlayer(Vue) {
|
|
const { ref, computed } = Vue;
|
|
return useAudioPlayer(ref, computed);
|
|
}
|