feat(pwa-offline): Pinia store + IndexedDB; contexto para cache/eliminación; toasts; compatibilidad PWA offline
All checks were successful
build-and-deploy / build (push) Successful in 40s
build-and-deploy / deploy (push) Successful in 4s

- 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:
2025-08-10 02:51:38 -06:00
parent ba70e0d280
commit 81330de97e
14 changed files with 613 additions and 39 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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>

View File

@@ -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'

View File

@@ -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
View 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
View 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)
}
}
})