feat(app): integrate Jamendo API and enhance UI components
- Added Jamendo API client ID to the environment configuration for music search. - Updated pnpm lock file to include new dependencies for enhanced functionality. - Integrated Plyr library for improved media playback experience. - Refactored ChatHeader, ChatInput, and ChatMessage components to utilize new styles and improve user interaction. - Enhanced CSS styles for path-glass elements to align with the new design system. Made-with: Cursor
This commit is contained in:
@@ -15,6 +15,10 @@ VITE_ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||
# Get your key at: https://www.themoviedb.org/settings/api
|
||||
TMDB_API_KEY=your-tmdb-key-here
|
||||
|
||||
# Jamendo API (optional, extends music search - free 35k req/mo)
|
||||
# Get your client_id at: https://devportal.jamendo.com/
|
||||
JAMENDO_CLIENT_ID=your-jamendo-client-id
|
||||
|
||||
# Development flags
|
||||
VITE_DEV_MODE=true
|
||||
VITE_MOCK_MEDIA_SOURCES=true
|
||||
|
||||
@@ -82,7 +82,7 @@ define(['./workbox-cf23aef7'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.7fp7mese2fg"
|
||||
"revision": "0.9dj45k1er1c"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"dependencies": {
|
||||
"@aiui/core": "workspace:*",
|
||||
"pinia": "latest",
|
||||
"plyr": "^3.8.4",
|
||||
"vue": "latest",
|
||||
"vue-router": "latest"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="glass-strong rounded-t-2xl flex items-center justify-between px-4 py-3 relative z-20 border-t-0 border-x-0"
|
||||
ref="headerRef"
|
||||
class="path-glass-card rounded-t-2xl rounded-b-none flex items-center justify-between px-4 py-3 relative z-[60] shrink-0 border-t-0 border-x-0"
|
||||
:class="isDark ? '!border-b-white/10' : '!border-b-black/8'"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
@@ -10,6 +11,7 @@
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 relative">
|
||||
<button
|
||||
ref="chatListTriggerRef"
|
||||
class="w-full text-left group/btn"
|
||||
:class="conversationList.length > 0 ? 'cursor-pointer' : ''"
|
||||
@click="conversationList.length > 0 && (showChatList = !showChatList)"
|
||||
@@ -21,6 +23,7 @@
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-400'">{{ conversationId }}</p>
|
||||
<span class="text-[10px]" :class="isDark ? 'text-white/20' : 'text-gray-300'">·</span>
|
||||
<button
|
||||
ref="modelPickerTriggerRef"
|
||||
class="text-[10px] text-accent/70 hover:text-accent transition-colors truncate"
|
||||
@click.stop="showModelPicker = !showModelPicker; showChatList = false"
|
||||
>
|
||||
@@ -28,19 +31,14 @@
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<Transition name="picker">
|
||||
<div
|
||||
v-if="showChatList && conversationList.length > 0"
|
||||
class="contents"
|
||||
>
|
||||
<Teleport to="body">
|
||||
<div v-if="showChatList && conversationList.length > 0" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showChatList = false" />
|
||||
<Transition name="picker">
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
aria-hidden="true"
|
||||
@click="showChatList = false"
|
||||
/>
|
||||
<div
|
||||
class="absolute left-0 right-0 top-full z-50 mt-1 p-2 max-h-48 overflow-y-auto animate-fade-up-fast shadow-2xl rounded-xl"
|
||||
v-if="showChatList && conversationList.length > 0"
|
||||
class="fixed z-[9999] mt-1 p-2 max-h-48 overflow-y-auto animate-fade-up-fast shadow-2xl rounded-xl min-w-[200px]"
|
||||
:class="isDark ? 'bg-[#161618] border border-white/10' : 'bg-white border border-black/10'"
|
||||
:style="chatListDropdownStyle"
|
||||
@click.stop
|
||||
>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-wider mb-2 px-1"
|
||||
@@ -59,8 +57,8 @@
|
||||
{{ c.title || 'Untitled' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,11 +123,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Transition name="picker">
|
||||
<div
|
||||
v-if="showModelPicker"
|
||||
class="absolute left-0 right-0 top-full z-50 mx-3 mt-1 glass-card p-3 space-y-3 animate-fade-up-fast shadow-2xl"
|
||||
>
|
||||
<Teleport to="body">
|
||||
<div v-if="showModelPicker" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showModelPicker = false" />
|
||||
<Transition name="picker">
|
||||
<div
|
||||
v-if="showModelPicker"
|
||||
class="fixed z-[9999] path-glass-card p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px]"
|
||||
:style="modelPickerDropdownStyle"
|
||||
@click.stop
|
||||
>
|
||||
<div v-for="provider in availableProviders" :key="provider.id">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-wider mb-1.5 px-1"
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-400'">
|
||||
@@ -152,12 +154,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useAI } from '@/composables/useAI'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
@@ -180,6 +183,43 @@ const { isDark, toggleTheme } = useTheme()
|
||||
const chatStore = useChatStore()
|
||||
const showModelPicker = ref(false)
|
||||
const showChatList = ref(false)
|
||||
const headerRef = ref<HTMLElement | null>(null)
|
||||
const chatListTriggerRef = ref<HTMLElement | null>(null)
|
||||
const modelPickerTriggerRef = ref<HTMLElement | null>(null)
|
||||
const chatListDropdownStyle = ref<Record<string, string>>({})
|
||||
const modelPickerDropdownStyle = ref<Record<string, string>>({})
|
||||
|
||||
function updateChatListPosition() {
|
||||
nextTick(() => {
|
||||
const el = chatListTriggerRef.value
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect()
|
||||
chatListDropdownStyle.value = {
|
||||
top: `${r.bottom + 4}px`,
|
||||
left: `${r.left}px`,
|
||||
width: `${Math.max(r.width, 200)}px`,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateModelPickerPosition() {
|
||||
nextTick(() => {
|
||||
const el = modelPickerTriggerRef.value
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect()
|
||||
modelPickerDropdownStyle.value = {
|
||||
top: `${r.bottom + 4}px`,
|
||||
right: 'auto',
|
||||
left: `${r.left}px`,
|
||||
width: `${Math.max(r.width + 24, 220)}px`,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(showChatList, (v) => { if (v) updateChatListPosition() })
|
||||
watch(showModelPicker, (v) => { if (v) updateModelPickerPosition() })
|
||||
|
||||
const conversationList = computed(() => chatStore.conversationList)
|
||||
const activeConversationId = computed(() => chatStore.activeConversationId)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="p-3 md:p-4">
|
||||
<div
|
||||
class="glass rounded-2xl px-4 py-3 flex items-end gap-3 transition-all duration-300"
|
||||
class="path-glass-bubble rounded-2xl px-4 py-3 flex items-end gap-3 transition-all duration-300"
|
||||
:class="focused
|
||||
? isDark
|
||||
? 'border-white/30'
|
||||
@@ -24,7 +24,7 @@
|
||||
/>
|
||||
<button
|
||||
:disabled="!canSend"
|
||||
class="shrink-0 glass-button glass-button-sm rounded-xl px-3 transition-all duration-200"
|
||||
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200"
|
||||
:class="canSend
|
||||
? 'hover:opacity-80 active:scale-95'
|
||||
: 'opacity-30 cursor-not-allowed'"
|
||||
|
||||
@@ -73,8 +73,8 @@ const isUser = computed(() => props.message.role === 'user')
|
||||
|
||||
const bubbleClasses = computed(() =>
|
||||
isUser.value
|
||||
? 'glass-strong rounded-br-md'
|
||||
: 'glass-card rounded-bl-md'
|
||||
? 'path-glass-bubble-user rounded-br-md rounded-2xl'
|
||||
: 'path-glass-bubble rounded-bl-md rounded-2xl'
|
||||
)
|
||||
|
||||
const inlineFilms = computed(() => {
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
<div
|
||||
ref="messageListRef"
|
||||
class="flex-1 overflow-y-auto scrollbar-hide p-4 space-y-3"
|
||||
class="relative z-0 flex-1 min-h-0 overflow-y-auto scrollbar-hide p-4 space-y-3"
|
||||
>
|
||||
<div v-if="messages.length === 0" class="flex items-center justify-center h-full">
|
||||
<div class="text-center space-y-3 animate-fade-up">
|
||||
<div class="w-16 h-16 rounded-2xl glass flex items-center justify-center mx-auto">
|
||||
<span class="text-2xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'">✦</span>
|
||||
<div class="w-16 h-16 rounded-2xl path-glass-card flex items-center justify-center mx-auto overflow-hidden">
|
||||
<img src="/icon.svg" alt="AIUI" class="w-10 h-10 object-contain" />
|
||||
</div>
|
||||
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
Start a conversation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex justify-start animate-fade-up-fast">
|
||||
<div class="glass-card rounded-2xl rounded-bl-md px-4 py-3">
|
||||
<div class="path-glass-card rounded-2xl rounded-bl-md px-4 py-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
v-for="i in 3"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Transition name="panel">
|
||||
<aside
|
||||
v-if="panelOpen"
|
||||
class="glass-card overflow-hidden flex flex-col"
|
||||
class="path-glass-card overflow-hidden flex flex-col"
|
||||
:class="isMobile
|
||||
? 'fixed inset-0 z-30'
|
||||
: 'w-80 xl:w-96 shrink-0'"
|
||||
|
||||
@@ -18,10 +18,20 @@
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
|
||||
|
||||
<button
|
||||
class="absolute top-3 left-3 p-2 rounded-lg backdrop-blur-md transition-colors bg-black/30 text-white/80 hover:bg-black/50"
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-20 h-20 rounded-full flex items-center justify-center path-glass-icon hover:scale-105 active:scale-95 transition-all duration-200 z-10"
|
||||
title="Play"
|
||||
@click="onPlay"
|
||||
>
|
||||
<svg class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7L8 5z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -49,13 +59,26 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="wavlakeChartUrl" class="rounded-xl overflow-hidden border"
|
||||
:class="isDark ? 'border-white/10' : 'border-black/8'">
|
||||
<h4 class="text-xs font-semibold mb-2 px-3 pt-3"
|
||||
:class="isDark ? 'text-white/50' : 'text-gray-500'">Discover on Wavlake</h4>
|
||||
<iframe
|
||||
:src="wavlakeChartUrl"
|
||||
class="w-full h-[380px] border-0"
|
||||
loading="lazy"
|
||||
title="Wavlake chart"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold mb-2"
|
||||
:class="isDark ? 'text-white/50' : 'text-gray-500'">Listen on</h4>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
v-if="youtubeSearchUrl"
|
||||
:href="youtubeSearchUrl"
|
||||
v-for="link in listenLinks"
|
||||
:key="link.url"
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center justify-between p-3 rounded-xl transition-colors"
|
||||
@@ -64,12 +87,12 @@
|
||||
: 'bg-black/3 hover:bg-black/5'"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="text-sm">▶️</span>
|
||||
<span class="text-sm">{{ link.icon }}</span>
|
||||
<div>
|
||||
<p class="text-xs font-medium"
|
||||
:class="isDark ? 'text-white/80' : 'text-gray-800'">YouTube (free)</p>
|
||||
<p class="text-[10px]"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">Search for this song</p>
|
||||
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ link.name }}</p>
|
||||
<p v-if="link.desc" class="text-[10px]"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ link.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4" :class="isDark ? 'text-white/30' : 'text-gray-400'"
|
||||
@@ -109,14 +132,47 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import type { Song } from '@aiui/core/types/content'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { generateSongCoverFallback, fetchMusicCover } from '@/composables/useImageFallback'
|
||||
import { usePlayer } from '@/composables/usePlayer'
|
||||
import { mapToWavlakeGenre } from '@/utils/wavlakeGenres'
|
||||
|
||||
const props = defineProps<{ song: Song }>()
|
||||
const { play } = usePlayer()
|
||||
|
||||
function onPlay() {
|
||||
play(props.song)
|
||||
}
|
||||
defineEmits<{ back: [] }>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const coverFailed = ref(false)
|
||||
const fetchedCover = ref<string | null>(null)
|
||||
|
||||
const q = computed(() =>
|
||||
`${props.song.title} ${props.song.artist}`.trim().replace(/\s+/g, '+'),
|
||||
)
|
||||
|
||||
const listenLinks = computed(() => {
|
||||
const links: { name: string; url: string; icon: string; desc?: string }[] = [
|
||||
{ name: 'Internet Archive', url: `https://archive.org/search?query=${q.value}`, icon: '🏛️', desc: 'Free, open archive' },
|
||||
{ name: 'Bandcamp', url: `https://bandcamp.com/search?q=${q.value}`, icon: '📦', desc: 'Artist-first' },
|
||||
{ name: 'SoundCloud', url: `https://soundcloud.com/search?q=${q.value}`, icon: '☁️', desc: 'Indie & remixes' },
|
||||
{ name: 'Wavlake', url: `https://wavlake.com/`, icon: '⚡', desc: 'Lightning, indie discovery' },
|
||||
{ name: 'Odysee', url: `https://odysee.com/$/search?q=${q.value}`, icon: '🔗', desc: 'Decentralized' },
|
||||
{ name: 'Jamendo', url: `https://www.jamendo.com/search?q=${q.value}`, icon: '🎵', desc: 'Royalty-free' },
|
||||
]
|
||||
return links
|
||||
})
|
||||
|
||||
const wavlakeGenre = computed(() =>
|
||||
mapToWavlakeGenre(props.song.genres ?? []),
|
||||
)
|
||||
|
||||
const wavlakeChartUrl = computed(() => {
|
||||
const genre = wavlakeGenre.value
|
||||
if (!genre) return null
|
||||
return `https://embed.wavlake.com/chart?days=21&limit=10&genre=${encodeURIComponent(genre)}`
|
||||
})
|
||||
|
||||
const coverSrc = computed(() => {
|
||||
if (coverFailed.value) return null
|
||||
return props.song.coverUrl || fetchedCover.value
|
||||
@@ -126,11 +182,6 @@ const fallbackCover = computed(() =>
|
||||
generateSongCoverFallback(props.song.title, props.song.artist)
|
||||
)
|
||||
|
||||
const youtubeSearchUrl = computed(() => {
|
||||
const q = `${props.song.title} ${props.song.artist}`.trim()
|
||||
return q ? `https://www.youtube.com/results?search_query=${encodeURIComponent(q)}` : null
|
||||
})
|
||||
|
||||
function formatDuration(sec: number): string {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = sec % 60
|
||||
@@ -143,6 +194,12 @@ function sourceIcon(type: string): string {
|
||||
youtube: '▶️',
|
||||
'apple-music': '🍎',
|
||||
bandcamp: '📦',
|
||||
soundcloud: '☁️',
|
||||
wavlake: '⚡',
|
||||
internet_archive: '🏛️',
|
||||
jamendo: '🎵',
|
||||
odysee: '🔗',
|
||||
funkwhale: '🐋',
|
||||
}
|
||||
return icons[type] ?? '🎵'
|
||||
}
|
||||
|
||||
@@ -48,8 +48,19 @@
|
||||
class="group flex flex-col items-stretch text-left w-full"
|
||||
@click="$emit('selectSong', song)"
|
||||
>
|
||||
<div class="cover-card flex-1 min-h-0">
|
||||
<div class="cover-card flex-1 min-h-0 relative">
|
||||
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]">
|
||||
<button
|
||||
class="absolute inset-0 flex items-center justify-center z-10 backdrop-blur-sm bg-black/30 opacity-0 group-hover:opacity-100 transition-all duration-200"
|
||||
title="Play"
|
||||
@click.stop="onPlayClick(song)"
|
||||
>
|
||||
<span class="w-16 h-16 rounded-full flex items-center justify-center path-glass-icon">
|
||||
<svg class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7L8 5z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<img
|
||||
v-if="coverSrc(song)"
|
||||
:src="coverSrc(song)!"
|
||||
@@ -103,6 +114,7 @@
|
||||
import { ref, computed, reactive, onMounted, watch } from 'vue'
|
||||
import type { Song } from '@aiui/core/types/content'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { usePlayer } from '@/composables/usePlayer'
|
||||
import { generateSongCoverFallback, fetchMusicCover } from '@/composables/useImageFallback'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -115,6 +127,7 @@ const props = withDefaults(defineProps<{
|
||||
defineEmits<{ selectSong: [song: Song] }>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { play } = usePlayer()
|
||||
const search = ref('')
|
||||
const activeGenre = ref<string | null>(null)
|
||||
const failedCovers = ref<Set<string>>(new Set())
|
||||
@@ -135,6 +148,10 @@ function onCoverError(song: Song) {
|
||||
failedCovers.value = new Set(failedCovers.value)
|
||||
}
|
||||
|
||||
function onPlayClick(song: Song) {
|
||||
play(song)
|
||||
}
|
||||
|
||||
function fetchCoversFor(songs: Song[]) {
|
||||
for (const song of songs) {
|
||||
if (song.coverUrl || fetchedCovers.has(song.id)) continue
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<Transition name="player-slide">
|
||||
<div
|
||||
v-if="hasTrack"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 flex items-center gap-4 px-4 py-3 path-glass-card rounded-none border-t-0 border-x-0 shadow-2xl"
|
||||
>
|
||||
<!-- Plyr container: YouTube requires min 200x200px. Kept off-screen but sized. -->
|
||||
<div
|
||||
ref="plyrContainerRef"
|
||||
class="absolute left-[-9999px] w-[320px] h-[180px] overflow-hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 max-w-[280px]">
|
||||
<div
|
||||
class="w-12 h-12 rounded-lg overflow-hidden shrink-0 flex items-center justify-center path-glass-icon"
|
||||
>
|
||||
<img
|
||||
v-if="coverUrl"
|
||||
:src="coverUrl"
|
||||
:alt="currentSong!.title"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<span v-else class="text-lg">🎵</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold truncate"
|
||||
:class="isDark ? 'text-white/90' : 'text-gray-900'">
|
||||
{{ currentSong!.title }}
|
||||
</p>
|
||||
<p class="text-xs truncate" :class="isDark ? 'text-white/50' : 'text-gray-500'">
|
||||
{{ currentSong!.artist }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 flex-1 max-w-xl mx-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center glass-button shrink-0 transition-all hover:scale-105 active:scale-95"
|
||||
@click="toggle"
|
||||
>
|
||||
<svg v-if="isLoading" class="w-5 h-5 animate-spin" :class="isDark ? 'text-white/90' : 'text-gray-800'" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<svg v-else-if="isPlaying" class="w-5 h-5" :class="isDark ? 'text-white/90' : 'text-gray-800'" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" :class="isDark ? 'text-white/90' : 'text-gray-800'" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7L8 5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-[10px] font-mono tabular-nums"
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-400'">
|
||||
{{ formatTime(currentTime) }}
|
||||
</span>
|
||||
<div
|
||||
class="flex-1 h-1.5 rounded-full cursor-pointer group"
|
||||
:class="isDark ? 'bg-white/15' : 'bg-black/10'"
|
||||
@click="onScrubberClick"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-150 group-hover:h-2 bg-accent"
|
||||
:style="{ width: `${progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono tabular-nums"
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-400'">
|
||||
{{ formatTime(duration) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="error" class="text-[10px] text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-9 h-9 rounded-xl flex items-center justify-center path-glass-button path-glass-button-sm shrink-0 transition-all hover:scale-105"
|
||||
@click="clear"
|
||||
title="Close player"
|
||||
>
|
||||
<svg class="w-4 h-4" :class="isDark ? 'text-white/70' : 'text-gray-600'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { usePlayer } from '@/composables/usePlayer'
|
||||
import { generateSongCoverFallback, fetchMusicCover } from '@/composables/useImageFallback'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const {
|
||||
currentSong,
|
||||
hasTrack,
|
||||
isPlaying,
|
||||
isLoading,
|
||||
error,
|
||||
currentTime,
|
||||
duration,
|
||||
progress,
|
||||
toggle,
|
||||
seek,
|
||||
clear,
|
||||
setContainer,
|
||||
} = usePlayer()
|
||||
|
||||
const plyrContainerRef = ref<HTMLDivElement | null>(null)
|
||||
const fetchedCover = ref<string | null>(null)
|
||||
|
||||
const coverUrl = computed(() => {
|
||||
const song = currentSong.value
|
||||
if (!song) return null
|
||||
return song.coverUrl || fetchedCover.value
|
||||
})
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function onScrubberClick(e: MouseEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100))
|
||||
seek(percent)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
setContainer(plyrContainerRef.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => plyrContainerRef.value,
|
||||
(el) => setContainer(el),
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
watch(currentSong, (song) => {
|
||||
fetchedCover.value = null
|
||||
if (song && !song.coverUrl) {
|
||||
fetchMusicCover(song.title, song.artist).then((url) => {
|
||||
if (url) fetchedCover.value = url
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-slide-enter-active,
|
||||
.player-slide-leave-active {
|
||||
transition: transform 0.25s ease, opacity 0.2s ease;
|
||||
}
|
||||
.player-slide-enter-from,
|
||||
.player-slide-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<button
|
||||
class="fixed z-50 w-14 h-14 rounded-full glass-button flex items-center justify-center
|
||||
class="fixed z-50 w-14 h-14 rounded-full path-glass-button flex items-center justify-center
|
||||
transition-all duration-300 hover:scale-105 active:scale-95"
|
||||
:class="positionClasses"
|
||||
:style="{ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.45), 0 0 20px rgba(247, 147, 26, 0.15)' }"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:class="[positionClasses, currentTheme]"
|
||||
>
|
||||
<div
|
||||
class="w-[380px] h-[600px] md:w-[420px] md:h-[640px] glass-card overflow-hidden"
|
||||
class="w-[380px] h-[600px] md:w-[420px] md:h-[640px] path-glass-card overflow-hidden"
|
||||
:style="isDark
|
||||
? 'box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(247, 147, 26, 0.06)'
|
||||
: 'box-shadow: 0 20px 60px rgba(0, 0, 0, 0.12), 0 0 40px rgba(247, 147, 26, 0.04)'"
|
||||
|
||||
@@ -21,9 +21,14 @@ const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the u
|
||||
|
||||
**Films:** When recommending or discussing films from the user's library, use [[film:ID]] where ID is the film's id. For films NOT in the library, use [[film_ext:Title|Year|Director]], e.g. [[film_ext:Brokeback Mountain|2005|Ang Lee]].
|
||||
|
||||
**Songs:** When recommending or discussing songs from the user's library, use [[song:ID]] where ID is the song's id. For songs NOT in the library, use [[song_ext:Title|Artist|Year]] (year optional), e.g. [[song_ext:Never Meant|American Football|1999]].
|
||||
**Songs:** When recommending or discussing songs, ALWAYS use tags for every song you mention:
|
||||
- Library songs: [[song:ID]] where ID is the song's id (e.g. [[song:s1]]).
|
||||
- Other songs: [[song_ext:Title|Artist|Year]] (year optional), e.g. [[song_ext:Never Meant|American Football|1999]].
|
||||
Never list songs in plain text only—each recommendation must have a tag so the UI can show playable cards.
|
||||
|
||||
Always include these tags so the UI can render rich cards. You may recommend multiple items. Write a brief reason why each is worth checking out.
|
||||
**Music discovery:** For genre-based requests (e.g. "best math rock"), pick from the user's library when relevant, or use [[song_ext:...]] for others. Prioritize indie-friendly platforms: Wavlake, Bandcamp, Internet Archive, SoundCloud, Odysee, Jamendo.
|
||||
|
||||
Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out.
|
||||
|
||||
The user's film library:
|
||||
${filmContext}
|
||||
|
||||
@@ -118,20 +118,68 @@ export function useContentPanel() {
|
||||
return songs
|
||||
}
|
||||
|
||||
/** Infer songs from plain text when no tags present (e.g. old chats) */
|
||||
function extractSongsFromPlainText(text: string): Song[] {
|
||||
/** Infer songs from plain text when no tags present. Requires title+artist within 100 chars, whole-word artist. */
|
||||
function extractSongsFromLibraryMatch(text: string): Song[] {
|
||||
const lower = text.toLowerCase()
|
||||
const found: Song[] = []
|
||||
const found: { song: Song; pos: number }[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const song of mockSongs) {
|
||||
const key = song.id
|
||||
if (seen.has(key)) continue
|
||||
if (lower.includes(song.title.toLowerCase()) && lower.includes(song.artist.toLowerCase())) {
|
||||
const title = song.title.toLowerCase()
|
||||
const artist = song.artist.toLowerCase()
|
||||
if (!lower.includes(title)) continue
|
||||
const artistRe = new RegExp('\\b' + artist.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i')
|
||||
if (!artistRe.test(lower)) continue
|
||||
const titlePos = lower.indexOf(title)
|
||||
const artistMatch = lower.match(artistRe)
|
||||
const artistPos = artistMatch?.index ?? -1
|
||||
if (artistPos < 0) continue
|
||||
const dist = Math.abs(titlePos - artistPos)
|
||||
if (dist > 120) continue
|
||||
seen.add(key)
|
||||
found.push({ song, pos: Math.min(titlePos, artistPos) })
|
||||
}
|
||||
return found.sort((a, b) => a.pos - b.pos).map((f) => f.song)
|
||||
}
|
||||
|
||||
/** Extract song-like patterns: "Title" by Artist, Title – Artist, 1. Title - Artist */
|
||||
function extractSongsFromPatterns(text: string): Song[] {
|
||||
const songs: { title: string; artist: string; pos: number }[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const patterns: { re: RegExp; titleIdx: number; artistIdx: number }[] = [
|
||||
{ re: /"([^"]{2,80})"\s+by\s+([A-Za-z0-9][^,\n\.]{1,50}?)(?:\s*[,\n\.]|$)/gi, titleIdx: 1, artistIdx: 2 },
|
||||
{ re: /\*\*([^*]{2,80})\*\*\s+by\s+([A-Za-z0-9][^,\n\.]{1,50}?)(?:\s*[,\n\.]|$)/g, titleIdx: 1, artistIdx: 2 },
|
||||
{ re: /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)?([^\n\-–—]{2,60}?)\s*[-–—]\s*([A-Za-z0-9][^,\n]{1,50}?)(?:\s*[,\n\.]|$)/gm, titleIdx: 1, artistIdx: 2 },
|
||||
{ re: /([A-Za-z0-9][^\-–—\n]{2,60}?)\s+[-–—]\s+([A-Za-z0-9][^,\n]{1,50}?)(?=\s*[,\n\.]|$)/g, titleIdx: 1, artistIdx: 2 },
|
||||
]
|
||||
|
||||
for (const { re, titleIdx, artistIdx } of patterns) {
|
||||
let m: RegExpExecArray | null
|
||||
const rx = new RegExp(re.source, re.flags)
|
||||
while ((m = rx.exec(text)) !== null) {
|
||||
const title = m[titleIdx].trim()
|
||||
const artist = m[artistIdx].trim()
|
||||
if (title.length < 2 || artist.length < 2) continue
|
||||
if (/^\d{4}$/.test(title) || /^\d{4}$/.test(artist)) continue
|
||||
if (/\[\[(film|song)(_ext)?:/.test(title) || /\[\[(film|song)(_ext)?:/.test(artist)) continue
|
||||
if (/\*\*\[\[/.test(title) || title.includes(']]**')) continue
|
||||
const key = `${title.toLowerCase()}|${artist.toLowerCase()}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
found.push(song)
|
||||
songs.push({ title, artist, pos: m.index })
|
||||
}
|
||||
}
|
||||
return found
|
||||
|
||||
return songs
|
||||
.sort((a, b) => a.pos - b.pos)
|
||||
.map(({ title, artist }) => ({
|
||||
id: `ext-${`${title}|${artist}`.toLowerCase().replace(/\W/g, '-')}`,
|
||||
title,
|
||||
artist,
|
||||
sources: [],
|
||||
}))
|
||||
}
|
||||
|
||||
function extractAllSongs(text: string): Song[] {
|
||||
@@ -140,7 +188,14 @@ export function useContentPanel() {
|
||||
if (librarySongs.length > 0 || externalSongs.length > 0) {
|
||||
return [...librarySongs, ...externalSongs]
|
||||
}
|
||||
return extractSongsFromPlainText(text)
|
||||
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
|
||||
const libMatches = extractSongsFromLibraryMatch(text)
|
||||
const patternMatches = extractSongsFromPatterns(text)
|
||||
const libKeys = new Set(libMatches.map((s) => `${s.title.toLowerCase()}|${s.artist.toLowerCase()}`))
|
||||
const fromPatterns = patternMatches.filter(
|
||||
(p) => !libKeys.has(`${p.title.toLowerCase()}|${p.artist.toLowerCase()}`)
|
||||
)
|
||||
return [...libMatches, ...fromPatterns]
|
||||
}
|
||||
|
||||
function updatePanelFromText(text: string) {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Song } from '@aiui/core/types/content'
|
||||
import Plyr from 'plyr'
|
||||
import 'plyr/dist/plyr.css'
|
||||
|
||||
interface MusicSearchResult {
|
||||
source: string
|
||||
type: 'stream' | 'embed'
|
||||
url: string
|
||||
title?: string
|
||||
artist?: string
|
||||
}
|
||||
|
||||
const currentSong = ref<Song | null>(null)
|
||||
const playableSource = ref<MusicSearchResult | null>(null)
|
||||
const isPlaying = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
|
||||
let plyrInstance: Plyr | null = null
|
||||
let containerEl: HTMLDivElement | null = null
|
||||
|
||||
export function usePlayer() {
|
||||
const hasTrack = computed(() => !!currentSong.value)
|
||||
const progress = computed(() => {
|
||||
if (duration.value <= 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
async function searchMusic(query: string, title?: string, artist?: string): Promise<MusicSearchResult | null> {
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query })
|
||||
if (title) params.set('title', title)
|
||||
if (artist) params.set('artist', artist)
|
||||
const res = await fetch(`/api/music/search?${params}`)
|
||||
const data = (await res.json()) as MusicSearchResult & { error?: string }
|
||||
if (data.error || !data.url) {
|
||||
error.value = data.error ?? 'No playable source found'
|
||||
return null
|
||||
}
|
||||
return data as MusicSearchResult
|
||||
} catch (e) {
|
||||
error.value = 'Network error. Is the dev server running?'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function setContainer(el: HTMLDivElement | null) {
|
||||
containerEl = el
|
||||
if (el && playableSource.value) {
|
||||
initPlayer(playableSource.value)
|
||||
}
|
||||
}
|
||||
|
||||
function initPlayer(result: MusicSearchResult) {
|
||||
if (!containerEl) return
|
||||
destroyPlayer()
|
||||
|
||||
if (result.type === 'stream') {
|
||||
const audio = document.createElement('audio')
|
||||
audio.src = result.url
|
||||
audio.crossOrigin = 'anonymous'
|
||||
containerEl.innerHTML = ''
|
||||
containerEl.appendChild(audio)
|
||||
plyrInstance = new Plyr(audio, {
|
||||
controls: [],
|
||||
autoplay: true,
|
||||
muted: false,
|
||||
})
|
||||
} else {
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.src = result.url
|
||||
iframe.style.width = '100%'
|
||||
iframe.style.height = '100%'
|
||||
iframe.style.border = 'none'
|
||||
containerEl.innerHTML = ''
|
||||
containerEl.appendChild(iframe)
|
||||
plyrInstance = null
|
||||
duration.value = 0
|
||||
currentTime.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
plyrInstance!.on('ready', () => {
|
||||
plyrInstance!.play().catch(() => { /* autoplay blocked */ })
|
||||
})
|
||||
plyrInstance!.on('timeupdate', () => {
|
||||
currentTime.value = plyrInstance!.currentTime ?? 0
|
||||
})
|
||||
plyrInstance!.on('loadedmetadata', () => {
|
||||
duration.value = plyrInstance!.duration ?? 0
|
||||
})
|
||||
plyrInstance!.on('ended', () => {
|
||||
isPlaying.value = false
|
||||
})
|
||||
plyrInstance!.on('playing', () => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
plyrInstance!.on('pause', () => {
|
||||
isPlaying.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function destroyPlayer() {
|
||||
if (plyrInstance) {
|
||||
plyrInstance.destroy()
|
||||
plyrInstance = null
|
||||
}
|
||||
if (containerEl) {
|
||||
containerEl.innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function play(song: Song) {
|
||||
error.value = null
|
||||
if (currentSong.value?.id === song.id && playableSource.value) {
|
||||
plyrInstance?.play()
|
||||
isPlaying.value = true
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
const query = `${song.title} ${song.artist}`.trim()
|
||||
const result = await searchMusic(query, song.title, song.artist)
|
||||
isLoading.value = false
|
||||
currentSong.value = song
|
||||
if (!result) {
|
||||
if (!error.value) error.value = 'No playable source. Tries: Internet Archive, Jamendo, Odysee. Add JAMENDO_CLIENT_ID for more.'
|
||||
return
|
||||
}
|
||||
error.value = null
|
||||
playableSource.value = result
|
||||
if (containerEl) {
|
||||
initPlayer(result)
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
plyrInstance?.pause()
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (plyrInstance) {
|
||||
if (isPlaying.value) pause()
|
||||
else plyrInstance.play()
|
||||
}
|
||||
}
|
||||
|
||||
function seek(percent: number) {
|
||||
if (!plyrInstance || !duration.value) return
|
||||
const time = (percent / 100) * duration.value
|
||||
plyrInstance.currentTime = time
|
||||
currentTime.value = time
|
||||
}
|
||||
|
||||
function clear() {
|
||||
pause()
|
||||
destroyPlayer()
|
||||
currentSong.value = null
|
||||
playableSource.value = null
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
currentSong,
|
||||
playableSource,
|
||||
isPlaying,
|
||||
isLoading,
|
||||
error,
|
||||
currentTime,
|
||||
duration,
|
||||
hasTrack,
|
||||
progress,
|
||||
play,
|
||||
pause,
|
||||
toggle,
|
||||
seek,
|
||||
clear,
|
||||
setContainer,
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
<template>
|
||||
<div
|
||||
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
||||
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]'"
|
||||
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300 transition-[padding]"
|
||||
:class="[
|
||||
hasTrack && 'pb-[72px]'
|
||||
]"
|
||||
:style="isDark
|
||||
? { background: '#000 url(/assets/img/bg-intro-3.jpg) center center / cover no-repeat fixed' }
|
||||
: { backgroundColor: '#f5f4f1' }"
|
||||
>
|
||||
<div v-if="isDark" class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute top-[-20%] left-[-10%] w-[500px] h-[500px] rounded-full bg-accent/5 blur-[120px]" />
|
||||
<div class="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] rounded-full bg-info/5 blur-[120px]" />
|
||||
</div>
|
||||
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
|
||||
<div class="relative z-10 flex-1 flex h-full p-3 md:p-4 gap-3 md:gap-4">
|
||||
<div class="flex-1 flex h-full p-3 md:p-4 gap-3 md:gap-4">
|
||||
|
||||
<!-- Content surface (main area) -->
|
||||
<main
|
||||
class="flex-1 min-w-0 glass-card overflow-hidden flex flex-col"
|
||||
class="flex-1 min-w-0 path-glass-card overflow-hidden flex flex-col"
|
||||
:class="[
|
||||
panelSide === 'left' ? 'order-last' : 'order-first',
|
||||
(selectedFilm || selectedSong) && 'detail-active'
|
||||
@@ -52,8 +54,8 @@
|
||||
|
||||
<div v-else key="empty" class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center space-y-4 animate-fade-up max-w-sm px-6">
|
||||
<div class="w-20 h-20 rounded-2xl glass flex items-center justify-center mx-auto">
|
||||
<span class="text-3xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'">✦</span>
|
||||
<div class="w-20 h-20 rounded-2xl path-glass-card flex items-center justify-center mx-auto overflow-hidden">
|
||||
<img src="/icon.svg" alt="AIUI" class="w-12 h-12 object-contain" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold mb-1"
|
||||
@@ -70,9 +72,9 @@
|
||||
</Transition>
|
||||
</main>
|
||||
|
||||
<!-- Chat panel -->
|
||||
<!-- Chat panel: high z-index so it + dropdowns stay above content -->
|
||||
<aside
|
||||
class="w-80 xl:w-96 shrink-0 flex flex-col glass-card overflow-visible"
|
||||
class="relative z-[100] w-80 xl:w-96 shrink-0 flex flex-col path-glass-card overflow-visible"
|
||||
:class="panelSide === 'left' ? 'order-first' : 'order-last'"
|
||||
>
|
||||
<ChatWindow
|
||||
@@ -82,6 +84,8 @@
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
<PlayerBar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Map common genre names to Wavlake chart genre param (top-level from list.csv) */
|
||||
export const WAVLAKE_GENRE_MAP: Record<string, string> = {
|
||||
'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
|
||||
}
|
||||
@@ -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<MusicSearchResult | null> {
|
||||
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<MusicSearchResult | null> {
|
||||
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<MusicSearchResult | null> {
|
||||
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))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+39
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user