diff --git a/Untitled.png b/Untitled.png
deleted file mode 100644
index f0d36ad..0000000
Binary files a/Untitled.png and /dev/null differ
diff --git a/components/ToastContainer.client.vue b/components/ToastContainer.client.vue
new file mode 100644
index 0000000..5596de0
--- /dev/null
+++ b/components/ToastContainer.client.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
diff --git a/components/TrackContextMenu.client.vue b/components/TrackContextMenu.client.vue
new file mode 100644
index 0000000..82ae632
--- /dev/null
+++ b/components/TrackContextMenu.client.vue
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/TrackList.client.vue b/components/TrackList.client.vue
index 499085a..ff3813e 100644
--- a/components/TrackList.client.vue
+++ b/components/TrackList.client.vue
@@ -30,17 +30,33 @@
:is-playing="currentTrack?.name === track.name && isPlaying"
:is-loading="loadingTrack === track.name"
:has-error="failedTracks.has(track.name)"
+ :is-cached="isTrackCached(track)"
@click="handleTrackClick(track, index)"
+ @context="handleContextFromItem"
class="track-item-wrapper animate-fade-in-up"
/>
+
\ No newline at end of file
+
diff --git a/layouts/default.vue b/layouts/default.vue
index 64867db..2372bbd 100644
--- a/layouts/default.vue
+++ b/layouts/default.vue
@@ -1,10 +1,12 @@
+
\ No newline at end of file
+
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 6b76f22..f1de904 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -1,7 +1,14 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import { defineNuxtConfig } from 'nuxt/config'
+import { resolve, isAbsolute } from 'path'
+
+// Compute absolute music directory at build-time to avoid .output cwd issues in preview/prod
+const musicDirEnv = process.env.MUSIC_DIR || './music'
+const musicDirAbs = isAbsolute(musicDirEnv) ? musicDirEnv : resolve(process.cwd(), musicDirEnv)
export default defineNuxtConfig({
+ // Helpers
+ hooks: {},
compatibilityDate: '2025-08-02',
// Disable SSR completely to avoid hydration issues with client-side audio APIs
ssr: false,
@@ -36,29 +43,20 @@ export default defineNuxtConfig({
},
modules: [
- '@vueuse/nuxt',
+ '@vueuse/nuxt',
+ '@pinia/nuxt',
['@vite-pwa/nuxt', {
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'logo.png', 'logo-192.png', 'logo-512.png', 'logo-maskable-512.png', 'icon.svg'],
- workbox: {
+ workbox: process.env.NODE_ENV === 'production' ? {
navigateFallback: '/',
+ cleanupOutdatedCaches: true,
globPatterns: [
'**/*.{js,css,html,ico,png,svg}',
'_nuxt/**/*.{js,css}',
'assets/**/*.{png,jpg,jpeg,svg,gif,webp}'
],
runtimeCaching: [
- {
- urlPattern: /^https?:\/\/.*\/api\/music\/.*/i,
- handler: 'CacheFirst',
- options: {
- cacheName: 'music-cache',
- expiration: {
- maxEntries: 100,
- maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
- }
- }
- },
{
urlPattern: /\.(png|jpg|jpeg|svg|gif|webp)$/,
handler: 'CacheFirst',
@@ -71,17 +69,22 @@ export default defineNuxtConfig({
}
}
]
+ } : {
+ // Dev SW: configuración mínima y sin patrones problemáticos
+ navigateFallback: '/',
+ globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
+ globIgnores: ['**/_payload.json', '_nuxt/builds/**/*.json'],
},
client: {
installPrompt: true,
periodicSyncForUpdates: 20
},
devOptions: {
- enabled: false,
+ // Permite instalar SW en dev cuando se habilita explícitamente
+ enabled: process.env.PWA_DEV === 'true',
type: 'module',
navigateFallback: '/'
},
- mode: 'development',
manifest: {
name: 'RepoDructor Music Player',
short_name: 'RepoDructor',
@@ -131,12 +134,17 @@ export default defineNuxtConfig({
experimental: {
wasm: true
},
+ prerender: {
+ crawlLinks: true,
+ routes: ['/']
+ },
// Development configuration for proxy
devProxy: process.env.NODE_ENV === 'development' ? {} : undefined
},
// Runtime configuration
runtimeConfig: {
+ musicDirAbs,
public: {
musicPath: process.env.NUXT_PUBLIC_MUSIC_PATH || '/music'
}
diff --git a/package-lock.json b/package-lock.json
index d645d81..6c32f09 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,10 +9,13 @@
"version": "1.0.0",
"hasInstallScript": true,
"dependencies": {
+ "@pinia/nuxt": "^0.11.2",
"@vite-pwa/nuxt": "^1.0.4",
"@vueuse/core": "^10.5.0",
"@vueuse/nuxt": "^10.5.0",
- "lucide-vue-next": "^0.536.0"
+ "idb": "^8.0.3",
+ "lucide-vue-next": "^0.536.0",
+ "pinia": "^3.0.3"
},
"devDependencies": {
"@nuxt/devtools": "latest",
@@ -4202,6 +4205,21 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/@pinia/nuxt": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.2.tgz",
+ "integrity": "sha512-CgvSWpbktxxWBV7ModhAcsExsQZqpPq6vMYEe9DexmmY6959ev8ukL4iFhr/qov2Nb9cQAWd7niFDnaWkN+FHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@nuxt/kit": "^3.9.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "pinia": "^3.0.3"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -8770,9 +8788,9 @@
}
},
"node_modules/idb": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
- "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
+ "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/ieee754": {
@@ -11251,6 +11269,36 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pinia": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
+ "integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^7.7.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.4.4",
+ "vue": "^2.7.0 || ^3.5.11"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pinia/node_modules/@vue/devtools-api": {
+ "version": "7.7.7",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz",
+ "integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-kit": "^7.7.7"
+ }
+ },
"node_modules/pkg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
@@ -14752,6 +14800,12 @@
"workbox-core": "7.3.0"
}
},
+ "node_modules/workbox-background-sync/node_modules/idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "license": "ISC"
+ },
"node_modules/workbox-broadcast-update": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz",
@@ -15090,6 +15144,12 @@
"workbox-core": "7.3.0"
}
},
+ "node_modules/workbox-expiration/node_modules/idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "license": "ISC"
+ },
"node_modules/workbox-google-analytics": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz",
diff --git a/package.json b/package.json
index 07ea84c..4bf8d75 100644
--- a/package.json
+++ b/package.json
@@ -15,9 +15,12 @@
"nuxt": "^3.8.0"
},
"dependencies": {
+ "@pinia/nuxt": "^0.11.2",
"@vite-pwa/nuxt": "^1.0.4",
"@vueuse/core": "^10.5.0",
"@vueuse/nuxt": "^10.5.0",
- "lucide-vue-next": "^0.536.0"
+ "idb": "^8.0.3",
+ "lucide-vue-next": "^0.536.0",
+ "pinia": "^3.0.3"
}
}
diff --git a/pages/index.vue b/pages/index.vue
index 1546412..d8f3497 100644
--- a/pages/index.vue
+++ b/pages/index.vue
@@ -60,6 +60,7 @@ definePageMeta({
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
+import { useMusicStore } from '~/stores/music'
// Import components
import AuroraBackground from '~/components/AuroraBackground.client.vue'
@@ -67,8 +68,11 @@ import MainContainer from '~/components/MainContainer.client.vue'
import TrackList from '~/components/TrackList.client.vue'
import MusicControls from '~/components/MusicControls.client.vue'
+// Store
+const musicStore = useMusicStore()
+
// Reactive state
-const tracks = ref([])
+const tracks = computed(() => musicStore.tracks)
const currentTrack = ref(null)
const currentTrackIndex = ref(0)
const isPlaying = ref(false)
@@ -102,16 +106,23 @@ const displayTracks = computed(() => {
// Methods
const loadTracks = async () => {
try {
- const response = await $fetch('/api/music')
- tracks.value = response.tracks
+ await musicStore.fetchTracks()
if (tracks.value.length > 0) {
generateShuffledIndices()
+ loading.value = false
+ return
}
} catch (error) {
- console.error('Failed to load tracks:', error)
- } finally {
- loading.value = false
+ console.warn('Failed to load tracks from server, will fallback to cache:', error)
}
+ // Fallback to cached tracks when offline or server fails
+ const cached = await musicStore.loadCachedTracks()
+ if (cached.length > 0) {
+ // Use cached list locally by overwriting store tracks for session display
+ musicStore.tracks = cached
+ generateShuffledIndices()
+ }
+ loading.value = false
}
const generateShuffledIndices = () => {
@@ -138,23 +149,36 @@ const playTrack = async (track, index) => {
}
try {
+ // Try IndexedDB cached version first
+ const cachedBlob = await musicStore.getCachedBlob(track.name)
+ if (cachedBlob) {
+ const audioUrl = URL.createObjectURL(cachedBlob)
+ audioPlayer.value.currentBlobUrl = audioUrl
+ audioPlayer.value.src = audioUrl
+ audioPlayer.value.load()
+ audioPlayer.value.addEventListener('canplay', () => {
+ loadingTrack.value = null
+ audioPlayer.value.play()
+ }, { once: true })
+ return
+ }
+
// Fetch and preload entire song into memory
const encodedName = encodeURIComponent(track.name)
const response = await fetch(`/api/music/${encodedName}`)
-
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
-
const blob = await response.blob()
const audioUrl = URL.createObjectURL(blob)
-
+
+ // No auto-cache: solo reproducir, el usuario decide cachear desde menú
// 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
@@ -359,6 +383,8 @@ const handleViewportChange = () => {
// Lifecycle
onMounted(() => {
loadTracks()
+ // Load cached songs metadata
+ musicStore.loadCachedNames()
if (import.meta.client) {
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
@@ -391,4 +417,3 @@ onUnmounted(() => {
-
diff --git a/server/api/music/[filename].get.ts b/server/api/music/[filename].get.ts
index cd3f5ff..83b2ccd 100644
--- a/server/api/music/[filename].get.ts
+++ b/server/api/music/[filename].get.ts
@@ -30,9 +30,9 @@ export default defineEventHandler(async (event) => {
// Determine the music directory path
let musicDir: string
- if (process.env.MUSIC_DIR) {
- // If MUSIC_DIR is set, resolve it (handles both absolute and relative paths)
- musicDir = resolve(process.cwd(), process.env.MUSIC_DIR)
+ if (config.musicDirAbs || process.env.MUSIC_DIR) {
+ // Prefer absolute dir from runtimeConfig; fallback to env (resolved relative to repo root at build time)
+ musicDir = (config.musicDirAbs as string) || resolve(process.cwd(), process.env.MUSIC_DIR!)
} else {
// Fallback to public/music
const defaultPublicPath = config.public?.musicPath || '/music'
diff --git a/server/api/music/index.get.ts b/server/api/music/index.get.ts
index 2fe8a9e..5096130 100644
--- a/server/api/music/index.get.ts
+++ b/server/api/music/index.get.ts
@@ -9,9 +9,10 @@ export default defineEventHandler(async (event) => {
console.log(`[MUSIC API] Music list request from ${realIP}`)
const config = useRuntimeConfig()
+ // Prefer absolute dir computed at build time; fallback to env or public path
const defaultPublicPath = config.public?.musicPath || '/music'
const publicRel = defaultPublicPath.replace(/^\//, '')
- const musicDir = process.env.MUSIC_DIR || join(process.cwd(), 'public', publicRel)
+ const musicDir = config.musicDirAbs || process.env.MUSIC_DIR || join(process.cwd(), 'public', publicRel)
// Check if music directory exists
try {
diff --git a/stores/music.ts b/stores/music.ts
new file mode 100644
index 0000000..563f158
--- /dev/null
+++ b/stores/music.ts
@@ -0,0 +1,132 @@
+import { defineStore } from 'pinia'
+import { openDB, type IDBPDatabase } from 'idb'
+
+type Track = {
+ name: string
+ duration?: number
+}
+
+type DBTrack = {
+ name: string
+ blob: Blob
+ type?: string
+ duration?: number
+}
+
+let dbPromise: Promise | null = null
+
+async function getDB() {
+ if (!dbPromise) {
+ dbPromise = openDB('repodructor-music', 1, {
+ upgrade(db) {
+ if (!db.objectStoreNames.contains('tracks')) {
+ const store = db.createObjectStore('tracks', { keyPath: 'name' })
+ store.createIndex('name', 'name', { unique: true })
+ }
+ }
+ })
+ }
+ return dbPromise
+}
+
+export const useMusicStore = defineStore('music', {
+ state: () => ({
+ tracks: [] as Track[],
+ cachedNames: new Set(),
+ loading: false,
+ error: null as string | null
+ }),
+ getters: {
+ isCached: (state) => (name: string) => state.cachedNames.has(name)
+ },
+ actions: {
+ async fetchTracks() {
+ this.loading = true
+ this.error = null
+ try {
+ const response = await $fetch<{ tracks: Track[] }>('/api/music')
+ this.tracks = response.tracks || []
+ } catch (e: any) {
+ this.error = e?.message || 'Failed to load tracks'
+ } finally {
+ this.loading = false
+ }
+ },
+
+ async loadCachedNames() {
+ try {
+ const db = await getDB()
+ const tx = db.transaction('tracks', 'readonly')
+ const store = tx.objectStore('tracks')
+ const all = await store.getAllKeys()
+ this.cachedNames = new Set((all as string[]) || [])
+ } catch (e) {
+ // ignore
+ }
+ },
+
+ async loadCachedTracks(): Promise