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:
Dorian
2026-03-02 19:37:00 +00:00
parent 80aeefa4bf
commit 7a0e42bf63
25 changed files with 1165 additions and 111 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -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"), {
+1
View File
@@ -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

+61 -21
View File
@@ -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)'"
+7 -2
View File
@@ -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) {
+185
View File
@@ -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,
}
}
+19 -12
View File
@@ -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()
+261 -42
View File
@@ -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 {
+38
View File
@@ -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
}
+221
View File
@@ -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))
},
}
}
+2
View File
@@ -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'],
+1 -1
View File
@@ -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
+39
View File
@@ -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