-
-
β¦
+
+
+
+
@@ -97,8 +101,11 @@ import FilmDetail from '@/components/content/FilmDetail.vue'
import SongGrid from '@/components/content/SongGrid.vue'
import SongDetail from '@/components/content/SongDetail.vue'
import ContextLoader from '@/components/content/ContextLoader.vue'
+import PlayerBar from '@/components/player/PlayerBar.vue'
+import { usePlayer } from '@/composables/usePlayer'
const chatStore = useChatStore()
+const { hasTrack } = usePlayer()
const { isDark } = useTheme()
useAI()
diff --git a/packages/app/src/styles/main.css b/packages/app/src/styles/main.css
index 4575b770..b32fb8ff 100644
--- a/packages/app/src/styles/main.css
+++ b/packages/app/src/styles/main.css
@@ -148,6 +148,171 @@ body {
border-radius: 0.75rem;
}
+ /* ===== PATH GLASS β from Archy OnboardingPath (Choose Your Path) ===== */
+ .path-glass-container {
+ background: rgba(0, 0, 0, 0.65);
+ backdrop-filter: blur(40px);
+ -webkit-backdrop-filter: blur(40px);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 24px;
+ box-shadow:
+ 0 20px 60px rgba(0, 0, 0, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.1);
+ overflow-x: hidden;
+ }
+
+ .path-glass-card {
+ background: rgba(0, 0, 0, 0.65);
+ backdrop-filter: blur(40px);
+ -webkit-backdrop-filter: blur(40px);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 24px;
+ box-shadow:
+ 0 20px 60px rgba(0, 0, 0, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.1);
+ overflow-x: hidden;
+ overflow-y: visible;
+ }
+
+ /* Chat bubbles / option cards β path-option-card style */
+ .path-glass-bubble {
+ position: relative;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ box-shadow:
+ 0 8px 24px rgba(0, 0, 0, 0.45),
+ inset 0 1px 0 rgba(255, 255, 255, 0.22);
+ border-radius: 16px;
+ border: none;
+ }
+
+ .path-glass-bubble::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ padding: 2px;
+ background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
+ -webkit-mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ pointer-events: none;
+ }
+
+ .path-glass-bubble-user {
+ position: relative;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ box-shadow:
+ 0 8px 24px rgba(0, 0, 0, 0.45),
+ inset 0 1px 0 rgba(255, 255, 255, 0.25);
+ border-radius: 16px;
+ border: none;
+ }
+
+ .path-glass-bubble-user::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ padding: 2px;
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.2), transparent);
+ -webkit-mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ pointer-events: none;
+ }
+
+ /* Buttons β path-action-button style */
+ .path-glass-button {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 48px;
+ min-height: 48px;
+ padding-block: 0 !important;
+ line-height: 48px;
+ background: rgba(0, 0, 0, 0.25);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ color: rgba(255, 255, 255, 0.96);
+ box-shadow:
+ 0 8px 24px rgba(0, 0, 0, 0.45),
+ inset 0 1px 0 rgba(255, 255, 255, 0.22);
+ border-radius: 16px;
+ border: none;
+ transition: all 0.3s ease;
+ }
+
+ .path-glass-button::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ padding: 2px;
+ background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
+ -webkit-mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ pointer-events: none;
+ }
+
+ .path-glass-button-sm {
+ min-height: 0 !important;
+ height: auto !important;
+ line-height: inherit;
+ padding-block: 0.375rem !important;
+ padding-inline: 0.75rem;
+ }
+
+ .path-glass-button:hover {
+ transform: translateY(-2px);
+ background: rgba(0, 0, 0, 0.35);
+ box-shadow:
+ 0 12px 32px rgba(0, 0, 0, 0.6),
+ inset 0 1px 0 rgba(255, 255, 255, 0.25);
+ }
+
+ .path-glass-button:hover::before {
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
+ }
+
+ /* Play icon / option-card style β no border-radius so rounded-full works for circles */
+ .path-glass-icon {
+ position: relative;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ box-shadow:
+ 0 8px 24px rgba(0, 0, 0, 0.45),
+ inset 0 1px 0 rgba(255, 255, 255, 0.22);
+ border: none;
+ }
+
+ .path-glass-icon::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ padding: 2px;
+ background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
+ -webkit-mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ pointer-events: none;
+ }
+
.nav-tab-active {
position: relative;
background: rgba(0, 0, 0, 0.35) !important;
@@ -251,6 +416,64 @@ body {
.light .nav-tab-active::before {
background: linear-gradient(135deg, rgba(0, 0, 0, 0.08), transparent);
}
+
+ /* Path glass β light mode (lighter variant) */
+ .light .path-glass-container,
+ .light .path-glass-card {
+ background: rgba(255, 255, 255, 0.75);
+ border: 1px solid rgba(0, 0, 0, 0.08);
+ box-shadow:
+ 0 20px 60px rgba(0, 0, 0, 0.08),
+ inset 0 1px 0 rgba(255, 255, 255, 0.9);
+ }
+
+ .light .path-glass-bubble {
+ background: rgba(255, 255, 255, 0.85);
+ box-shadow:
+ 0 8px 24px rgba(0, 0, 0, 0.08),
+ inset 0 1px 0 rgba(255, 255, 255, 0.5);
+ }
+
+ .light .path-glass-bubble::before {
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.6), transparent);
+ }
+
+ .light .path-glass-bubble-user {
+ background: rgba(0, 0, 0, 0.06);
+ box-shadow:
+ 0 8px 24px rgba(0, 0, 0, 0.06),
+ inset 0 1px 0 rgba(255, 255, 255, 0.3);
+ }
+
+ .light .path-glass-bubble-user::before {
+ background: linear-gradient(135deg, rgba(0, 0, 0, 0.08), transparent);
+ }
+
+ .light .path-glass-button {
+ background: rgba(0, 0, 0, 0.85);
+ color: #fafafa;
+ box-shadow:
+ 0 8px 24px rgba(0, 0, 0, 0.15),
+ inset 0 1px 0 rgba(255, 255, 255, 0.2);
+ }
+
+ .light .path-glass-button:hover {
+ background: rgba(0, 0, 0, 0.9);
+ box-shadow:
+ 0 12px 32px rgba(0, 0, 0, 0.2),
+ inset 0 1px 0 rgba(255, 255, 255, 0.25);
+ }
+
+ .light .path-glass-icon {
+ background: rgba(255, 255, 255, 0.85);
+ box-shadow:
+ 0 8px 24px rgba(0, 0, 0, 0.08),
+ inset 0 1px 0 rgba(255, 255, 255, 0.5);
+ }
+
+ .light .path-glass-icon::before {
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.6), transparent);
+ }
}
/* ===== FOCUS STATES β gamepad/keyboard glow ===== */
@@ -354,75 +577,71 @@ input:focus-visible {
50% { opacity: 1; }
}
-/* ===== POSTER CARD β Indeehub-style gradient border ===== */
+/* ===== POSTER CARD β path-glass border ===== */
.poster-card {
- margin: 2px;
- padding: 2px;
border-radius: 0.75rem;
- background: linear-gradient(127deg, var(--color-accent) 0%, var(--color-info) 100%);
- transition: margin 0.2s ease, padding 0.2s ease;
-}
-
-.poster-card:hover {
- margin: 0;
- padding: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ box-shadow:
+ 0 4px 16px rgba(0, 0, 0, 0.25),
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
+ overflow: hidden;
}
.light .poster-card {
- background: linear-gradient(127deg, rgba(247, 147, 26, 0.4) 0%, rgba(59, 130, 246, 0.35) 100%);
+ border-color: rgba(0, 0, 0, 0.08);
+ box-shadow:
+ 0 4px 16px rgba(0, 0, 0, 0.06),
+ inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.poster-card-sm {
- margin: 1px;
- padding: 1px;
border-radius: 0.5rem;
- background: linear-gradient(127deg, var(--color-accent) 0%, var(--color-info) 100%);
- transition: margin 0.2s ease, padding 0.2s ease;
-}
-
-.poster-card-sm:hover {
- margin: 0;
- padding: 2px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ box-shadow:
+ 0 2px 8px rgba(0, 0, 0, 0.2),
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
+ overflow: hidden;
}
.light .poster-card-sm {
- background: linear-gradient(127deg, rgba(247, 147, 26, 0.35) 0%, rgba(59, 130, 246, 0.3) 100%);
+ border-color: rgba(0, 0, 0, 0.08);
+ box-shadow:
+ 0 2px 8px rgba(0, 0, 0, 0.06),
+ inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
-/* Cover card β same as poster-card, for square album art */
+/* Cover card β path-glass border, square album art */
.cover-card {
- margin: 2px;
- padding: 2px;
border-radius: 0.75rem;
- background: linear-gradient(127deg, var(--color-accent) 0%, var(--color-info) 100%);
- transition: margin 0.2s ease, padding 0.2s ease;
-}
-
-.cover-card:hover {
- margin: 0;
- padding: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ box-shadow:
+ 0 4px 16px rgba(0, 0, 0, 0.25),
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
+ overflow: hidden;
}
.light .cover-card {
- background: linear-gradient(127deg, rgba(247, 147, 26, 0.4) 0%, rgba(59, 130, 246, 0.35) 100%);
+ border-color: rgba(0, 0, 0, 0.08);
+ box-shadow:
+ 0 4px 16px rgba(0, 0, 0, 0.06),
+ inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.cover-card-sm {
- margin: 1px;
- padding: 1px;
border-radius: 0.5rem;
- background: linear-gradient(127deg, var(--color-accent) 0%, var(--color-info) 100%);
- transition: margin 0.2s ease, padding 0.2s ease;
-}
-
-.cover-card-sm:hover {
- margin: 0;
- padding: 2px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ box-shadow:
+ 0 2px 8px rgba(0, 0, 0, 0.2),
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
+ overflow: hidden;
}
.light .cover-card-sm {
- background: linear-gradient(127deg, rgba(247, 147, 26, 0.35) 0%, rgba(59, 130, 246, 0.3) 100%);
+ border-color: rgba(0, 0, 0, 0.08);
+ box-shadow:
+ 0 2px 8px rgba(0, 0, 0, 0.06),
+ inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.animate-fade-up {
diff --git a/packages/app/src/utils/wavlakeGenres.ts b/packages/app/src/utils/wavlakeGenres.ts
new file mode 100644
index 00000000..8677a83c
--- /dev/null
+++ b/packages/app/src/utils/wavlakeGenres.ts
@@ -0,0 +1,38 @@
+/** Map common genre names to Wavlake chart genre param (top-level from list.csv) */
+export const WAVLAKE_GENRE_MAP: Record
= {
+ 'math rock': 'alternative',
+ 'indie rock': 'alternative',
+ 'post-rock': 'alternative',
+ 'prog rock': 'rock',
+ 'progressive rock': 'rock',
+ 'rock': 'rock',
+ 'alternative': 'alternative',
+ 'emo': 'alternative',
+ 'post-hardcore': 'alternative',
+ 'hip-hop': 'hip-hop/rap',
+ 'hip hop': 'hip-hop/rap',
+ 'rap': 'hip-hop/rap',
+ 'electronic': 'electronic',
+ 'ambient': 'electronic',
+ 'house': 'dance/edm',
+ 'techno': 'dance/edm',
+ 'jazz': 'jazz',
+ 'blues': 'blues',
+ 'folk': 'singer/songwriter',
+ 'country': 'country',
+ 'classical': 'classical',
+ 'pop': 'pop',
+ 'r&b': 'r&b/soul',
+ 'soul': 'r&b/soul',
+ 'reggae': 'reggae',
+ 'world': 'world',
+}
+
+export function mapToWavlakeGenre(genres: string[]): string | null {
+ for (const g of genres) {
+ const key = g.toLowerCase().trim()
+ const mapped = WAVLAKE_GENRE_MAP[key]
+ if (mapped) return mapped
+ }
+ return null
+}
diff --git a/packages/app/vite-music-search.ts b/packages/app/vite-music-search.ts
new file mode 100644
index 00000000..f8948e5d
--- /dev/null
+++ b/packages/app/vite-music-search.ts
@@ -0,0 +1,221 @@
+import type { Plugin } from 'vite'
+import type { Connect } from 'vite'
+import { loadEnv } from 'vite'
+
+export interface MusicSearchResult {
+ source: string
+ type: 'stream' | 'embed'
+ url: string
+ title?: string
+ artist?: string
+}
+
+function scoreIAResult(doc: { title?: string; creator?: string }, title: string, artist: string): number {
+ const t = (doc.title ?? '').toLowerCase()
+ const c = (Array.isArray(doc.creator) ? doc.creator.join(' ') : (doc.creator ?? '')).toLowerCase()
+ const titleTerms = title.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
+ const artistTerms = artist.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
+ let score = 0
+ for (const term of titleTerms) {
+ if (t.includes(term)) score += 2
+ }
+ for (const term of artistTerms) {
+ if (c.includes(term) || t.includes(term)) score += 2
+ }
+ return score
+}
+
+async function searchInternetArchive(q: string, title?: string, artist?: string): Promise {
+ try {
+ const params = new URLSearchParams({
+ q: `mediatype:audio ${q}`,
+ fl: ['identifier', 'title', 'creator'].join(','),
+ output: 'json',
+ rows: '10',
+ })
+ const res = await fetch(
+ `https://archive.org/advancedsearch.php?${params}`,
+ { headers: { Accept: 'application/json' } },
+ )
+ if (!res.ok) return null
+ const data = (await res.json()) as { response?: { docs?: { identifier: string; title?: string; creator?: string }[] } }
+ const docs = data.response?.docs ?? []
+ if (docs.length === 0) return null
+ const doc =
+ title && artist && docs.length > 1
+ ? docs.reduce((best, d) =>
+ scoreIAResult(d, title, artist) > scoreIAResult(best, title, artist) ? d : best,
+ )
+ : docs[0]
+ if (title && artist && scoreIAResult(doc, title, artist) === 0) {
+ return null
+ }
+ const meta = await fetch(`https://archive.org/metadata/${doc.identifier}`)
+ .then((r) => r.json())
+ .catch(() => null)
+ const files = (meta as { files?: { name: string; format?: string }[] })?.files ?? []
+ const audio = files.find(
+ (f: { name: string; format?: string }) =>
+ ['VBR MP3', 'MP3', 'OGG Vorbis', '128Kbps MP3', 'Flac'].includes((f.format ?? '').toString()) ||
+ /\.(mp3|ogg|m4a|flac)$/i.test(f.name),
+ )
+ if (!audio) return null
+ const streamUrl = `https://archive.org/download/${doc.identifier}/${encodeURIComponent(audio.name)}`
+ return {
+ source: 'internet_archive',
+ type: 'stream',
+ url: streamUrl,
+ title: doc.title,
+ artist: Array.isArray(doc.creator) ? doc.creator[0] : doc.creator,
+ }
+ } catch {
+ return null
+ }
+}
+
+function scoreJamendoTrack(
+ track: { name: string; artist_name: string },
+ title: string,
+ artist: string,
+): number {
+ const t = track.name.toLowerCase()
+ const a = track.artist_name.toLowerCase()
+ const titleTerms = title.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
+ const artistTerms = artist.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
+ let score = 0
+ for (const term of titleTerms) {
+ if (t.includes(term)) score += 2
+ }
+ for (const term of artistTerms) {
+ if (a.includes(term)) score += 2
+ }
+ return score
+}
+
+async function searchJamendo(q: string, clientId: string, title?: string, artist?: string): Promise {
+ try {
+ const params = new URLSearchParams({
+ client_id: clientId,
+ search: q,
+ limit: '5',
+ format: 'json',
+ })
+ const res = await fetch(`https://api.jamendo.com/v3.0/tracks/?${params}`)
+ if (!res.ok) return null
+ const data = (await res.json()) as { results?: { id: string; name: string; artist_name: string; audio: string }[] }
+ const results = data.results ?? []
+ const track =
+ title && artist && results.length > 1
+ ? results.reduce((best, r) =>
+ scoreJamendoTrack(r, title, artist) > scoreJamendoTrack(best, title, artist) ? r : best,
+ )
+ : results[0]
+ if (!track?.audio) return null
+ if (title && artist && scoreJamendoTrack(track, title, artist) === 0) {
+ return null
+ }
+ return {
+ source: 'jamendo',
+ type: 'stream',
+ url: track.audio,
+ title: track.name,
+ artist: track.artist_name,
+ }
+ } catch {
+ return null
+ }
+}
+
+function scoreOdyseeItem(name: string, title: string, artist: string): number {
+ const n = name.toLowerCase().replace(/-/g, ' ')
+ const titleTerms = title.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
+ const artistTerms = artist.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
+ let score = 0
+ for (const term of titleTerms) {
+ if (n.includes(term)) score += 2
+ }
+ for (const term of artistTerms) {
+ if (n.includes(term)) score += 2
+ }
+ return score
+}
+
+async function searchOdysee(q: string, title?: string, artist?: string): Promise {
+ try {
+ const res = await fetch(
+ `https://lighthouse.odysee.tv/search?s=${encodeURIComponent(q)}&size=8`,
+ )
+ if (!res.ok) return null
+ const items = (await res.json()) as { name: string; claimId: string }[]
+ if (!Array.isArray(items) || items.length === 0) return null
+ const item =
+ title && artist && items.length > 1
+ ? items.reduce((best, i) =>
+ scoreOdyseeItem(i.name, title, artist) > scoreOdyseeItem(best.name, title, artist) ? i : best,
+ )
+ : items[0]
+ if (!item?.claimId) return null
+ if (title && artist && scoreOdyseeItem(item.name, title, artist) === 0) {
+ return null
+ }
+ const embedUrl = `https://odysee.com/$/embed/${item.name.replace(/^#/, '')}`
+ return {
+ source: 'odysee',
+ type: 'embed',
+ url: embedUrl,
+ title: item.name?.replace(/-/g, ' '),
+ }
+ } catch {
+ return null
+ }
+}
+
+function createMusicSearchMiddleware(
+ jamendoClientId: string | undefined,
+) {
+ return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
+ if (req.method !== 'GET') return next()
+ const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
+ const q = url.searchParams.get('q')?.trim()
+ const title = url.searchParams.get('title')?.trim()
+ const artist = url.searchParams.get('artist')?.trim()
+ if (!q) {
+ res.writeHead(400, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ error: 'Missing q (query)' }))
+ return
+ }
+ try {
+ const result =
+ (await searchInternetArchive(q, title ?? undefined, artist ?? undefined)) ??
+ (jamendoClientId ? await searchJamendo(q, jamendoClientId, title ?? undefined, artist ?? undefined) : null) ??
+ (await searchOdysee(q, title ?? undefined, artist ?? undefined))
+
+ res.setHeader('Content-Type', 'application/json')
+ res.setHeader('Access-Control-Allow-Origin', '*')
+ res.setHeader('Cache-Control', 'public, max-age=3600')
+ res.end(JSON.stringify(result ?? { error: 'No results from any source' }))
+ } catch (err) {
+ console.error('[music-search]', err)
+ res.writeHead(502, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ error: String(err) }))
+ }
+ }
+}
+
+export function musicSearchPlugin(): Plugin {
+ let jamendoClientId: string | undefined
+
+ return {
+ name: 'aiui-music-search',
+ configResolved(config) {
+ const env = loadEnv(config.mode, config.root ?? process.cwd(), '')
+ jamendoClientId = env.JAMENDO_CLIENT_ID ?? env.VITE_JAMENDO_CLIENT_ID
+ },
+ configureServer(server) {
+ server.middlewares.use('/api/music/search', createMusicSearchMiddleware(jamendoClientId))
+ },
+ configurePreviewServer(server) {
+ server.middlewares.use('/api/music/search', createMusicSearchMiddleware(jamendoClientId))
+ },
+ }
+}
diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts
index da60486c..50183fdc 100644
--- a/packages/app/vite.config.ts
+++ b/packages/app/vite.config.ts
@@ -5,6 +5,7 @@ import { VitePWA } from 'vite-plugin-pwa'
import { resolve } from 'path'
import { tmdbPlugin } from './vite-tmdb'
import { devChatsPlugin } from './vite-dev-chats'
+import { musicSearchPlugin } from './vite-music-search'
export default defineConfig({
plugins: [
@@ -12,6 +13,7 @@ export default defineConfig({
tailwindcss(),
tmdbPlugin(),
devChatsPlugin(),
+ musicSearchPlugin(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icon.svg', 'apple-touch-icon-180x180.png'],
diff --git a/packages/core/src/types/content.ts b/packages/core/src/types/content.ts
index b1a0dd92..8de724f0 100644
--- a/packages/core/src/types/content.ts
+++ b/packages/core/src/types/content.ts
@@ -35,7 +35,7 @@ export interface FilmRendererData {
}
export interface SongSource {
- type: 'plex' | 'spotify' | 'youtube' | 'apple-music' | 'bandcamp'
+ type: 'plex' | 'spotify' | 'youtube' | 'apple-music' | 'bandcamp' | 'soundcloud' | 'wavlake' | 'internet_archive' | 'jamendo' | 'odysee' | 'funkwhale'
name: string
url: string
icon?: string
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ba744204..8dd423d0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,6 +23,9 @@ importers:
pinia:
specifier: latest
version: 3.0.4(typescript@5.8.3)(vue@3.5.29(typescript@5.8.3))
+ plyr:
+ specifier: ^3.8.4
+ version: 3.8.4
vue:
specifier: latest
version: 3.5.29(typescript@5.8.3)
@@ -1376,6 +1379,9 @@ packages:
core-js-compat@3.48.0:
resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==}
+ core-js@3.48.0:
+ resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1387,6 +1393,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ custom-event-polyfill@1.0.7:
+ resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==}
+
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -1953,6 +1962,9 @@ packages:
resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
engines: {node: '>= 12.0.0'}
+ loadjs@4.3.0:
+ resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==}
+
local-pkg@1.1.2:
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
engines: {node: '>=14'}
@@ -2113,6 +2125,9 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
+ plyr@3.8.4:
+ resolution: {integrity: sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==}
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -2143,6 +2158,9 @@ packages:
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
+ rangetouch@2.0.1:
+ resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==}
+
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
@@ -2494,6 +2512,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ url-polyfill@1.1.14:
+ resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==}
+
vite-plugin-pwa@1.2.0:
resolution: {integrity: sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==}
engines: {node: '>=16.0.0'}
@@ -4055,6 +4076,8 @@ snapshots:
dependencies:
browserslist: 4.28.1
+ core-js@3.48.0: {}
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -4065,6 +4088,8 @@ snapshots:
csstype@3.2.3: {}
+ custom-event-polyfill@1.0.7: {}
+
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -4685,6 +4710,8 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.31.1
lightningcss-win32-x64-msvc: 1.31.1
+ loadjs@4.3.0: {}
+
local-pkg@1.1.2:
dependencies:
mlly: 1.8.0
@@ -4834,6 +4861,14 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
+ plyr@3.8.4:
+ dependencies:
+ core-js: 3.48.0
+ custom-event-polyfill: 1.0.7
+ loadjs: 4.3.0
+ rangetouch: 2.0.1
+ url-polyfill: 1.1.14
+
possible-typed-array-names@1.1.0: {}
postcss@8.5.6:
@@ -4856,6 +4891,8 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
+ rangetouch@2.0.1: {}
+
readdirp@5.0.0: {}
reflect.getprototypeof@1.0.10:
@@ -5275,6 +5312,8 @@ snapshots:
dependencies:
punycode: 2.3.1
+ url-polyfill@1.1.14: {}
+
vite-plugin-pwa@1.2.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0):
dependencies:
debug: 4.4.3