Files
archy/packages/app/src/composables/usePlayer.ts
T
DorianandClaude Opus 4.6 79691c9a25 feat(app): add music queue management with next/prev navigation
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>
2026-03-03 20:22:51 +00:00

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,
}
}