Initial commit: RepoDructor music player

- Nuxt 3 app with glassmorphism design
- Music streaming from local files
- Player controls with shuffle/repeat
- PWA support
- Responsive design for mobile
- Configured for proxy deployment
This commit is contained in:
2025-08-03 17:14:05 -06:00
commit 2f90d92ad9
14 changed files with 16786 additions and 0 deletions

90
.gitignore vendored Normal file
View File

@@ -0,0 +1,90 @@
# Nuxt.js
.output
.nuxt
.nitro
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# ESLint cache
.eslintcache
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Environment variables
.env
.env.local
.env.production
.env.development
# Logs
logs
*.log
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Build outputs
dist/
.output/
.nuxt/
# Local music files (keep them out of git)
music/
*.mp3
*.wav
*.flac
*.m4a
*.ogg
*.aac
# Temporary files
.tmp/
*.tmp
# Cache files
.cache/
.parcel-cache/
# PWA files
sw.js
workbox-*.js

95
README.md Normal file
View File

@@ -0,0 +1,95 @@
# 🎵 RepoDructor Music Player
A beautiful glassmorphism music player webapp built with Nuxt.js for local network use.
## Features
- 🎨 **Glassmorphism UI** with aurora effects and light orbs
- 🌓 **Dark/Light mode** toggle
- 🔀 **True random shuffle** using Fisher-Yates algorithm
- 🔁 **Repeat modes**: None, All, Single track
- 📱 **Responsive design** for mobile and desktop
- 🎧 **Audio streaming** with range request support
- 🎵 **Multiple audio formats** support (MP3, WAV, FLAC, M4A, OGG, AAC)
## Setup
1. **Install dependencies:**
```bash
npm install
```
2. **Add your music:**
- Place your audio files in the `/music` directory
- Supported formats: `.mp3`, `.wav`, `.flac`, `.m4a`, `.ogg`, `.aac`
- You can organize files in subfolders
3. **Start the development server:**
```bash
npm run dev
```
4. **Access the player:**
- Open http://localhost:3000 in your browser
- Other devices on your network can access it via your IP address
## Usage
### Controls
- **Play/Pause**: Click the main play button or any track
- **Next/Previous**: Use the navigation buttons
- **Shuffle**: Click the shuffle button (🔀) to enable random playback
- **Repeat**: Click the repeat button to cycle through modes:
- 🔁 No repeat
- 🔂 Repeat all
- 🔂 Repeat single track
- **Volume**: Adjust using the volume slider
- **Seek**: Click anywhere on the progress bar
- **Theme**: Toggle between light and dark modes
### Random Algorithm
The shuffle feature uses the Fisher-Yates algorithm for truly random playback, ensuring:
- No bias towards certain tracks
- Equal probability for all songs
- No repetition until the entire playlist is played
- Genuine randomness regardless of track popularity
## Production
To build for production:
```bash
npm run build
npm run preview
```
## File Structure
```
├── assets/css/ # Global styles and glassmorphism effects
├── components/ # Vue components (if needed)
├── layouts/ # App layouts
├── music/ # 🎵 PUT YOUR MUSIC FILES HERE
├── pages/ # App pages (main player)
├── server/api/music/ # API endpoints for music serving
├── nuxt.config.ts # Nuxt configuration
└── package.json # Dependencies
```
## Technical Details
- **Framework**: Nuxt.js 3 with Vue.js
- **Styling**: Pure CSS with CSS custom properties
- **Audio**: Native HTML5 audio with streaming support
- **State**: Vue Composition API with localStorage persistence
- **Effects**: CSS animations and glassmorphism
- **Network**: Designed for local network access
## Browser Support
Works in all modern browsers that support:
- HTML5 Audio API
- CSS Backdrop Filter
- ES6+ JavaScript features
Enjoy your music! 🎶

397
assets/css/main.css Normal file
View File

@@ -0,0 +1,397 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
/* Light mode variables */
--bg-primary: #f8fafc;
--bg-secondary: rgba(255, 255, 255, 0.8);
--bg-glass: rgba(255, 255, 255, 0.25);
--text-primary: #1e293b;
--text-secondary: #64748b;
--accent-primary: #3b82f6;
--accent-secondary: #8b5cf6;
--border-glass: rgba(255, 255, 255, 0.18);
--shadow-glass: 0 8px 32px rgba(31, 38, 135, 0.37);
--aurora-1: #ff6b6b;
--aurora-2: #4ecdc4;
--aurora-3: #45b7d1;
--aurora-4: #f9ca24;
--aurora-5: #6c5ce7;
}
[data-theme="dark"] {
/* Dark mode variables */
--bg-primary: #0f172a;
--bg-secondary: rgba(15, 23, 42, 0.8);
--bg-glass: rgba(15, 23, 42, 0.3);
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--accent-primary: #60a5fa;
--accent-secondary: #a78bfa;
--border-glass: rgba(255, 255, 255, 0.125);
--shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.3);
--aurora-1: #ff5757;
--aurora-2: #5ac8c8;
--aurora-3: #4fb3d9;
--aurora-4: #feca57;
--aurora-5: #6a5acd;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
overflow-x: hidden;
min-height: 100vh;
}
/* Glassmorphism effect */
.glass {
background: var(--bg-glass);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-glass);
border-radius: 20px;
box-shadow: var(--shadow-glass);
}
.glass-strong {
background: var(--bg-secondary);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid var(--border-glass);
border-radius: 20px;
box-shadow: var(--shadow-glass);
}
/* Aurora background animation */
.aurora-bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
overflow: hidden;
}
.aurora-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
animation: float 8s ease-in-out infinite;
}
.aurora-orb:nth-child(1) {
background: linear-gradient(45deg, var(--aurora-1), var(--aurora-2));
width: 300px;
height: 300px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
.aurora-orb:nth-child(2) {
background: linear-gradient(45deg, var(--aurora-3), var(--aurora-4));
width: 250px;
height: 250px;
top: 60%;
right: 20%;
animation-delay: 2s;
animation-duration: 10s;
}
.aurora-orb:nth-child(3) {
background: linear-gradient(45deg, var(--aurora-5), var(--aurora-1));
width: 200px;
height: 200px;
bottom: 20%;
left: 50%;
animation-delay: 4s;
animation-duration: 12s;
}
.aurora-orb:nth-child(4) {
background: linear-gradient(45deg, var(--aurora-2), var(--aurora-5));
width: 180px;
height: 180px;
top: 30%;
right: 10%;
animation-delay: 6s;
animation-duration: 9s;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(30px, -30px) scale(1.1);
}
66% {
transform: translate(-20px, 20px) scale(0.9);
}
}
/* Button styles */
.btn {
background: var(--bg-glass);
backdrop-filter: blur(20px);
border: 1px solid var(--border-glass);
border-radius: 12px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.3s ease;
padding: 12px 20px;
font-weight: 500;
}
.btn:hover {
background: var(--accent-primary);
color: white;
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.3);
}
.btn-icon {
background: var(--bg-glass);
backdrop-filter: blur(20px);
border: 1px solid var(--border-glass);
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-icon:hover {
background: var(--accent-primary);
transform: scale(1.1);
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.4);
}
/* Music player container */
.music-player {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
padding-bottom: 140px; /* Space for fixed controls */
}
/* Track list styles */
.track-list {
background: var(--bg-glass);
backdrop-filter: blur(20px);
border: 1px solid var(--border-glass);
border-radius: 20px;
padding: 20px;
margin-bottom: 20px;
}
.track-item {
padding: 12px 16px;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid transparent;
}
.track-item:hover {
background: var(--bg-glass);
border-color: var(--border-glass);
transform: translateX(5px);
}
.track-item.active {
background: linear-gradient(45deg, var(--accent-primary), var(--accent-secondary));
color: white;
}
/* Player controls */
.player-controls {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-glass);
backdrop-filter: blur(40px);
border: 1px solid var(--border-glass);
border-radius: 20px;
padding: 20px;
display: flex;
align-items: center;
gap: 20px;
box-shadow: var(--shadow-glass);
}
/* Progress bar */
.progress-bar {
width: 300px;
height: 6px;
background: var(--bg-glass);
border-radius: 3px;
overflow: hidden;
cursor: pointer;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
border-radius: 3px;
transition: width 0.1s ease;
}
/* Volume control */
.volume-control {
display: flex;
align-items: center;
gap: 10px;
}
.volume-slider {
width: 100px;
height: 6px;
background: var(--bg-glass);
border-radius: 3px;
overflow: hidden;
cursor: pointer;
}
/* Responsive design */
@media (max-width: 768px) {
.music-player {
padding: 15px;
padding-bottom: 100px;
}
.player-controls {
position: fixed;
bottom: calc(20px + env(safe-area-inset-bottom));
left: 20px;
right: 20px;
width: auto;
max-width: calc(100vw - 40px);
height: auto;
transform: none;
background: var(--bg-glass);
backdrop-filter: blur(40px);
border: 1px solid var(--border-glass);
border-radius: 16px;
padding: 12px 16px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
box-shadow: var(--shadow-glass);
z-index: 1000;
overflow: hidden;
min-height: 60px;
transition: bottom 0.3s ease;
}
/* Previous button */
.player-controls > button:first-child {
flex-shrink: 0;
width: 36px;
height: 36px;
font-size: 0.9rem;
}
/* Play/Pause button */
.player-controls > button:nth-child(2) {
flex-shrink: 0;
width: 44px;
height: 44px;
font-size: 1.2rem;
}
/* Next button */
.player-controls > button:nth-child(3) {
flex-shrink: 0;
width: 36px;
height: 36px;
font-size: 0.9rem;
}
/* Progress bar */
.progress-bar {
flex: 1;
min-width: 60px;
max-width: 120px;
height: 4px;
margin: 0 8px;
}
/* Time display */
.player-controls > div:nth-child(5) {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--text-secondary);
white-space: nowrap;
min-width: 70px;
text-align: center;
}
/* Volume control */
.volume-control {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
min-width: 80px;
}
.volume-control span {
font-size: 0.8rem;
}
.volume-slider {
width: 50px;
height: 4px;
}
.aurora-orb {
filter: blur(60px);
}
/* Dynamic viewport handling for mobile browser bars */
.player-controls.browser-bars-visible {
bottom: calc(20px + env(safe-area-inset-bottom) + var(--browser-bar-height, 0px));
}
/* Support for different viewport units */
@supports (height: 100dvh) {
.player-controls {
bottom: calc(100dvh - 100vh + 20px + env(safe-area-inset-bottom));
}
}
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-glass);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--accent-primary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-secondary);
}

18
layouts/default.vue Normal file
View File

@@ -0,0 +1,18 @@
<template>
<div>
<slot />
</div>
</template>
<script setup>
useHead({
title: 'RepoDructor - Music Player',
meta: [
{ name: 'description', content: 'A beautiful glassmorphism music player for your local network' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
})
</script>

51
nuxt-ssl/cert-key.pem Normal file
View File

@@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----

View File

30
nuxt-ssl/cert.pem Normal file
View File

@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFKjCCAxKgAwIBAgIUQVthLCjPuhepwh+Rowd4iDvIIbAwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTAyMDcxOTEyNDBaFw0zNTAy
MDUxOTEyNDBaMA8xDTALBgNVBAMMBHo1OTAwggIiMA0GCSqGSIb3DQEBAQUAA4IC
DwAwggIKAoICAQC9xEkAC91G37mZdOFRJr0OqikFBZEpWHjBYX06bCbHDSlEpqDq
6XlexJYdRXJKw7YSwtWG9Sug7Uhxe+K7xLLFHEoBTZoFzvvHcYOWeaPwAKQDtqVR
R3m61S4caDYiAAhC/XCFlu0OrZnkU3mndouZKOylpOn3GIu+xuIy4V4IsmgG52qr
XCK2VCqpmMNPcFgb2yEdyFnGmymZGhE8Btg8QjQKWKQi/GpBVtDecYeXpQjr+Rdh
KHcZguJlAz4UYnfwc8Jg6y46YYDU+vllihK4Fk061qRzqFYQQFXmuuPKq5WAx21N
dMke1/s43UG/0WT3PBISq8A1+T3CqHU7YYORyjuy/JDWwROgNrcG9exyHo32eVUj
R7M9lOcsxiyyXDdlpZQPICJgnhW6KefXpRStHfPQG/HsmIrsj4ecqxrSndIk1B4M
yzhNdGApIWR+JO1L0wVgS6LPSwGIgNcSHamdGHH2nwaKdDPQQtGZmmijvQmAiw2T
CbrmzrJihbQ3qrEMV5Sq8sbe6f+afSoxdzStfD65vtiZV5p9U83iKHvO6BjBohfD
Lu23pRkiXRqmp/V2EnvLEvIA6p4+u4YGt2JyabQu84FO0s4hSuButbGcv3f/fUmI
JEI+mFuIWQdEfmA/6QHd6YAoSRjqIcWEmmzQMwN935nkFnydKL9aLXojBQIDAQAB
o0gwRjAhBgNVHREEGjAYghB6NTkwLmludGVybm8uY29thwTAqFeHMAwGA1UdDwQF
AwMH+YAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDQYJKoZIhvcNAQELBQADggIBAKjF
jRa12/g8vSlrJsCvEDwKqMxhe0fbtjNEdng0SsTk+naJq0ZZQR9GCJmTUDfs1dH0
WcPdzYyFh10QlQtB3P8iMagKCWPmcN3q+Jf/z8fsRX4VSEBESQgOVOWhBOPlhZaj
cIN59hxzxc/z0w0oS/D341Vpg7Mw35quUdWfXAqXzknGnM+6jQ398lnOCljAj5Mg
nT4J1bt9DTyl3tANvw/kRmr5ey1dVKSu92OxowmrXdINVHbBgTJyYGf40eBTf2Ur
YyNjPGNfF3d1iovIAFwFp2sBhjLzD4NwfIgJZheHjrmvXeIOxFiJwdqJSO4a8Z1H
aMmbNU2wjRWSb1LJXbX9q0OcF2gAu0eSPr1RNLPezDdP0sfMH6DDAZ/gtFz04jwB
+esV8Elm+LShsKtbs2UXc1qRNdp2I7+dA8Sut34C7SEnoAlhtQduy7T/czpPX7X+
lAg1YEveV6d7njRvgjSwek6f4M9dr4kNFV4b1IztRPoZJEQKz3RCr+90KkwXO2cX
5aw3AOpJ+oP6azWxuINQb9W2NIzbKPBFb0ekk1jbMz1CeN6uNqh0zE2w44LlsIsF
tCcH3oIqi5lbu5aEZcrHnZ9OPEVZy7gHwynqoLgOZdyUSxKJ/hylUvDyxsNBQea4
HTKy1XSLHbSuBqdWcvRjcNr73Teb9Tg2aePPgJII
-----END CERTIFICATE-----

View File

80
nuxt.config.ts Normal file
View File

@@ -0,0 +1,80 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
// @ts-check
import { defineNuxtConfig } from 'nuxt/config'
export default defineNuxtConfig({
compatibilityDate: '2025-08-02',
devtools: { enabled: true },
devServer: {
host: '0.0.0.0',
port: 3000,
https: false
},
vite: {
server: {
hmr: {
port: 3000,
host: 'musica.nucleoriofrio.com',
protocol: 'wss'
}
}
},
modules: ['@vueuse/nuxt', '@vite-pwa/nuxt'],
css: ['~/assets/css/main.css'],
nitro: {
experimental: {
wasm: true
},
devProxy: {
'/api': {
target: 'https://musica.nucleoriofrio.com',
changeOrigin: true
}
}
},
runtimeConfig: {
public: {
musicPath: '/music',
baseURL: process.env.NODE_ENV === 'development' ? 'https://musica.nucleoriofrio.com' : ''
}
},
pwa: {
registerType: 'autoUpdate',
workbox: {
navigateFallback: '/',
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
},
client: {
installPrompt: true
},
manifest: {
name: 'RepoDructor Music Player',
short_name: 'RepoDructor',
description: 'A beautiful glassmorphism music player for your local network',
theme_color: '#3b82f6',
background_color: '#0f172a',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{
src: 'icon.svg',
sizes: '192x192',
type: 'image/svg+xml'
},
{
src: 'icon.svg',
sizes: '512x512',
type: 'image/svg+xml'
},
{
src: 'icon.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'any maskable'
}
]
}
}
})

15513
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "repodructor-music-player",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"start": "node .output/server/index.mjs"
},
"devDependencies": {
"@nuxt/devtools": "latest",
"nuxt": "^3.8.0"
},
"dependencies": {
"@vite-pwa/nuxt": "^1.0.4",
"@vueuse/core": "^10.5.0",
"@vueuse/nuxt": "^10.5.0"
}
}

466
pages/index.vue Normal file
View File

@@ -0,0 +1,466 @@
<template>
<div class="music-player">
<!-- Aurora background -->
<div class="aurora-bg">
<div class="aurora-orb"></div>
<div class="aurora-orb"></div>
<div class="aurora-orb"></div>
<div class="aurora-orb"></div>
</div>
<!-- Header -->
<header class="glass-strong" style="padding: 20px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="font-size: 2rem; font-weight: 700; background: linear-gradient(45deg, var(--accent-primary), var(--accent-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">
🎵 RepoDructor
</h1>
<div style="display: flex; gap: 10px; align-items: center;">
<!-- Theme toggle -->
<button @click="toggleTheme" class="btn-icon" title="Toggle theme">
<span v-if="isDark"></span>
<span v-else>🌙</span>
</button>
<!-- Shuffle -->
<button @click="toggleShuffle" class="btn-icon" :class="{ 'active': isShuffled }" title="Shuffle">
🔀
</button>
<!-- Repeat modes -->
<button @click="cycleRepeat" class="btn-icon" title="Repeat mode">
<span v-if="repeatMode === 'none'">🔁</span>
<span v-else-if="repeatMode === 'all'">🔂</span>
<span v-else>🔂</span>
</button>
</div>
</header>
<!-- Now Playing -->
<div v-if="currentTrack" class="glass" style="padding: 20px; margin-bottom: 20px; text-align: center;">
<h2 style="font-size: 1.5rem; margin-bottom: 10px;">Now Playing</h2>
<p style="font-size: 1.2rem; font-weight: 600; color: var(--accent-primary);">
{{ currentTrack.name }}
</p>
<p style="color: var(--text-secondary); margin-top: 5px;">
Duration: {{ formatTime(currentTrack.duration || 0) }}
</p>
</div>
<!-- Track List -->
<div class="track-list">
<h3 style="margin-bottom: 15px; font-size: 1.3rem;">Your Music Library</h3>
<div v-if="loading" style="text-align: center; padding: 40px;">
<p>Loading your music...</p>
</div>
<div v-else-if="tracks.length === 0" style="text-align: center; padding: 40px;">
<p>No music files found. Add some music files to the /music folder!</p>
</div>
<div v-else>
<div
v-for="(track, index) in displayTracks"
:key="track.name"
@click="playTrack(track, index)"
class="track-item"
:class="{ active: currentTrack?.name === track.name }"
style="display: flex; justify-content: space-between; align-items: center;"
>
<div>
<p style="font-weight: 500;">{{ track.name }}</p>
<p style="font-size: 0.9rem; color: var(--text-secondary);">
{{ formatTime(track.duration || 0) }}
</p>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<span v-if="currentTrack?.name === track.name && !isPlaying"></span>
<span v-else-if="currentTrack?.name === track.name && isPlaying"></span>
<span v-else>🎵</span>
</div>
</div>
</div>
</div>
<!-- Player Controls -->
<div class="player-controls" v-if="currentTrack">
<!-- Previous -->
<button @click="previousTrack" class="btn-icon" title="Previous">
</button>
<!-- Play/Pause -->
<button @click="togglePlay" class="btn-icon" style="width: 60px; height: 60px; font-size: 1.5rem;" title="Play/Pause">
<span v-if="isPlaying"></span>
<span v-else></span>
</button>
<!-- Next -->
<button @click="nextTrack" class="btn-icon" title="Next">
</button>
<!-- Progress Bar -->
<div class="progress-bar" @click="seekTo" ref="progressBar">
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
</div>
<!-- Time -->
<div style="min-width: 100px; text-align: center; font-size: 0.9rem;">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<!-- Volume -->
<div class="volume-control">
<span>🔊</span>
<div class="volume-slider" @click="setVolume" ref="volumeSlider">
<div class="progress-fill" :style="{ width: volume * 100 + '%' }"></div>
</div>
</div>
</div>
<!-- Audio Element -->
<audio
ref="audioPlayer"
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onTrackEnded"
@canplay="onCanPlay"
@error="onAudioError"
></audio>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
// 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)
// Theme
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)
const progressBar = ref(null)
const volumeSlider = ref(null)
// Computed
const progressPercent = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
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) => {
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', () => {
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', () => {
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 toggleShuffle = () => {
isShuffled.value = !isShuffled.value
if (isShuffled.value) {
generateShuffledIndices()
}
}
const cycleRepeat = () => {
const modes = ['none', 'all', 'one']
const currentIndex = modes.indexOf(repeatMode.value)
repeatMode.value = modes[(currentIndex + 1) % modes.length]
}
const toggleTheme = () => {
isDark.value = !isDark.value
}
const formatTime = (seconds) => {
if (!seconds || isNaN(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
const seekTo = (event) => {
if (!audioPlayer.value || !progressBar.value) return
const rect = progressBar.value.getBoundingClientRect()
const percent = (event.clientX - rect.left) / rect.width
const newTime = percent * duration.value
audioPlayer.value.currentTime = newTime
}
const setVolume = (event) => {
if (!volumeSlider.value) return
const rect = volumeSlider.value.getBoundingClientRect()
const percent = (event.clientX - rect.left) / rect.width
volume.value = Math.max(0, Math.min(1, percent))
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>
.active {
background: linear-gradient(45deg, var(--accent-primary), var(--accent-secondary)) !important;
color: white !important;
}
.btn-icon.active {
background: var(--accent-primary) !important;
color: white !important;
}
</style>

1
public/favicon.ico Normal file
View File

@@ -0,0 +1 @@
🎵

23
public/icon.svg Normal file
View File

@@ -0,0 +1,23 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="256" cy="256" r="240" fill="url(#gradient)" />
<!-- Glass effect -->
<circle cx="256" cy="256" r="240" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2" />
<circle cx="256" cy="200" r="80" fill="rgba(255,255,255,0.1)" />
<!-- Music note -->
<path d="M200 180 L200 350 C200 370 220 390 240 390 C260 390 280 370 280 350 C280 330 260 310 240 310 C220 310 200 320 200 340 L200 200 L320 160 L320 280 C320 300 340 320 360 320 C380 320 400 300 400 280 C400 260 380 240 360 240 C340 240 320 250 320 270 L320 140 Z" fill="white" />
<!-- Additional decorative elements -->
<circle cx="150" cy="150" r="20" fill="rgba(255,255,255,0.2)" />
<circle cx="380" cy="380" r="15" fill="rgba(255,255,255,0.2)" />
<circle cx="100" cy="350" r="12" fill="rgba(255,255,255,0.15)" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB