import { Howl } from "howler" export default defineNuxtPlugin((nuxtApp) => { const { close: closeFullscreen } = useFullscreen() const VOLUME_STORAGE_KEY = "music-v2-player-volume" const DEFAULT_VOLUME = 0.5 const sound = ref(null) const queue = ref([]) const prevQueue = ref([]) const currentQueue = ref([]) const currentSong = ref(null) const repeat = ref(false) const isPlaying = ref(false) const storedVolume = ref(DEFAULT_VOLUME) const clampVolume = (value: number) => Math.min(1, Math.max(0, value)) const readStoredVolume = () => { if (!import.meta.client) return DEFAULT_VOLUME const rawVolume = window.localStorage.getItem(VOLUME_STORAGE_KEY) if (rawVolume === null) return DEFAULT_VOLUME const parsedVolume = Number(rawVolume) if (Number.isNaN(parsedVolume)) return DEFAULT_VOLUME return clampVolume(parsedVolume) } if (import.meta.client) { storedVolume.value = readStoredVolume() watch(storedVolume, (value) => { window.localStorage.setItem(VOLUME_STORAGE_KEY, String(value)) }) } const tick = ref(0) const secondsPlayed = computed({ get() { tick.value return (sound.value?.seek() as number) || 0 }, set(value: number) { sound.value?.seek(value) }, }) const volume = computed({ get() { tick.value return storedVolume.value }, set(value) { const nextVolume = clampVolume(value) storedVolume.value = nextVolume sound.value?.volume(nextVolume) }, }) const duration = ref(0) const aniFrame = ref(null) // Helpers const updateMediaSession = async () => { if (!import.meta.client) return if (!navigator.mediaSession) { console.warn("Media Session API is not supported") return } if (!currentSong.value) return navigator.mediaSession.metadata = new MediaMetadata({ title: currentSong.value.name, artist: currentSong.value.artists[0]?.name, album: currentSong.value.album.name, artwork: [ { src: GET_FILE(currentSong.value.album.file), sizes: "512x512", }, ], }) navigator.mediaSession.setPositionState({ duration: duration.value, position: secondsPlayed.value, }) } const startTracking = () => { const update = () => { tick.value++ aniFrame.value = requestAnimationFrame(update) } update() } const stopTracking = () => { if (aniFrame.value) cancelAnimationFrame(aniFrame.value) aniFrame.value = null } const createHowl = async (song: Track) => { if (!import.meta.client) return if (!song) throw new Error("Song not provided") const newSound = new Howl({ src: [GET_AUDIO_FILE(song.fileDir)], html5: false, autoplay: false, volume: storedVolume.value, onload: () => { duration.value = sound.value?.duration() || 0 sound.value?.volume(storedVolume.value) }, onend: () => { void closeFullscreen() next() stopTracking() }, onstop: () => { isPlaying.value = false stopTracking() if (navigator.mediaSession) navigator.mediaSession.playbackState = "none" }, onplay: () => { isPlaying.value = true startTracking() if (navigator.mediaSession) navigator.mediaSession.playbackState = "playing" }, onpause: () => { isPlaying.value = false stopTracking() if (navigator.mediaSession) navigator.mediaSession.playbackState = "paused" }, }) sound.value = newSound return newSound } // Core actions const play = async () => { if (!currentSong.value) await next() if (!sound.value && currentSong.value) { await createHowl(currentSong.value) } if (sound.value && !sound.value.playing()) { sound.value.play() isPlaying.value = true } updateMediaSession() } const pause = () => { sound.value?.pause() isPlaying.value = false updateMediaSession() } const next = async (autoplay: boolean = true) => { if (currentSong.value) { prevQueue.value.unshift(currentSong.value) } currentSong.value = queue.value.shift() || null sound.value?.stop() sound.value = null if (!currentSong.value && repeat.value) { queue.value = [...currentQueue.value] currentSong.value = queue.value.shift() || null } if (currentSong.value && autoplay) await play() updateMediaSession() } const prev = async () => { if (sound.value && sound.value.seek() > 5) { sound.value.seek(0) return } const prevSong = prevQueue.value.shift() if (!prevSong) return if (currentSong.value) queue.value.unshift(currentSong.value) currentSong.value = prevSong sound.value?.stop() sound.value = null if (currentSong.value) await play() updateMediaSession() } // Queue const addToQueue = (song: Track) => { queue.value.push(song) currentQueue.value.push(song) } const addToFrontOfQueue = (song: Track) => { queue.value.unshift(song) currentQueue.value.unshift(song) } const clear = () => { queue.value = [] currentQueue.value = [] currentSong.value = null sound.value?.stop() sound.value = null } const shuffle = () => { queue.value.sort(() => Math.random() - 0.5) } // Album const playAlbum = async (songs: Track[]) => { clear() songs.forEach((song) => addToQueue(song)) await next() } const playShuffledAlbum = async (songs: Track[]) => { clear() songs.forEach((song) => addToQueue(song)) shuffle() await next() } const playAlbumAtIndex = async (songs: Track[], index: number) => { clear() songs.forEach((song) => addToQueue(song)) for (let i = 0; i < index + 1; i++) { await next(false) } await play() } // Info const getDuration = () => new Promise((resolve) => { if (!sound.value) return resolve(0) if (sound.value.state() === "loaded") { resolve(sound.value.duration()) } else { sound.value.once("load", () => resolve(sound.value!.duration())) } }) return { provide: { player: { isPlaying, currentSong, repeat, play, pause, next, prev, addToQueue, addToFrontOfQueue, clear, shuffle, playAlbum, playShuffledAlbum, playAlbumAtIndex, getDuration, secondsPlayed, duration, volume, queue, }, }, } })