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:
90
.gitignore
vendored
Normal file
90
.gitignore
vendored
Normal 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
95
README.md
Normal 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
397
assets/css/main.css
Normal 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
18
layouts/default.vue
Normal 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
51
nuxt-ssl/cert-key.pem
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
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
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
0
nuxt-ssl/cert-key.pem:Zone.Identifier
Normal file
0
nuxt-ssl/cert-key.pem:Zone.Identifier
Normal file
30
nuxt-ssl/cert.pem
Normal file
30
nuxt-ssl/cert.pem
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
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
|
||||||
|
-----END CERTIFICATE-----
|
||||||
0
nuxt-ssl/cert.pem:Zone.Identifier
Normal file
0
nuxt-ssl/cert.pem:Zone.Identifier
Normal file
80
nuxt.config.ts
Normal file
80
nuxt.config.ts
Normal 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
15513
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal 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
466
pages/index.vue
Normal 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
1
public/favicon.ico
Normal file
@@ -0,0 +1 @@
|
|||||||
|
🎵
|
||||||
23
public/icon.svg
Normal file
23
public/icon.svg
Normal 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 |
Reference in New Issue
Block a user