feat(pwa-offline): Pinia store + IndexedDB; contexto para cache/eliminación; toasts; compatibilidad PWA offline
- Agrega @pinia/nuxt, idb y store central (stores/music.ts) - Cacheo manual desde menú contextual y borrado (TrackContextMenu) - Ícono verde para canciones cacheadas, sin auto-cache al reproducir - Toasts de feedback (stores/toast.ts, ToastContainer) - Fallback offline de listado a IndexedDB; fix MUSIC_DIR absoluto en preview/prod - Ajustes PWA: navigateFallback '/', devOptions, workbox condicional - Estilos y animación del context menu (tema light/dark, blur fuerte) - Correcciones de sintaxis y posicionamiento exacto al cursor
This commit is contained in:
BIN
Untitled.png
BIN
Untitled.png
Binary file not shown.
|
Before Width: | Height: | Size: 229 KiB |
82
components/ToastContainer.client.vue
Normal file
82
components/ToastContainer.client.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="toast-container">
|
||||
<transition-group name="toast" tag="div">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
class="toast glass"
|
||||
:class="t.type"
|
||||
@click="dismiss(t.id)"
|
||||
>
|
||||
<div class="dot" />
|
||||
<span class="msg">{{ t.message }}</span>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useToastStore } from '~/stores/toast'
|
||||
|
||||
const toast = useToastStore()
|
||||
const { toasts } = storeToRefs(toast)
|
||||
const dismiss = (id: number) => toast.remove(id)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-enter-active, .toast-leave-active {
|
||||
transition: all 250ms ease;
|
||||
}
|
||||
.toast-enter-from, .toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
min-width: 240px;
|
||||
max-width: 360px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(17, 24, 39, 0.6);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.glass { /* keep naming consistent with app */
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.toast .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 9999px;
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
.toast.success .dot { background: #22c55e; }
|
||||
.toast.error .dot { background: #ef4444; }
|
||||
.toast.info .dot { background: #60a5fa; }
|
||||
|
||||
.msg {
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
153
components/TrackContextMenu.client.vue
Normal file
153
components/TrackContextMenu.client.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<transition name="ctx">
|
||||
<div
|
||||
v-if="visible && track"
|
||||
class="context-menu glass"
|
||||
:style="menuStyle"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<div class="menu-header">
|
||||
<span class="title" :title="track.name">{{ track.name }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isCached"
|
||||
class="menu-item"
|
||||
@click.stop="onCache"
|
||||
>
|
||||
<Download :size="18" />
|
||||
<span>Guardar offline</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="menu-item danger"
|
||||
@click.stop="onDelete"
|
||||
>
|
||||
<Trash2 :size="18" />
|
||||
<span>Eliminar de memoria</span>
|
||||
</button>
|
||||
<button class="menu-item" @click.stop="$emit('close')">
|
||||
<X :size="18" />
|
||||
<span>Cerrar</span>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { Download, Trash2, X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
x: { type: Number, default: 0 },
|
||||
y: { type: Number, default: 0 },
|
||||
track: { type: Object, default: null },
|
||||
isCached: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'cache', 'delete'])
|
||||
|
||||
const menuStyle = computed(() => {
|
||||
const offset = 0 // show menu as close as possible
|
||||
const padding = 8
|
||||
const width = 260
|
||||
const height = 140
|
||||
let left = props.x + offset
|
||||
let top = props.y + offset
|
||||
if (typeof window !== 'undefined') {
|
||||
if (left + width + padding > window.innerWidth) {
|
||||
left = Math.max(padding, window.innerWidth - width - padding)
|
||||
}
|
||||
if (top + height + padding > window.innerHeight) {
|
||||
top = Math.max(padding, window.innerHeight - height - padding)
|
||||
}
|
||||
}
|
||||
return { left: left + 'px', top: top + 'px' }
|
||||
})
|
||||
|
||||
const onCache = () => emit('cache', props.track)
|
||||
const onDelete = () => emit('delete', props.track)
|
||||
|
||||
const hide = (e) => {
|
||||
// Close when clicking outside
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', hide)
|
||||
window.addEventListener('scroll', hide, true)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', hide)
|
||||
window.removeEventListener('scroll', hide, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
backdrop-filter: blur(50px);
|
||||
-webkit-backdrop-filter: blur(50px);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-glass);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Appear/disappear animation */
|
||||
.ctx-enter-active, .ctx-leave-active {
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
.ctx-enter-from, .ctx-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-glass);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.06s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: var(--bg-glass);
|
||||
}
|
||||
|
||||
.menu-item:active {
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
.menu-item.danger svg,
|
||||
.menu-item.danger span {
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
<TrackContextMenu
|
||||
:visible="showMenu"
|
||||
:x="menuX"
|
||||
:y="menuY"
|
||||
:track="menuTrack"
|
||||
:is-cached="menuTrack ? isTrackCached(menuTrack) : false"
|
||||
@close="closeMenu"
|
||||
@cache="cacheSelected"
|
||||
@delete="deleteSelected"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Music } from 'lucide-vue-next'
|
||||
import TrackListItem from './TrackListItem.client.vue'
|
||||
import { useMusicStore } from '~/stores/music'
|
||||
import TrackContextMenu from './TrackContextMenu.client.vue'
|
||||
import { useToastStore } from '~/stores/toast'
|
||||
|
||||
const props = defineProps({
|
||||
tracks: {
|
||||
@@ -78,6 +94,43 @@ const emit = defineEmits(['track-selected'])
|
||||
const handleTrackClick = (track, index) => {
|
||||
emit('track-selected', { track, index })
|
||||
}
|
||||
|
||||
const musicStore = useMusicStore()
|
||||
const toast = useToastStore()
|
||||
const isTrackCached = (track) => musicStore.isCached(track.name)
|
||||
|
||||
// Context menu state and handlers
|
||||
const showMenu = ref(false)
|
||||
const menuX = ref(0)
|
||||
const menuY = ref(0)
|
||||
const menuTrack = ref(null)
|
||||
|
||||
const handleContextFromItem = ({ track, x, y }) => {
|
||||
menuTrack.value = track
|
||||
menuX.value = x
|
||||
menuY.value = y
|
||||
showMenu.value = true
|
||||
}
|
||||
|
||||
const closeMenu = () => { showMenu.value = false }
|
||||
|
||||
const cacheSelected = async () => {
|
||||
if (!menuTrack.value) return
|
||||
const ok = await musicStore.cacheByName(menuTrack.value.name, menuTrack.value.duration)
|
||||
if (ok) {
|
||||
toast.success(`Guardado offline: ${menuTrack.value.name}`)
|
||||
} else {
|
||||
toast.error(`No se pudo guardar: ${menuTrack.value.name}`)
|
||||
}
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const deleteSelected = async () => {
|
||||
if (!menuTrack.value) return
|
||||
await musicStore.deleteCachedTrack(menuTrack.value.name)
|
||||
toast.info(`Eliminado de memoria: ${menuTrack.value.name}`)
|
||||
closeMenu()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
@click="handleClick"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
@contextmenu.prevent="handleContext"
|
||||
>
|
||||
<div class="track-info">
|
||||
<p class="track-name">{{ track.name }}</p>
|
||||
@@ -25,7 +26,7 @@
|
||||
<AlertCircle v-if="hasError" class="icon error" :size="18" />
|
||||
<Pause v-else-if="isActive && !isPlaying" class="icon paused" :size="18" />
|
||||
<Play v-else-if="isActive && isPlaying" class="icon playing" :size="18" />
|
||||
<Music v-else class="icon idle" :size="18" />
|
||||
<Music v-else :class="['icon', 'idle', { cached: isCached }]" :size="18" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,10 +61,14 @@ const props = defineProps({
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isCached: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
const emit = defineEmits(['click', 'context'])
|
||||
|
||||
const isHovered = ref(false)
|
||||
|
||||
@@ -71,6 +76,10 @@ const handleClick = () => {
|
||||
emit('click', props.track)
|
||||
}
|
||||
|
||||
const handleContext = (event) => {
|
||||
emit('context', { track: props.track, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
if (!seconds || isNaN(seconds)) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
@@ -215,6 +224,10 @@ const formatTime = (seconds) => {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.icon.idle.cached {
|
||||
color: #22c55e; /* green-500 */
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@@ -284,4 +297,4 @@ const formatTime = (seconds) => {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ToastContainer from '~/components/ToastContainer.client.vue'
|
||||
useHead({
|
||||
title: 'RepoDructor - Music Player',
|
||||
meta: [
|
||||
@@ -24,4 +26,4 @@ useHead({
|
||||
{ rel: 'manifest', href: '/manifest.webmanifest' }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
68
package-lock.json
generated
68
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
<style scoped>
|
||||
/* Page-specific styles */
|
||||
</style>
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
132
stores/music.ts
Normal file
132
stores/music.ts
Normal file
@@ -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<IDBPDatabase> | 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<string>(),
|
||||
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<Track[]> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
const tx = db.transaction('tracks', 'readonly')
|
||||
const store = tx.objectStore('tracks')
|
||||
const all = (await store.getAll()) as DBTrack[]
|
||||
const names = all.map(t => t.name)
|
||||
this.cachedNames = new Set(names)
|
||||
const list: Track[] = all.map(t => ({ name: t.name, duration: t.duration }))
|
||||
return list
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
async getCachedBlob(name: string): Promise<Blob | null> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
const entry = (await db.get('tracks', name)) as DBTrack | undefined
|
||||
if (entry && entry.blob) {
|
||||
this.cachedNames.add(name)
|
||||
return entry.blob
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
async saveTrackBlob(name: string, blob: Blob, duration?: number) {
|
||||
try {
|
||||
const db = await getDB()
|
||||
const payload: DBTrack = { name, blob, type: blob.type, duration }
|
||||
await db.put('tracks', payload)
|
||||
this.cachedNames.add(name)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCachedTrack(name: string) {
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.delete('tracks', name)
|
||||
this.cachedNames.delete(name)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
// Fetch from API and store in IndexedDB
|
||||
async cacheByName(name: string, duration?: number): Promise<boolean> {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(name)
|
||||
const response = await fetch(`/api/music/${encodedName}`)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const blob = await response.blob()
|
||||
await this.saveTrackBlob(name, blob, duration)
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
42
stores/toast.ts
Normal file
42
stores/toast.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info'
|
||||
|
||||
export type Toast = {
|
||||
id: number
|
||||
type: ToastType
|
||||
message: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
let counter = 1
|
||||
|
||||
export const useToastStore = defineStore('toast', {
|
||||
state: () => ({
|
||||
toasts: [] as Toast[]
|
||||
}),
|
||||
actions: {
|
||||
push(message: string, type: ToastType = 'info', timeout = 2500) {
|
||||
const id = counter++
|
||||
const toast: Toast = { id, type, message, timeout }
|
||||
this.toasts.push(toast)
|
||||
if (timeout && timeout > 0) {
|
||||
setTimeout(() => this.remove(id), timeout)
|
||||
}
|
||||
return id
|
||||
},
|
||||
success(message: string, timeout = 2500) {
|
||||
return this.push(message, 'success', timeout)
|
||||
},
|
||||
error(message: string, timeout = 3000) {
|
||||
return this.push(message, 'error', timeout)
|
||||
},
|
||||
info(message: string, timeout = 2500) {
|
||||
return this.push(message, 'info', timeout)
|
||||
},
|
||||
remove(id: number) {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user