diff --git a/app/app.vue b/app/app.vue index ac86994..d149f07 100644 --- a/app/app.vue +++ b/app/app.vue @@ -2,6 +2,7 @@ + diff --git a/app/components/Player/index.vue b/app/components/Player/index.vue new file mode 100644 index 0000000..2e7501f --- /dev/null +++ b/app/components/Player/index.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/app/composables/usePlayer.ts b/app/composables/usePlayer.ts new file mode 100644 index 0000000..1491f27 --- /dev/null +++ b/app/composables/usePlayer.ts @@ -0,0 +1,5 @@ +export const usePlayer = () => { + const { $player } = useNuxtApp() + + return $player +} diff --git a/app/composables/useSong.ts b/app/composables/useSong.ts new file mode 100644 index 0000000..0f4917a --- /dev/null +++ b/app/composables/useSong.ts @@ -0,0 +1,7 @@ +export const useSong = () => { + const { currentSong, queue } = usePlayer() + + return computed(() => { + return currentSong.value ?? queue.value[0] + }) +} diff --git a/app/pages/index.vue b/app/pages/index.vue index 9263154..99724ff 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,5 +1,18 @@ - + diff --git a/app/plugins/player.ts b/app/plugins/player.ts new file mode 100644 index 0000000..91c14a2 --- /dev/null +++ b/app/plugins/player.ts @@ -0,0 +1,231 @@ +import { Howl } from "howler" + +export default defineNuxtPlugin((nuxtApp) => { + 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 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 sound.value?.volume() ?? 1 + }, + set(value) { + sound.value?.volume(value) + }, + }) + 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: 0.5, + onload: () => { + duration.value = sound.value?.duration() || 0 + volume.value = sound.value?.volume() || 1 + }, + onend: () => { + 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 () => { + 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) 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 clear = () => { + queue.value = [] + currentQueue.value = [] + currentSong.value = null + sound.value?.stop() + sound.value = null + } + + const shuffle = () => { + queue.value.sort(() => Math.random() - 0.5) + } + + // 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, + clear, + shuffle, + getDuration, + secondsPlayed, + duration, + volume, + queue, + }, + }, + } +}) diff --git a/app/styles/_variables.scss b/app/styles/_variables.scss index b047d8b..6405e14 100644 --- a/app/styles/_variables.scss +++ b/app/styles/_variables.scss @@ -9,3 +9,6 @@ $color-button-hover: rgba(255, 255, 255, 0.16); $color-attention: #fc8181; $border-radius-standard: 0.3rem; +$border-radius-lg: 0.6rem; + +$player-height: 72px; diff --git a/app/ui/RangeSlider.vue b/app/ui/RangeSlider.vue index 926eb19..c74fa4a 100644 --- a/app/ui/RangeSlider.vue +++ b/app/ui/RangeSlider.vue @@ -3,20 +3,30 @@ interface SliderProps { min?: number max?: number step?: number + dontUpdateImmediately?: boolean } const props = withDefaults(defineProps(), { min: 0, max: 100, step: 1, + dontUpdateImmediately: false, }) const value = defineModel() const sliderRef = useTemplateRef("slider-ref") + +const isDragging = ref(false) +const localValue = ref(null) + const percentage = computed(() => { + const currentValue = + isDragging.value && props.dontUpdateImmediately + ? (localValue.value ?? value.value ?? 0) + : (value.value ?? 0) const range = props.max - props.min - return (((value.value ?? 0) - props.min) / range) * 100 + return (((currentValue ?? 0) - props.min) / range) * 100 }) const updateFromEvent = (event: MouseEvent) => { @@ -26,16 +36,27 @@ const updateFromEvent = (event: MouseEvent) => { const x = event.clientX - rect.left const ratio = Math.min(Math.max(x / rect.width, 0), 1) - value.value = props.min + ratio * (props.max - props.min) + const newValue = props.min + ratio * (props.max - props.min) + + if (props.dontUpdateImmediately) localValue.value = newValue + else value.value = newValue } const onMouseDown = (event: MouseEvent) => { + isDragging.value = true updateFromEvent(event) const move = (e: MouseEvent) => updateFromEvent(e) const up = () => { window.removeEventListener("mousemove", move) window.removeEventListener("mouseup", up) + + isDragging.value = false + + if (localValue.value != null && props.dontUpdateImmediately) + value.value = localValue.value + + localValue.value = null } window.addEventListener("mousemove", move) @@ -76,6 +97,5 @@ const onMouseDown = (event: MouseEvent) => { height: 100%; background: $color-accent; border-radius: 999px; - transition: width 0.08s linear; } diff --git a/app/ui/Sidebar.vue b/app/ui/Sidebar.vue index eef62f7..0286d0b 100644 --- a/app/ui/Sidebar.vue +++ b/app/ui/Sidebar.vue @@ -39,6 +39,7 @@ height: 100%; overflow-y: auto; padding: 2rem 3rem; + position: relative; } } diff --git a/app/ui/ToastContainer.vue b/app/ui/ToastContainer.vue index ac7085d..2c494d1 100644 --- a/app/ui/ToastContainer.vue +++ b/app/ui/ToastContainer.vue @@ -1,9 +1,10 @@