import { ref, shallowRef, computed } from 'vue' import type { Song } from '@aiui/core/types/content' import Plyr from 'plyr' import 'plyr/dist/plyr.css' interface MusicSearchResult { source: string type: 'stream' | 'embed' url: string title?: string artist?: string } const currentSong = ref(null) const playableSource = ref(null) const isPlaying = ref(false) const isLoading = ref(false) const error = ref(null) const currentTime = ref(0) const duration = ref(0) // Queue management const queue = shallowRef([]) const currentIndex = ref(-1) let plyrInstance: Plyr | null = null let containerEl: HTMLDivElement | null = null export function usePlayer() { const hasTrack = computed(() => !!currentSong.value) const progress = computed(() => { if (duration.value <= 0) return 0 return (currentTime.value / duration.value) * 100 }) async function searchMusic(query: string, title?: string, artist?: string): Promise { try { const params = new URLSearchParams({ q: query }) if (title) params.set('title', title) if (artist) params.set('artist', artist) const res = await fetch(`/api/music/search?${params}`) const data = (await res.json()) as MusicSearchResult & { error?: string } if (data.error || !data.url) { error.value = data.error ?? 'No playable source found' return null } return data as MusicSearchResult } catch (e) { error.value = 'Network error. Is the dev server running?' return null } } function setContainer(el: HTMLDivElement | null) { containerEl = el if (el && playableSource.value) { initPlayer(playableSource.value) } } function initPlayer(result: MusicSearchResult) { if (!containerEl) return destroyPlayer() if (result.type === 'stream') { const audio = document.createElement('audio') audio.src = result.url audio.crossOrigin = 'anonymous' containerEl.innerHTML = '' containerEl.appendChild(audio) plyrInstance = new Plyr(audio, { controls: [], autoplay: true, muted: false, }) } else { const iframe = document.createElement('iframe') iframe.src = result.url iframe.style.width = '100%' iframe.style.height = '100%' iframe.style.border = 'none' containerEl.innerHTML = '' containerEl.appendChild(iframe) plyrInstance = null duration.value = 0 currentTime.value = 0 return } plyrInstance!.on('ready', () => { Promise.resolve(plyrInstance!.play()).catch(() => { /* autoplay blocked */ }) }) plyrInstance!.on('timeupdate', () => { currentTime.value = plyrInstance!.currentTime ?? 0 }) plyrInstance!.on('loadedmetadata', () => { duration.value = plyrInstance!.duration ?? 0 }) plyrInstance!.on('ended', () => { isPlaying.value = false // Auto-advance to next song in queue if (currentIndex.value >= 0 && currentIndex.value < queue.value.length - 1) { playNext() } }) plyrInstance!.on('playing', () => { isPlaying.value = true }) plyrInstance!.on('pause', () => { isPlaying.value = false }) } function destroyPlayer() { if (plyrInstance) { plyrInstance.destroy() plyrInstance = null } if (containerEl) { containerEl.innerHTML = '' } } async function play(song: Song) { error.value = null if (currentSong.value?.id === song.id && playableSource.value) { plyrInstance?.play() isPlaying.value = true return } isLoading.value = true const query = `${song.title} ${song.artist}`.trim() const result = await searchMusic(query, song.title, song.artist) isLoading.value = false currentSong.value = song if (!result) { if (!error.value) error.value = 'No playable source. Tries: Internet Archive, Jamendo, Odysee. Add JAMENDO_CLIENT_ID for more.' return } error.value = null playableSource.value = result if (containerEl) { initPlayer(result) } } function pause() { plyrInstance?.pause() isPlaying.value = false } function toggle() { if (plyrInstance) { if (isPlaying.value) pause() else plyrInstance.play() } } function seek(percent: number) { if (!plyrInstance || !duration.value) return const time = (percent / 100) * duration.value plyrInstance.currentTime = time currentTime.value = time } function addToQueue(song: Song) { const existing = queue.value.find(s => s.id === song.id) if (!existing) { queue.value = [...queue.value, song] } } function playNext() { if (queue.value.length === 0) return const nextIdx = currentIndex.value + 1 if (nextIdx < queue.value.length) { currentIndex.value = nextIdx play(queue.value[nextIdx]) } } function playPrevious() { if (queue.value.length === 0) return const prevIdx = currentIndex.value - 1 if (prevIdx >= 0) { currentIndex.value = prevIdx play(queue.value[prevIdx]) } } function removeFromQueue(index: number) { const newQueue = [...queue.value] newQueue.splice(index, 1) queue.value = newQueue // Adjust currentIndex if needed if (index < currentIndex.value) { currentIndex.value-- } else if (index === currentIndex.value) { // Removed the current track — pause currentIndex.value = Math.min(currentIndex.value, newQueue.length - 1) } } // Override play to update queue position const originalPlay = play async function playWithQueue(song: Song) { // If not in queue, add it const idx = queue.value.findIndex(s => s.id === song.id) if (idx === -1) { addToQueue(song) currentIndex.value = queue.value.length - 1 } else { currentIndex.value = idx } await originalPlay(song) } function clear() { pause() destroyPlayer() currentSong.value = null playableSource.value = null currentTime.value = 0 duration.value = 0 error.value = null } function clearQueue() { clear() queue.value = [] currentIndex.value = -1 } const hasNext = computed(() => currentIndex.value < queue.value.length - 1) const hasPrevious = computed(() => currentIndex.value > 0) return { currentSong, playableSource, isPlaying, isLoading, error, currentTime, duration, hasTrack, progress, queue, currentIndex, hasNext, hasPrevious, play: playWithQueue, pause, toggle, seek, clear, clearQueue, addToQueue, playNext, playPrevious, removeFromQueue, setContainer, } }