Add queue (ShallowRef<Song[]>), currentIndex, playNext, playPrevious, addToQueue, removeFromQueue to usePlayer. Auto-advance on song end. Add prev/next buttons and queue count to PlayerBar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
264 lines
6.7 KiB
TypeScript
264 lines
6.7 KiB
TypeScript
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<Song | null>(null)
|
|
const playableSource = ref<MusicSearchResult | null>(null)
|
|
const isPlaying = ref(false)
|
|
const isLoading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
const currentTime = ref(0)
|
|
const duration = ref(0)
|
|
|
|
// Queue management
|
|
const queue = shallowRef<Song[]>([])
|
|
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<MusicSearchResult | null> {
|
|
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,
|
|
}
|
|
}
|