- Replace emoji icons with professional SVG icons from Lucide Vue - Add collapsible MusicControls with compact top-right collapse button - Improve icon system with dynamic sizing and proper prop handling - Disable SSR to prevent hydration issues with audio APIs - Update IconButton to support both emoji strings and SVG components - Optimize bottom positioning for expanded vs collapsed states - Document new icon system in DESIGN_SYSTEM.md
372 lines
10 KiB
Vue
372 lines
10 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Aurora Background -->
|
|
<AuroraBackground
|
|
:is-playing="isPlaying"
|
|
:current-track="currentTrack"
|
|
/>
|
|
|
|
<!-- Main Container -->
|
|
<MainContainer
|
|
:current-track="currentTrack"
|
|
:is-playing="isPlaying"
|
|
@shuffle-changed="handleShuffleChanged"
|
|
@repeat-changed="handleRepeatChanged"
|
|
>
|
|
<!-- Track List -->
|
|
<TrackList
|
|
:tracks="tracks"
|
|
:display-tracks="displayTracks"
|
|
:current-track="currentTrack"
|
|
:is-playing="isPlaying"
|
|
:loading="loading"
|
|
:loading-track="loadingTrack"
|
|
@track-selected="handleTrackSelected"
|
|
/>
|
|
</MainContainer>
|
|
|
|
<!-- Music Controls -->
|
|
<MusicControls
|
|
:current-track="currentTrack"
|
|
:is-playing="isPlaying"
|
|
:current-time="currentTime"
|
|
:duration="duration"
|
|
:volume="volume"
|
|
@previous="previousTrack"
|
|
@next="nextTrack"
|
|
@toggle-play="togglePlay"
|
|
@seek="seekTo"
|
|
@volume-change="setVolume"
|
|
/>
|
|
|
|
<!-- Audio Element -->
|
|
<audio
|
|
ref="audioPlayer"
|
|
@loadedmetadata="onLoadedMetadata"
|
|
@timeupdate="onTimeUpdate"
|
|
@ended="onTrackEnded"
|
|
@canplay="onCanPlay"
|
|
@error="onAudioError"
|
|
></audio>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
// Disable SSR for this page since it uses browser APIs and audio elements
|
|
definePageMeta({
|
|
ssr: false
|
|
})
|
|
|
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
|
import { useLocalStorage } from '@vueuse/core'
|
|
|
|
// Import components
|
|
import AuroraBackground from '~/components/AuroraBackground.client.vue'
|
|
import MainContainer from '~/components/MainContainer.client.vue'
|
|
import TrackList from '~/components/TrackList.client.vue'
|
|
import MusicControls from '~/components/MusicControls.client.vue'
|
|
|
|
// Reactive state
|
|
const tracks = ref([])
|
|
const currentTrack = ref(null)
|
|
const currentTrackIndex = ref(0)
|
|
const isPlaying = ref(false)
|
|
const currentTime = ref(0)
|
|
const duration = ref(0)
|
|
const volume = ref(0.7)
|
|
const loading = ref(true)
|
|
const loadingTrack = ref(null)
|
|
|
|
// Theme (handled by ThemeToggle component now)
|
|
const isDark = useLocalStorage('theme-dark', false)
|
|
|
|
// Playback modes
|
|
const isShuffled = useLocalStorage('shuffle', false)
|
|
const repeatMode = useLocalStorage('repeat', 'none') // 'none', 'all', 'one'
|
|
const shuffledIndices = ref([])
|
|
|
|
// Refs
|
|
const audioPlayer = ref(null)
|
|
|
|
// Computed - removed progressPercent as it's not used in this component
|
|
|
|
const displayTracks = computed(() => {
|
|
if (isShuffled.value && shuffledIndices.value.length > 0) {
|
|
return shuffledIndices.value.map(index => tracks.value[index])
|
|
}
|
|
return tracks.value
|
|
})
|
|
|
|
// Methods
|
|
const loadTracks = async () => {
|
|
try {
|
|
const response = await $fetch('/api/music')
|
|
tracks.value = response.tracks
|
|
if (tracks.value.length > 0) {
|
|
generateShuffledIndices()
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load tracks:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const generateShuffledIndices = () => {
|
|
shuffledIndices.value = Array.from({ length: tracks.value.length }, (_, i) => i)
|
|
// Fisher-Yates shuffle algorithm for truly random results
|
|
for (let i = shuffledIndices.value.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1))
|
|
;[shuffledIndices.value[i], shuffledIndices.value[j]] = [shuffledIndices.value[j], shuffledIndices.value[i]]
|
|
}
|
|
}
|
|
|
|
const playTrack = async (track, index) => {
|
|
loadingTrack.value = track.name
|
|
currentTrack.value = track
|
|
currentTrackIndex.value = isShuffled.value
|
|
? shuffledIndices.value.indexOf(tracks.value.indexOf(track))
|
|
: index
|
|
|
|
if (audioPlayer.value) {
|
|
// Clear previous audio data
|
|
if (audioPlayer.value.currentBlobUrl) {
|
|
URL.revokeObjectURL(audioPlayer.value.currentBlobUrl)
|
|
audioPlayer.value.currentBlobUrl = null
|
|
}
|
|
|
|
try {
|
|
// Fetch and preload entire song into memory
|
|
const encodedName = encodeURIComponent(track.name)
|
|
const response = await fetch(`/api/music/${encodedName}`)
|
|
const blob = await response.blob()
|
|
const audioUrl = URL.createObjectURL(blob)
|
|
|
|
// Store reference to revoke later
|
|
audioPlayer.value.currentBlobUrl = audioUrl
|
|
audioPlayer.value.src = audioUrl
|
|
console.log('Preloaded track into memory:', track.name)
|
|
audioPlayer.value.load()
|
|
|
|
// Auto-play when ready
|
|
audioPlayer.value.addEventListener('canplay', () => {
|
|
loadingTrack.value = null
|
|
audioPlayer.value.play()
|
|
}, { once: true })
|
|
} catch (error) {
|
|
console.error('Failed to preload track:', error)
|
|
// Fallback to streaming
|
|
const encodedName = encodeURIComponent(track.name)
|
|
audioPlayer.value.src = `/api/music/${encodedName}`
|
|
audioPlayer.value.load()
|
|
|
|
// Auto-play when ready (fallback)
|
|
audioPlayer.value.addEventListener('canplay', () => {
|
|
loadingTrack.value = null
|
|
audioPlayer.value.play()
|
|
}, { once: true })
|
|
}
|
|
}
|
|
}
|
|
|
|
const togglePlay = () => {
|
|
if (!audioPlayer.value || !currentTrack.value) return
|
|
|
|
if (isPlaying.value) {
|
|
audioPlayer.value.pause()
|
|
} else {
|
|
audioPlayer.value.play()
|
|
}
|
|
}
|
|
|
|
const nextTrack = () => {
|
|
if (tracks.value.length === 0) return
|
|
|
|
let nextIndex
|
|
const trackList = isShuffled.value ? shuffledIndices.value : Array.from({ length: tracks.value.length }, (_, i) => i)
|
|
|
|
if (repeatMode.value === 'one') {
|
|
// Repeat current track
|
|
nextIndex = currentTrackIndex.value
|
|
} else {
|
|
// Move to next track
|
|
nextIndex = (currentTrackIndex.value + 1) % trackList.length
|
|
|
|
if (nextIndex === 0 && repeatMode.value === 'none') {
|
|
// End of playlist and no repeat
|
|
isPlaying.value = false
|
|
return
|
|
}
|
|
}
|
|
|
|
const actualIndex = isShuffled.value ? trackList[nextIndex] : nextIndex
|
|
const track = tracks.value[actualIndex]
|
|
|
|
playTrack(track, actualIndex)
|
|
|
|
setTimeout(() => {
|
|
if (audioPlayer.value) {
|
|
audioPlayer.value.play()
|
|
}
|
|
}, 100)
|
|
}
|
|
|
|
const previousTrack = () => {
|
|
if (tracks.value.length === 0) return
|
|
|
|
const trackList = isShuffled.value ? shuffledIndices.value : Array.from({ length: tracks.value.length }, (_, i) => i)
|
|
const prevIndex = currentTrackIndex.value === 0 ? trackList.length - 1 : currentTrackIndex.value - 1
|
|
|
|
const actualIndex = isShuffled.value ? trackList[prevIndex] : prevIndex
|
|
const track = tracks.value[actualIndex]
|
|
|
|
playTrack(track, actualIndex)
|
|
|
|
setTimeout(() => {
|
|
if (audioPlayer.value) {
|
|
audioPlayer.value.play()
|
|
}
|
|
}, 100)
|
|
}
|
|
|
|
const handleShuffleChanged = (shuffled) => {
|
|
isShuffled.value = shuffled
|
|
if (shuffled) {
|
|
generateShuffledIndices()
|
|
}
|
|
}
|
|
|
|
const handleRepeatChanged = (mode) => {
|
|
repeatMode.value = mode
|
|
}
|
|
|
|
const handleTrackSelected = ({ track, index }) => {
|
|
playTrack(track, index)
|
|
}
|
|
|
|
// Removed formatTime function as it's not used in this component - it's defined in child components where needed
|
|
|
|
const seekTo = (newTime) => {
|
|
if (!audioPlayer.value) return
|
|
audioPlayer.value.currentTime = newTime
|
|
}
|
|
|
|
const setVolume = (newVolume) => {
|
|
volume.value = Math.max(0, Math.min(1, newVolume))
|
|
|
|
if (audioPlayer.value) {
|
|
audioPlayer.value.volume = volume.value
|
|
}
|
|
}
|
|
|
|
// Audio event handlers
|
|
const onLoadedMetadata = () => {
|
|
if (audioPlayer.value) {
|
|
duration.value = audioPlayer.value.duration
|
|
audioPlayer.value.volume = volume.value
|
|
}
|
|
}
|
|
|
|
const onAudioError = (error) => {
|
|
console.error('Audio error:', error)
|
|
console.error('Failed to load:', currentTrack.value?.name)
|
|
// Try next track if current fails
|
|
nextTrack()
|
|
}
|
|
|
|
const onTimeUpdate = () => {
|
|
if (audioPlayer.value) {
|
|
currentTime.value = audioPlayer.value.currentTime
|
|
}
|
|
}
|
|
|
|
const onTrackEnded = () => {
|
|
isPlaying.value = false
|
|
nextTrack()
|
|
}
|
|
|
|
const onCanPlay = () => {
|
|
// Track is ready to play
|
|
}
|
|
|
|
// Watchers
|
|
watch(isDark, (newValue) => {
|
|
if (process.client) {
|
|
document.documentElement.setAttribute('data-theme', newValue ? 'dark' : 'light')
|
|
}
|
|
}, { immediate: true })
|
|
|
|
watch(() => audioPlayer.value, (newAudio) => {
|
|
if (newAudio) {
|
|
newAudio.addEventListener('play', () => { isPlaying.value = true })
|
|
newAudio.addEventListener('pause', () => { isPlaying.value = false })
|
|
}
|
|
})
|
|
|
|
// Cleanup function
|
|
const cleanupAudio = () => {
|
|
if (audioPlayer.value?.currentBlobUrl) {
|
|
URL.revokeObjectURL(audioPlayer.value.currentBlobUrl)
|
|
audioPlayer.value.currentBlobUrl = null
|
|
}
|
|
}
|
|
|
|
// Handle mobile browser UI bars
|
|
const handleViewportChange = () => {
|
|
if (!process.client) return
|
|
|
|
const vh = window.innerHeight
|
|
const dvh = window.visualViewport ? window.visualViewport.height : vh
|
|
const diff = vh - dvh
|
|
|
|
const playerControls = document.querySelector('.player-controls')
|
|
if (playerControls) {
|
|
if (diff > 50) {
|
|
// Browser bars are visible, move controls up
|
|
playerControls.style.setProperty('--browser-bar-height', `${diff}px`)
|
|
playerControls.classList.add('browser-bars-visible')
|
|
} else {
|
|
// Browser bars hidden, controls to bottom
|
|
playerControls.style.setProperty('--browser-bar-height', '0px')
|
|
playerControls.classList.remove('browser-bars-visible')
|
|
}
|
|
}
|
|
}
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
loadTracks()
|
|
if (process.client) {
|
|
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
|
|
|
|
// Handle mobile browser UI changes
|
|
handleViewportChange()
|
|
window.addEventListener('resize', handleViewportChange)
|
|
if (window.visualViewport) {
|
|
window.visualViewport.addEventListener('resize', handleViewportChange)
|
|
}
|
|
|
|
// Cleanup when page unloads
|
|
window.addEventListener('beforeunload', cleanupAudio)
|
|
window.addEventListener('pagehide', cleanupAudio)
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
cleanupAudio()
|
|
if (process.client) {
|
|
window.removeEventListener('beforeunload', cleanupAudio)
|
|
window.removeEventListener('pagehide', cleanupAudio)
|
|
window.removeEventListener('resize', handleViewportChange)
|
|
if (window.visualViewport) {
|
|
window.visualViewport.removeEventListener('resize', handleViewportChange)
|
|
}
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Page-specific styles */
|
|
</style>
|
|
|