feat(app): video player, guide page, free films, PWA cache fix

- Add VideoPlayerOverlay component for free film playback
- Add GuidePage with interactive node setup walkthrough
- Add freeFilms data catalog with public domain films
- Enhance PlayerBar with video support and queue management
- Add video player store for overlay state management
- Refactor music search plugin (Jamendo integration cleanup)
- Add PWA cache version purge mechanism in main.ts
- Add PWA icon cache fix skill for Brave/Chrome
- Improve content grids: loading states, image fallbacks
- Enhance useArchy composable with node context
- Update useNostr with relay pool management
- Expand chat store with guide conversation support
- Add test fixtures for guide and node demo prompts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 00:56:39 +00:00
co-authored by Claude Opus 4.6
parent c84c0fb424
commit b77c93607a
32 changed files with 3027 additions and 413 deletions
+102
View File
@@ -0,0 +1,102 @@
---
name: pwa-icon-cache-fix
description: Use when the user reports a PWA icon not updating, stale PWA icon, wrong icon after install, or any PWA caching issue. Also applies when changing PWA icons in a Vite + vite-plugin-pwa project.
version: 2.0.0
---
# PWA Icon Cache Fix
## Problem
PWA icons are cached at FOUR independent layers:
1. **Service worker cache** (Workbox precache)
2. **Browser HTTP cache**
3. **Browser manifest resources** (Chromium stores resized icons in its profile data, keyed by a permanent extension ID tied to the origin — NEVER re-fetched even after uninstall/reinstall)
4. **macOS .app bundle** (`.icns` file baked into the `.app` in `~/Applications/`)
Query string cache busting (`?v=2`) and uninstall/reinstall do NOT fix this. Chromium reuses the same extension ID for the same origin, so it keeps the old cached icons.
## Fix Steps
### 1. Verify icon files on disk and server are correct
```bash
# Visual check
Read packages/app/public/pwa-192x192.png
Read packages/app/public/pwa-512x512.png
# Hash match check
curl -s http://localhost:5173/pwa-192x192.png | md5
md5 -q packages/app/public/pwa-192x192.png
```
### 2. Find the PWA's Chromium extension ID
Read the installed `.app` bundle's `Info.plist` to get the `CrAppModeShortcutID`:
```bash
plutil -p "~/Applications/Brave Browser Apps.localized/AIUI.app/Contents/Info.plist" | grep CrAppModeShortcutID
```
This returns an ID like `idemibpphagihbobmgmaojhjfidlfpdl`.
### 3. Overwrite the cached icons in browser profile
Chromium stores resized icons at:
`~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/Manifest Resources/{ID}/Icons/`
Overwrite every size using `sips`:
```bash
ICON_DIR="~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/Manifest Resources/{ID}/Icons"
SRC="packages/app/public/pwa-512x512.png"
for size in 32 48 64 96 128 192 256 512; do
sips -z $size $size "$SRC" --out "${ICON_DIR}/${size}.png"
done
```
### 4. Rebuild the macOS .icns in the .app bundle
```bash
ICONSET="/tmp/aiui.iconset"
mkdir -p "$ICONSET"
SRC="packages/app/public/pwa-512x512.png"
sips -z 16 16 "$SRC" --out "$ICONSET/icon_16x16.png"
sips -z 32 32 "$SRC" --out "$ICONSET/icon_16x16@2x.png"
sips -z 32 32 "$SRC" --out "$ICONSET/icon_32x32.png"
sips -z 64 64 "$SRC" --out "$ICONSET/icon_32x32@2x.png"
sips -z 128 128 "$SRC" --out "$ICONSET/icon_128x128.png"
sips -z 256 256 "$SRC" --out "$ICONSET/icon_128x128@2x.png"
sips -z 256 256 "$SRC" --out "$ICONSET/icon_256x256.png"
sips -z 512 512 "$SRC" --out "$ICONSET/icon_256x256@2x.png"
sips -z 512 512 "$SRC" --out "$ICONSET/icon_512x512.png"
cp "$SRC" "$ICONSET/icon_512x512@2x.png"
iconutil -c icns "$ICONSET" -o "~/Applications/Brave Browser Apps.localized/AIUI.app/Contents/Resources/app.icns"
```
### 5. Flush macOS icon cache
```bash
touch "~/Applications/Brave Browser Apps.localized/AIUI.app"
killall Finder
killall Dock
```
### 6. Bump PWA_CACHE_VERSION in main.ts
Increment the `PWA_CACHE_VERSION` constant — this nukes all SW caches on next page load for web-layer caching.
### 7. Delete stale build artifacts
Remove old `dist/` and `dev-dist/` SW/manifest files.
## Browser-Specific Paths
- **Brave**: `~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/`
- **Chrome**: `~/Library/Application Support/Google/Chrome/Default/Web Applications/`
- **PWA apps (Brave)**: `~/Applications/Brave Browser Apps.localized/`
- **PWA apps (Chrome)**: `~/Applications/Chrome Apps.localized/`
## Key Insight
Chromium assigns a permanent extension ID per origin (e.g., `localhost:5173`). This ID persists across uninstall/reinstall. The icon cache in `Manifest Resources/{ID}/Icons/` is populated ONCE and never refreshed from the manifest. The only fix is to overwrite the files directly on disk.
+6 -3
View File
@@ -2,6 +2,7 @@
<div class="h-dvh flex flex-col" :class="currentTheme" :style="rootStyle">
<RouterView />
<ArticleOverlay />
<VideoPlayerOverlay />
<PlayerBar v-if="!isMobile" />
<PassphraseDialog
:visible="showPassphrase"
@@ -20,6 +21,7 @@ import { useTheme } from '@/composables/useTheme'
import { useArchy } from '@/composables/useArchy'
import { useVisualViewport } from '@/composables/useVisualViewport'
import ArticleOverlay from '@/components/content/ArticleOverlay.vue'
import VideoPlayerOverlay from '@/components/player/VideoPlayerOverlay.vue'
import PlayerBar from '@/components/player/PlayerBar.vue'
import PassphraseDialog from '@/components/ui/PassphraseDialog.vue'
import {
@@ -37,10 +39,11 @@ const isMobile = computed(() => windowWidth.value < 1024)
const { viewportHeight, isKeyboardOpen } = useVisualViewport()
function onResize() { windowWidth.value = window.innerWidth }
// On mobile, bind height to visualViewport.height so the container
// actually shrinks when the keyboard opens (dvh doesn't do this reliably)
// On mobile, always bind height to visualViewport so the container
// respects the actual visible area (dvh doesn't reliably exclude
// Safari's bottom toolbar when body is position:fixed)
const rootStyle = computed(() => {
if (isMobile.value && isKeyboardOpen.value) {
if (isMobile.value && viewportHeight.value > 0) {
return { height: `${viewportHeight.value}px`, overflow: 'hidden' }
}
return {}
@@ -0,0 +1,136 @@
/**
* AIUI Guide — pre-loaded as a chat conversation so users can
* read it right in the chat window.
*/
export function guideToConversation(): {
id: string
title: string
messages: { id: string; role: string; content: string; timestamp: number }[]
createdAt: number
updatedAt: number
} {
const baseTime = 1772496000000
const guideContent = `# AIUI Guide — Your Node Assistant
AIUI is your AI assistant running directly on your Archipelago node. It can see your installed apps, read your files, check Bitcoin and Lightning status, and help you manage everything — all privately, with no data leaving your node.
---
## Node Awareness
AIUI automatically knows about your node setup. Just ask naturally:
- *"What apps do I have installed?"*
- *"Is my node connected to the network?"*
- *"What version of Archipelago am I running?"*
---
## File Browsing & Reading
AIUI can browse and read text files stored in your Nextcloud. Supported formats: \`.txt\`, \`.md\`, \`.json\`, \`.csv\`, \`.log\`, \`.yaml\`, \`.conf\`, \`.toml\`, \`.xml\`, \`.html\`, \`.css\`, \`.js\`, \`.ts\`, \`.py\`, \`.sh\`, and more.
- *"What files do I have?"*
- *"Read my config.yaml file"*
- *"Show me the contents of notes.md"*
- *"Summarize my todo.txt"*
> Files are read up to 100KB. Larger files are truncated. Binary files (images, videos) cannot be read as text.
---
## Bitcoin Node Status
If you have Bitcoin Core running, AIUI can check sync status, block height, and mempool info in real-time.
- *"How's my Bitcoin node doing?"*
- *"What block height am I on?"*
- *"Is my node fully synced?"*
- *"How many transactions are in the mempool?"*
---
## Lightning Network (LND)
AIUI can query your LND node for channels, peers, balances, and sync status. Private keys and macaroons are never exposed.
- *"What's my Lightning balance?"*
- *"How many channels do I have open?"*
- *"How many peers is my node connected to?"*
- *"Is my Lightning node synced?"*
---
## App Logs
When an app isn't working right, AIUI can pull recent log output to help diagnose issues.
- *"Why is Mempool not working?"*
- *"Show me the Bitcoin Core logs"*
- *"What errors is Nextcloud showing?"*
- *"Show me the last 100 lines of LND logs"*
---
## App Management
AIUI can help you navigate your node, open apps, and install new ones.
- *"Open Mempool"*
- *"Install BTCPay Server"*
- *"Take me to the Settings page"*
- *"What apps are available to install?"*
---
## Chat Features
- **Conversation History** — All chats saved locally. Use the history panel to switch between them.
- **Edit Messages** — Click any sent message to edit and re-send.
- **Branch Conversations** — Fork at any point to explore a different direction.
- **Web Search** — When enabled, AIUI searches the web for current info.
- **Image Support** — Attach images for visual questions.
---
## Privacy & Permissions
AIUI only accesses what you allow. Node data categories (apps, files, wallet, bitcoin, network, system) are permission-gated through the Archy permissions panel. All processing goes through your node's Claude proxy — your conversations and data never touch third-party servers beyond the AI API. Private keys, seeds, and macaroons are never exposed.
---
## Tips
- Be specific — *"Read my bitcoin.conf"* works better than *"show me config files"*
- AIUI remembers context within a conversation, so ask follow-ups
- If something seems wrong with an app, ask AIUI to check the logs first
- You can ask AIUI to explain what a config file does after reading it
- Use the history panel to return to previous conversations at any time
---
Try asking me something! For example: *"What apps do I have installed?"* or *"How's my Bitcoin node?"*`
return {
id: 'aiui-guide',
title: 'AIUI Guide',
messages: [
{
id: 'guide-q',
role: 'user',
content: 'How do I use AIUI?',
timestamp: baseTime,
},
{
id: 'guide-a',
role: 'assistant',
content: guideContent,
timestamp: baseTime + 2000,
},
],
createdAt: baseTime,
updatedAt: baseTime + 2000,
}
}
@@ -0,0 +1,269 @@
/**
* Node Demo Prompts — pre-populated conversations demonstrating
* AIUI's local node search and management capabilities.
*/
export interface NodeDemoPrompt {
id: string
userQuery: string
assistantResponse: string
}
export const nodeDemoPrompts: NodeDemoPrompt[] = [
{
id: 'node-apps',
userQuery: 'What apps do I have installed on my node?',
assistantResponse: `You have the following apps installed on your Archipelago node:
- **Bitcoin Core** (running) — Your Bitcoin full node, fully synced to the blockchain
- **LND** (running) — Lightning Network daemon for fast, cheap payments
- **Mempool** (running) — Visual Bitcoin blockchain explorer and mempool monitor
- **Nextcloud** (running) — Your private cloud storage for files, photos, and documents
- **BTCPay Server** (running) — Self-hosted payment processor for accepting Bitcoin
- **Tor** (running) — Anonymous network routing for privacy
All 6 apps are running normally. Would you like me to check the logs for any of them, or open one up?`,
},
{
id: 'node-bitcoin',
userQuery: "How's my Bitcoin node doing?",
assistantResponse: `Your Bitcoin Core node is looking healthy:
**Bitcoin Node Status**
- **Block Height:** 886,421
- **Sync Progress:** 100.00% (fully synced)
- **Chain:** mainnet
- **Mempool:** 42,318 transactions (68.2 MB)
Your node is fully caught up with the Bitcoin network. The mempool is moderately full — transaction fees are around 12-25 sat/vB for next-block confirmation. This is a typical level of activity.
Would you like me to check your Lightning node status as well?`,
},
{
id: 'node-lightning',
userQuery: "Yes, what's my Lightning balance?",
assistantResponse: `Here's your Lightning Network status:
**Lightning (LND)**
- **Node Alias:** ArchyNode-228
- **Channels:** 5 active channels
- **Peers:** 8 connected peers
- **Channel Balance:** 3,150,000 sats (in channels)
- **On-chain Balance:** 1,245,000 sats
- **Pending Open:** 0 sats
- **Sync:** Fully synced to chain
Your total Lightning capacity is about 3.15M sats across 5 channels, plus 1.24M sats on-chain. Everything looks healthy — all channels are active and your node is fully synced.
Your total Bitcoin holdings visible to the node: **4,395,000 sats** (~$4,395 at current rates).`,
},
{
id: 'node-files',
userQuery: 'What files do I have stored on my node?',
assistantResponse: `You have **47 files** in your Nextcloud storage. Here are the most recent:
- bitcoin.conf
- lnd.conf
- notes.md
- budget-2026.csv
- family-photos/ (folder)
- Documents/ (folder)
- backup-keys.txt
- todo.txt
- project-ideas.md
- recipes/ (folder)
Would you like me to read any of these files? I can open text files like .txt, .md, .json, .csv, .conf, .yaml, and more.`,
},
{
id: 'node-read-file',
userQuery: 'Read my todo.txt',
assistantResponse: `Here's the contents of **todo.txt** (342 bytes):
\`\`\`
Shopping List
- Groceries for the week
- New HDMI cable for the node monitor
- Backup hard drive (4TB minimum)
Node Tasks
- Open more Lightning channels
- Set up Ride The Lightning (RTL)
- Configure automatic channel backups
- Test BTCPay Server checkout flow
Personal
- Call dentist for appointment
- Renew domain registration
- Plan weekend hike
\`\`\`
Looks like you've got some node tasks on your list! I can help you with a few of those — I can open the app marketplace to install Ride The Lightning, or check your current channel backup configuration. Want me to help with any of these?`,
},
{
id: 'node-read-config',
userQuery: 'Show me my bitcoin.conf',
assistantResponse: `Here's your **bitcoin.conf** (512 bytes):
\`\`\`conf
# Bitcoin Core Configuration
server=1
daemon=1
txindex=1
# RPC Settings
rpcuser=archipelago
rpcpassword=archipelago123
rpcallowip=127.0.0.1
rpcbind=127.0.0.1
rpcport=8332
# Network
listen=1
maxconnections=40
maxuploadtarget=5000
# Mempool
maxmempool=300
mempoolexpiry=72
# Performance
dbcache=450
par=2
# ZMQ (for LND)
zmqpubrawblock=tcp://127.0.0.1:28332
zmqpubrawtx=tcp://127.0.0.1:28333
\`\`\`
Your config looks solid. A few observations:
- **txindex=1** is enabled, which is needed for Electrum Server and block explorers
- **dbcache=450** MB — reasonable for your hardware
- **ZMQ** is configured for LND to receive real-time block/tx notifications
- **maxuploadtarget=5000** MB/day limits upload bandwidth
Everything is properly set up for running LND and Mempool alongside Bitcoin Core.`,
},
{
id: 'node-app-logs',
userQuery: "Mempool seems slow, can you check what's going on?",
assistantResponse: `Let me check the Mempool logs for you.
Here are the last 50 lines of **Mempool** logs:
\`\`\`
[2026-03-05 14:23:01] Updating mempool stats...
[2026-03-05 14:23:02] Fetched 42318 transactions from Bitcoin Core
[2026-03-05 14:23:03] Block 886421 indexed successfully
[2026-03-05 14:23:05] WebSocket: 12 clients connected
[2026-03-05 14:23:10] Mining pool stats updated
[2026-03-05 14:23:15] Mempool size: 68.2 MB (42318 txs)
[2026-03-05 14:23:20] Fee estimation updated: 12-25 sat/vB
[2026-03-05 14:23:25] WARN: Electrs sync delay detected (2.3s)
[2026-03-05 14:23:30] Updating difficulty adjustment...
[2026-03-05 14:23:35] Hashrate calculation complete
\`\`\`
I see one warning: **Electrs sync delay of 2.3 seconds**. This is the Electrum Server indexer that Mempool uses for address lookups. A small delay like this is usually temporary and happens when:
1. A new block was just found (extra indexing work)
2. The mempool is particularly full (more data to process)
The rest of the logs look normal. Mempool is serving 12 active WebSocket connections. If the slowness persists, you might want to restart the Mempool container. Want me to do that?`,
},
{
id: 'node-open-app',
userQuery: 'Open Mempool so I can check it myself',
assistantResponse: `Opening **Mempool** for you now. The app should appear in a new view — you'll see the real-time mempool visualization, fee estimates, and recent blocks.
If it's still feeling slow, try refreshing the page. The Electrs sync delay I noticed earlier should resolve itself within a few minutes.`,
},
{
id: 'node-install',
userQuery: 'What other apps can I install?',
assistantResponse: `Here are some apps available in the Archipelago marketplace that you haven't installed yet:
**Recommended for your setup:**
- **Ride The Lightning (RTL)** — Visual Lightning node manager with channel management, payments, and routing fee settings. Great companion to LND.
- **ThunderHub** — Another Lightning dashboard with a modern UI. More advanced routing and rebalancing tools.
- **BOS (Balance of Satoshis)** — CLI tool for advanced Lightning channel management and rebalancing.
**Privacy & Communication:**
- **Nostr Relay** — Run your own Nostr relay for censorship-resistant social media
- **SimpleX Chat Server** — Private messaging server
**Other:**
- **Home Assistant** — Smart home automation
- **Gitea** — Self-hosted Git repository (like a private GitHub)
- **Vaultwarden** — Self-hosted password manager (Bitwarden compatible)
Since you mentioned wanting to set up RTL in your todo list, would you like me to install **Ride The Lightning** for you?`,
},
{
id: 'node-summary',
userQuery: 'Give me a full status summary of my node',
assistantResponse: `Here's your complete **Archipelago Node Status**:
---
**System**
- Archipelago v0.1.0
- Network: Connected
- Uptime: 14 days, 6 hours
**Bitcoin Core** — Running
- Block: 886,421 (100% synced)
- Mempool: 42,318 txs (68.2 MB)
- Connections: 38 peers
**Lightning (LND)** — Running
- Alias: ArchyNode-228
- Channels: 5 active, 0 pending
- Peers: 8 connected
- Channel balance: 3,150,000 sats
- On-chain balance: 1,245,000 sats
- Total: 4,395,000 sats
**Apps** — 6/6 running
- Bitcoin Core, LND, Mempool, Nextcloud, BTCPay Server, Tor
**Storage**
- Nextcloud: 47 files
- Blockchain: ~600 GB
---
Everything is running smoothly. No errors or warnings in the last 24 hours. Your node is fully synced, all channels are active, and all apps are healthy.`,
},
]
/**
* Build a single conversation with all node demo prompts as sequential messages.
*/
export function nodeDemoToConversation(): {
id: string
title: string
messages: { id: string; role: string; content: string; timestamp: number }[]
createdAt: number
updatedAt: number
} {
const baseTime = 1772492400000 // Slightly after seed prompts
const messages: { id: string; role: string; content: string; timestamp: number }[] = []
for (let i = 0; i < nodeDemoPrompts.length; i++) {
const prompt = nodeDemoPrompts[i]
const ts = baseTime + i * 120000 // 2 min between each exchange
messages.push(
{ id: `${prompt.id}-q`, role: 'user', content: prompt.userQuery, timestamp: ts },
{ id: `${prompt.id}-a`, role: 'assistant', content: prompt.assistantResponse, timestamp: ts + 5000 },
)
}
return {
id: 'node-demo',
title: 'Exploring My Node',
messages,
createdAt: baseTime,
updatedAt: baseTime + nodeDemoPrompts.length * 120000,
}
}
@@ -177,7 +177,7 @@ defineEmits<{
const chatStore = useChatStore()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel } = useAI()
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
import { useVisualViewport } from '@/composables/useVisualViewport'
const codeContext = useCodeContext()
@@ -364,6 +364,21 @@ async function handleSend(text: string, images: ImageAttachment[] = []) {
return
}
if (trimmed === '/freefilms') {
const { freeFilms } = await import('@/data/freeFilms')
panelFilms.value = freeFilms
panelTitle.value = 'Free Documentary Films'
panelOpen.value = true
availableTabs.value = ['film', 'prompt']
setActiveTab('film')
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/freefilms' })
chatStore.addMessage(convId, { role: 'assistant', content: `Browse ${freeFilms.length} free documentary films from InDeeHub. Click any film to see details, and hit play to watch.` })
}
return
}
if (trimmed === '/code exit' || trimmed === '/exit') {
if (codeContext.isCodeMode.value) {
codeContext.exitCodeMode()
@@ -100,6 +100,7 @@ const BUILT_IN_COMMANDS: PaletteCommand[] = [
{ id: 'cmd-design', slash: '/design', title: 'Design System', preview: 'Open the design system viewer' },
{ id: 'cmd-search', slash: '/search ', title: 'Search', preview: 'Search your content library' },
{ id: 'cmd-seed', slash: '/seed', title: 'Seed', preview: 'Load seed conversations for all content types' },
{ id: 'cmd-freefilms', slash: '/freefilms', title: 'Free Films', preview: 'Browse free documentary films from InDeeHub' },
]
const props = defineProps<{
@@ -53,7 +53,8 @@
@click="$emit('selectBook', book)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(book) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(book) && !failedCovers.has(book.id)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(book)"
:src="coverSrc(book)!"
@@ -63,7 +64,7 @@
@error="onCoverError(book)"
/>
<div
v-else
v-else-if="failedCovers.has(book.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(book)})` }"
/>
@@ -102,7 +103,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { ref, computed, reactive, watch } from 'vue'
import type { Book } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateBookCoverFallback, fetchBookCover } from '@/composables/useImageFallback'
@@ -138,15 +139,20 @@ function onCoverError(book: Book) {
function fetchCoversFor(books: Book[]) {
for (const book of books) {
if (book.coverUrl || fetchedCovers.has(book.id)) continue
if (book.coverUrl || fetchedCovers.has(book.id) || failedCovers.value.has(book.id)) continue
fetchBookCover(book.title, book.author).then((url) => {
if (url) fetchedCovers.set(book.id, url)
}).catch(() => {})
if (url) {
fetchedCovers.set(book.id, url)
} else {
failedCovers.value = new Set([...failedCovers.value, book.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, book.id])
})
}
}
onMounted(() => fetchCoversFor(props.books))
watch(() => props.books, (books) => fetchCoversFor(books), { immediate: false })
watch(() => props.books, (books) => fetchCoversFor(books), { immediate: true })
const topGenres = computed(() => {
const counts = new Map<string, number>()
@@ -24,6 +24,19 @@
</svg>
</button>
<button
v-if="playableSource"
class="absolute inset-0 flex items-center justify-center z-[5] group/play"
aria-label="Watch film"
@click="openVideo"
>
<span class="w-20 h-20 rounded-full flex items-center justify-center path-glass-icon group-hover/play:scale-110 transition-transform">
<svg class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</span>
</button>
<div class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ film.title }}</h2>
<div class="flex items-center gap-2 mt-1 text-xs text-white/60">
@@ -108,14 +121,25 @@ import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useBannerFallback } from '@/composables/useBannerFallback'
import { fetchFilmImage } from '@/composables/useImageFallback'
import { useVideoPlayerStore } from '@/stores/videoPlayer'
const props = defineProps<{ film: Film }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const videoStore = useVideoPlayerStore()
const isExternal = computed(() => props.film.id.startsWith('ext-'))
const playableSource = computed(() =>
props.film.sources.find(s => s.type === 'youtube' || s.url.includes('youtube.com'))
)
function openVideo() {
if (!playableSource.value) return
videoStore.open(playableSource.value.url, props.film.title, props.film.posterUrl || props.film.backdropUrl)
}
const { bannerSrc, fallbackGradient, onBannerError } = useBannerFallback({
primaryUrls: () => [props.film.backdropUrl, props.film.posterUrl],
apiFetch: () => fetchFilmImage(props.film.title, props.film.year),
@@ -53,7 +53,8 @@
@click="$emit('selectFilm', film)"
>
<div class="poster-card flex-1 min-h-0">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(film) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(film) && !failedCovers.has(film.id)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(film)"
:src="coverSrc(film)!"
@@ -63,7 +64,7 @@
@error="onCoverError(film)"
/>
<div
v-else
v-else-if="failedCovers.has(film.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(film)})` }"
/>
@@ -103,7 +104,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { ref, computed, reactive, watch } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { handleImgError, fetchFilmImage, generatePosterFallback } from '@/composables/useImageFallback'
@@ -125,7 +126,7 @@ const fetchedCovers = reactive<Map<string, string>>(new Map())
function coverSrc(film: Film): string | null {
if (failedCovers.value.has(film.id)) return null
const url = film.posterUrl || fetchedCovers.get(film.id)
const url = film.posterUrl || film.backdropUrl || fetchedCovers.get(film.id)
return url || null
}
@@ -140,15 +141,20 @@ function onCoverError(film: Film) {
function fetchCoversFor(films: Film[]) {
for (const film of films) {
if (film.posterUrl || fetchedCovers.has(film.id)) continue
if (film.posterUrl || fetchedCovers.has(film.id) || failedCovers.value.has(film.id)) continue
fetchFilmImage(film.title, film.year).then((result) => {
if (result.posterUrl) fetchedCovers.set(film.id, result.posterUrl)
}).catch(() => {})
if (result.posterUrl) {
fetchedCovers.set(film.id, result.posterUrl)
} else {
failedCovers.value = new Set([...failedCovers.value, film.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, film.id])
})
}
}
onMounted(() => fetchCoversFor(props.films))
watch(() => props.films, (films) => fetchCoversFor(films), { immediate: false })
watch(() => props.films, (films) => fetchCoversFor(films), { immediate: true })
const topGenres = computed(() => {
const counts = new Map<string, number>()
@@ -159,8 +159,21 @@
@click="selectedNoteId = note.id"
>
<div class="flex items-start gap-2.5">
<div class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-xs font-bold bg-purple-500/20 text-purple-400">
{{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
<div class="w-8 h-8 rounded-full shrink-0 overflow-hidden">
<img
v-if="note.authorPicture && !failedAvatars.has(note.pubkey)"
:src="note.authorPicture"
:alt="note.authorName ?? 'profile'"
class="w-full h-full object-cover"
loading="lazy"
@error="failedAvatars.add(note.pubkey)"
/>
<div
v-else
class="w-full h-full flex items-center justify-center text-xs font-bold bg-purple-500/20 text-purple-400"
>
{{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
@@ -270,6 +283,7 @@ const subTabs = [
]
const selectedNoteId = ref<string | null>(null)
const failedAvatars = reactive(new Set<string>())
const search = ref('')
const activeKind = ref<number | null>(null)
const showCompose = ref(false)
@@ -53,7 +53,8 @@
@click="$emit('selectPodcast', podcast)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(podcast) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(podcast) && !failedCovers.has(podcast.id)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(podcast)"
:src="coverSrc(podcast)!"
@@ -63,7 +64,7 @@
@error="onCoverError(podcast)"
/>
<div
v-else
v-else-if="failedCovers.has(podcast.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(podcast)})` }"
/>
@@ -100,7 +101,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { ref, computed, reactive, watch } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback, fetchPodcastCover } from '@/composables/useImageFallback'
@@ -127,15 +128,20 @@ function coverSrc(podcast: Podcast): string | null {
function fetchCoversFor(podcasts: Podcast[]) {
for (const podcast of podcasts) {
if (podcast.coverUrl || fetchedCovers.has(podcast.id)) continue
if (podcast.coverUrl || fetchedCovers.has(podcast.id) || failedCovers.value.has(podcast.id)) continue
fetchPodcastCover(podcast.title, podcast.host).then((url) => {
if (url) fetchedCovers.set(podcast.id, url)
}).catch(() => {})
if (url) {
fetchedCovers.set(podcast.id, url)
} else {
failedCovers.value = new Set([...failedCovers.value, podcast.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, podcast.id])
})
}
}
onMounted(() => fetchCoversFor(props.podcasts))
watch(() => props.podcasts, (p) => fetchCoversFor(p), { immediate: false })
watch(() => props.podcasts, (p) => fetchCoversFor(p), { immediate: true })
function fallbackFor(podcast: Podcast): string {
return generatePodcastCoverFallback(podcast.title, podcast.host)
@@ -153,12 +153,7 @@ const q = computed(() =>
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' },
{ name: 'Wavlake', url: `https://wavlake.com/search?q=${q.value}`, icon: '', desc: 'Lightning-powered music' },
]
return links
})
@@ -50,14 +50,15 @@
:key="song.id"
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
:aria-label="`${song.title} by ${song.artist}`"
@click="$emit('selectSong', song)"
@click="emit('selectSong', song)"
>
<div class="cover-card flex-1 min-h-0 relative flex items-center justify-center">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(song) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(song) && !failedCovers.has(song.id)" class="absolute inset-0 animate-shimmer" />
<button
class="absolute inset-0 bottom-10 flex items-center justify-center z-10 backdrop-blur-sm bg-black/30 opacity-0 group-hover:opacity-100 transition-all duration-200"
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"
aria-label="Play"
@click.stop="onPlayClick(song)"
@click.stop="play(song); emit('selectSong', 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">
@@ -74,7 +75,7 @@
@error="onCoverError(song)"
/>
<div
v-else
v-else-if="failedCovers.has(song.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(song)})` }"
/>
@@ -111,7 +112,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { ref, computed, reactive, watch } from 'vue'
import type { Song } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { usePlayer } from '@/composables/usePlayer'
@@ -124,7 +125,7 @@ const props = withDefaults(defineProps<{
title: 'Recommended Songs',
})
defineEmits<{ selectSong: [song: Song] }>()
const emit = defineEmits<{ selectSong: [song: Song] }>()
const { isDark } = useTheme()
const { play } = usePlayer()
@@ -148,21 +149,22 @@ 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
if (song.coverUrl || fetchedCovers.has(song.id) || failedCovers.value.has(song.id)) continue
fetchMusicCover(song.title, song.artist, song.album).then((url) => {
if (url) fetchedCovers.set(song.id, url)
}).catch(() => {})
if (url) {
fetchedCovers.set(song.id, url)
} else {
failedCovers.value = new Set([...failedCovers.value, song.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, song.id])
})
}
}
onMounted(() => fetchCoversFor(props.songs))
watch(() => props.songs, (songs) => fetchCoversFor(songs), { immediate: false })
watch(() => props.songs, (songs) => fetchCoversFor(songs), { immediate: true })
const topGenres = computed(() => {
const counts = new Map<string, number>()
@@ -53,7 +53,8 @@
@click="$emit('selectSeries', s)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(s) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(s) && !failedCovers.has(s.id)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(s)"
:src="coverSrc(s)!"
@@ -63,7 +64,7 @@
@error="onCoverError(s)"
/>
<div
v-else
v-else-if="failedCovers.has(s.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(s)})` }"
/>
@@ -114,7 +115,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { ref, computed, reactive, watch } from 'vue'
import type { TVSeries } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateTVSeriesFallback, fetchTVImage } from '@/composables/useImageFallback'
@@ -136,7 +137,7 @@ const fetchedCovers = reactive<Map<string, string>>(new Map())
function coverSrc(s: TVSeries): string | null {
if (failedCovers.value.has(s.id)) return null
return s.posterUrl || fetchedCovers.get(s.id) || null
return s.posterUrl || s.backdropUrl || fetchedCovers.get(s.id) || null
}
function fallbackFor(s: TVSeries): string {
@@ -157,15 +158,20 @@ function yearDisplay(s: TVSeries): string {
function fetchCoversFor(list: TVSeries[]) {
for (const s of list) {
if (s.posterUrl || fetchedCovers.has(s.id)) continue
if (s.posterUrl || fetchedCovers.has(s.id) || failedCovers.value.has(s.id)) continue
fetchTVImage(s.title, s.year).then((result) => {
if (result.posterUrl) fetchedCovers.set(s.id, result.posterUrl)
}).catch(() => {})
if (result.posterUrl) {
fetchedCovers.set(s.id, result.posterUrl)
} else {
failedCovers.value = new Set([...failedCovers.value, s.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, s.id])
})
}
}
onMounted(() => fetchCoversFor(props.series))
watch(() => props.series, (list) => fetchCoversFor(list), { immediate: false })
watch(() => props.series, (list) => fetchCoversFor(list), { immediate: true })
const topGenres = computed(() => {
const counts = new Map<string, number>()
+140 -62
View File
@@ -6,7 +6,7 @@
props.variant === 'fixed'
? 'fixed bottom-0 left-0 right-0 z-[999]'
: 'w-full shrink-0',
'flex items-center gap-4 px-4 py-3 path-glass-card rounded-none border-t-0 border-x-0 shadow-2xl'
'path-glass-card !rounded-none'
]"
>
<!-- Plyr container: YouTube requires min 200x200px. Kept off-screen but sized. -->
@@ -16,99 +16,176 @@
aria-hidden="true"
/>
<div class="flex items-center gap-3 min-w-0 flex-1 max-w-[280px]">
<!-- Compact layout: mini-player (mobile) -->
<div v-if="props.compact" class="flex flex-col">
<!-- Scrubber bar (full width, thin) -->
<div
class="w-12 h-12 rounded-lg overflow-hidden shrink-0 flex items-center justify-center path-glass-icon"
class="w-full h-1 cursor-pointer bg-white/10"
@click="onScrubberClick"
>
<img
v-if="coverUrl"
:src="coverUrl"
:alt="currentSong!.title"
class="w-full h-full object-cover"
<div
class="h-full bg-accent transition-all duration-150"
:style="{ width: `${progress}%` }"
/>
<span v-else class="text-lg">🎵</span>
</div>
<div class="min-w-0">
<p class="text-sm font-semibold truncate text-white/90">
{{ currentSong!.title }}
</p>
<p class="text-xs truncate text-white/50">
{{ 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">
<!-- Previous button -->
<!-- Cover + info + controls -->
<div class="flex items-center gap-3 px-3 py-2">
<div class="w-10 h-10 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-base">🎵</span>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold truncate text-white/90">{{ currentSong!.title }}</p>
<p class="text-xs truncate text-white/50">{{ currentSong!.artist }}</p>
</div>
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center shrink-0 transition-all hover:scale-105 active:scale-95"
:class="hasPrevious ? 'text-white/70 hover:text-white/90' : 'text-white/20'"
class="w-11 h-11 rounded-full flex items-center justify-center shrink-0 active:scale-95"
:class="hasPrevious ? 'text-white/70' : 'text-white/20'"
:disabled="!hasPrevious"
aria-label="Previous track"
@click="playPrevious"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z" />
</svg>
</button>
<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"
class="w-12 h-12 rounded-full flex items-center justify-center glass-button shrink-0 active:scale-95"
aria-label="Play or pause"
@click="toggle"
>
<svg v-if="isLoading" class="w-5 h-5 animate-spin text-white/90" fill="none" viewBox="0 0 24 24">
<svg v-if="isLoading" class="w-6 h-6 animate-spin text-white/90" 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 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<svg v-else-if="isPlaying" class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-5 h-5 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<svg v-else class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</button>
<!-- Next button -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center shrink-0 transition-all hover:scale-105 active:scale-95"
:class="hasNext ? 'text-white/70 hover:text-white/90' : 'text-white/20'"
class="w-11 h-11 rounded-full flex items-center justify-center shrink-0 active:scale-95"
:class="hasNext ? 'text-white/70' : 'text-white/20'"
:disabled="!hasNext"
aria-label="Next track"
@click="playNext"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" />
</svg>
</button>
<span class="text-xs font-mono tabular-nums text-white/40">
{{ formatTime(currentTime) }}
</span>
<div
class="flex-1 h-1.5 rounded-full cursor-pointer group bg-white/15"
@click="onScrubberClick"
<button
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 active:scale-95 text-white/40"
aria-label="Close player"
@click="clear"
>
<div
class="h-full rounded-full transition-all duration-150 group-hover:h-2 bg-accent"
:style="{ width: `${progress}%` }"
/>
</div>
<span class="text-xs font-mono tabular-nums text-white/40">
{{ formatTime(duration) }}
</span>
<svg class="w-4 h-4" 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>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<p v-if="error" class="text-xs text-red-400 px-3 pb-2">{{ error }}</p>
</div>
<span
v-if="queue.length > 1"
class="text-xs font-mono tabular-nums shrink-0 text-white/30"
>{{ queue.length }} songs</span>
<button
class="touch-target rounded-xl 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 text-white/70" 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>
<!-- Desktop layout: full controls with scrubber -->
<div v-else class="flex items-center gap-4 px-4 py-3">
<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 text-white/90">{{ currentSong!.title }}</p>
<p class="text-xs truncate text-white/50">{{ 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="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center shrink-0 transition-all hover:scale-105 active:scale-95"
:class="hasPrevious ? 'text-white/70 hover:text-white/90' : 'text-white/20'"
:disabled="!hasPrevious"
aria-label="Previous track"
@click="playPrevious"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z" />
</svg>
</button>
<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"
aria-label="Play or pause"
@click="toggle"
>
<svg v-if="isLoading" class="w-6 h-6 animate-spin text-white/90" 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-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</button>
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center shrink-0 transition-all hover:scale-105 active:scale-95"
:class="hasNext ? 'text-white/70 hover:text-white/90' : 'text-white/20'"
:disabled="!hasNext"
aria-label="Next track"
@click="playNext"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" />
</svg>
</button>
<span class="text-xs font-mono tabular-nums text-white/40">
{{ formatTime(currentTime) }}
</span>
<div
class="flex-1 h-1.5 rounded-full cursor-pointer group bg-white/15"
@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-xs font-mono tabular-nums text-white/40">
{{ formatTime(duration) }}
</span>
</div>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
</div>
<span
v-if="queue.length > 1"
class="text-xs font-mono tabular-nums shrink-0 text-white/30"
>{{ queue.length }} songs</span>
<button
class="touch-target rounded-xl path-glass-button path-glass-button-sm shrink-0 transition-all hover:scale-105"
aria-label="Close player"
@click="clear"
title="Close player"
>
<svg class="w-4 h-4 text-white/70" 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>
</div>
</Transition>
</template>
@@ -120,7 +197,8 @@ import { fetchMusicCover } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
variant?: 'fixed' | 'inline'
}>(), { variant: 'fixed' })
compact?: boolean
}>(), { variant: 'fixed', compact: false })
const {
currentSong,
@@ -0,0 +1,516 @@
<template>
<Teleport to="body">
<Transition name="video-player">
<div
v-if="store.isOpen"
ref="containerRef"
class="fixed inset-0 z-[2500] flex flex-col bg-black"
:class="controlsVisible ? '' : 'cursor-none'"
@mousemove="showControls"
@touchstart.passive="showControls"
@keydown="onKeydown"
@click="onContainerClick"
tabindex="0"
>
<!-- Video area -->
<div class="flex-1 min-h-0 relative flex items-center justify-center">
<div
ref="playerRef"
class="w-full h-full"
/>
<!-- Big center play/pause indicator (flashes on toggle) -->
<Transition name="center-icon">
<div
v-if="showCenterIcon"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="w-20 h-20 rounded-full flex items-center justify-center bg-black/40 backdrop-blur-sm">
<svg v-if="isPlaying" class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</div>
</div>
</Transition>
<!-- Loading spinner -->
<div v-if="isBuffering" class="absolute inset-0 flex items-center justify-center pointer-events-none">
<svg class="w-12 h-12 animate-spin text-white/60" 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>
</div>
</div>
<!-- Controls overlay bottom bar -->
<div
class="absolute bottom-0 left-0 right-0 transition-all duration-300 z-20"
:class="controlsVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'"
>
<!-- Gradient fade above controls -->
<div class="h-24 bg-gradient-to-t from-black/80 to-transparent pointer-events-none" />
<div class="path-glass-card !rounded-none px-4 py-3 space-y-2">
<!-- Scrubber -->
<div
ref="scrubberRef"
class="group w-full h-1.5 rounded-full cursor-pointer bg-white/15 transition-all hover:h-2.5"
@click="onScrubberClick"
@mousedown="onScrubberDragStart"
>
<div
class="h-full bg-accent rounded-full transition-[width] duration-75 relative"
:style="{ width: `${progress}%` }"
>
<div class="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white shadow-md opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
<!-- Controls row -->
<div class="flex items-center gap-3">
<!-- Title -->
<div class="min-w-0 flex-1 max-w-[280px]">
<p class="text-sm font-semibold truncate text-white/90">{{ store.title }}</p>
<p class="text-xs truncate text-white/40">Free Documentary</p>
</div>
<!-- Center controls -->
<div class="flex items-center gap-2 flex-1 justify-center">
<!-- Rewind 10s -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/70 hover:text-white/90 transition-all hover:scale-105 active:scale-95"
aria-label="Rewind 10 seconds"
@click.stop="seek(-10)"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" />
</svg>
</button>
<!-- Play/Pause -->
<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"
aria-label="Play or pause"
@click.stop="toggle"
>
<svg v-if="isPlaying" class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</button>
<!-- Forward 10s -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/70 hover:text-white/90 transition-all hover:scale-105 active:scale-95"
aria-label="Forward 10 seconds"
@click.stop="seek(10)"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z" />
</svg>
</button>
<!-- Time -->
<span class="text-xs font-mono tabular-nums text-white/40 ml-1">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
</div>
<!-- Right controls -->
<div class="flex items-center gap-1 shrink-0">
<!-- Fullscreen -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/60 hover:text-white/90 transition-colors"
aria-label="Toggle fullscreen"
@click.stop="toggleFullscreen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
</button>
<!-- Close -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/60 hover:text-white/90 transition-colors"
aria-label="Close video player"
@click.stop="closePlayer"
>
<svg class="w-5 h-5" 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>
</div>
</div>
</div>
<!-- Top bar close button (visible with controls) -->
<div
class="absolute top-0 left-0 right-0 transition-all duration-300 z-20"
:class="controlsVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'"
>
<div class="h-16 bg-gradient-to-b from-black/60 to-transparent flex items-start justify-end px-4 pt-3">
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/70 hover:text-white/90 hover:bg-white/10 transition-colors"
aria-label="Close"
@click.stop="closePlayer"
>
<svg class="w-6 h-6" 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>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useVideoPlayerStore } from '@/stores/videoPlayer'
const store = useVideoPlayerStore()
const containerRef = ref<HTMLElement | null>(null)
const playerRef = ref<HTMLElement | null>(null)
const scrubberRef = ref<HTMLElement | null>(null)
const isPlaying = ref(false)
const isBuffering = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const progress = ref(0)
const controlsVisible = ref(true)
const showCenterIcon = ref(false)
let hideTimer: ReturnType<typeof setTimeout> | null = null
let rafId: number | null = null
let ytPlayer: any = null
let centerIconTimer: ReturnType<typeof setTimeout> | null = null
// YouTube IFrame API
let ytApiReady = false
const ytApiCallbacks: (() => void)[] = []
function loadYouTubeApi(): Promise<void> {
if (ytApiReady) return Promise.resolve()
return new Promise((resolve) => {
if ((window as any).YT?.Player) {
ytApiReady = true
resolve()
return
}
ytApiCallbacks.push(resolve)
if (!document.getElementById('yt-iframe-api')) {
const tag = document.createElement('script')
tag.id = 'yt-iframe-api'
tag.src = 'https://www.youtube.com/iframe_api'
document.head.appendChild(tag)
;(window as any).onYouTubeIframeAPIReady = () => {
ytApiReady = true
ytApiCallbacks.forEach(cb => cb())
ytApiCallbacks.length = 0
}
}
})
}
function extractYouTubeId(url: string): string | null {
// Handle embed URLs: youtube.com/embed/VIDEO_ID
const embedMatch = url.match(/\/embed\/([a-zA-Z0-9_-]{11})/)
if (embedMatch) return embedMatch[1]
// Handle watch URLs: youtube.com/watch?v=VIDEO_ID
const watchMatch = url.match(/[?&]v=([a-zA-Z0-9_-]{11})/)
if (watchMatch) return watchMatch[1]
// Handle youtu.be/VIDEO_ID
const shortMatch = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/)
if (shortMatch) return shortMatch[1]
return null
}
async function initPlayer(url: string) {
const videoId = extractYouTubeId(url)
if (!videoId || !playerRef.value) return
isBuffering.value = true
await loadYouTubeApi()
const YT = (window as any).YT
// Create a div target inside playerRef
const target = document.createElement('div')
target.id = 'yt-video-player'
playerRef.value.innerHTML = ''
playerRef.value.appendChild(target)
ytPlayer = new YT.Player('yt-video-player', {
videoId,
width: '100%',
height: '100%',
playerVars: {
autoplay: 1,
controls: 0,
modestbranding: 1,
rel: 0,
showinfo: 0,
iv_load_policy: 3,
fs: 0,
playsinline: 1,
},
events: {
onReady: () => {
isBuffering.value = false
isPlaying.value = true
startPolling()
showControls()
},
onStateChange: (e: any) => {
const state = e.data
// -1: unstarted, 0: ended, 1: playing, 2: paused, 3: buffering, 5: cued
isPlaying.value = state === 1
isBuffering.value = state === 3
if (state === 0) {
// Video ended
isPlaying.value = false
controlsVisible.value = true
}
},
},
})
}
function startPolling() {
if (rafId) cancelAnimationFrame(rafId)
function poll() {
if (ytPlayer?.getCurrentTime && ytPlayer?.getDuration) {
currentTime.value = ytPlayer.getCurrentTime() ?? 0
duration.value = ytPlayer.getDuration() ?? 0
progress.value = duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
}
rafId = requestAnimationFrame(poll)
}
poll()
}
function stopPolling() {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
}
function destroyPlayer() {
stopPolling()
if (ytPlayer?.destroy) {
try { ytPlayer.destroy() } catch {}
}
ytPlayer = null
if (playerRef.value) playerRef.value.innerHTML = ''
isPlaying.value = false
isBuffering.value = false
currentTime.value = 0
duration.value = 0
progress.value = 0
}
// Controls
function toggle() {
if (!ytPlayer) return
if (isPlaying.value) {
ytPlayer.pauseVideo()
} else {
ytPlayer.playVideo()
}
flashCenterIcon()
}
function seek(seconds: number) {
if (!ytPlayer?.seekTo) return
const target = Math.max(0, Math.min(duration.value, currentTime.value + seconds))
ytPlayer.seekTo(target, true)
showControls()
}
function seekToPercent(percent: number) {
if (!ytPlayer?.seekTo || duration.value <= 0) return
const target = (percent / 100) * duration.value
ytPlayer.seekTo(target, true)
}
function onScrubberClick(e: MouseEvent) {
if (!scrubberRef.value) return
const rect = scrubberRef.value.getBoundingClientRect()
const percent = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100))
seekToPercent(percent)
}
let isDragging = false
function onScrubberDragStart(e: MouseEvent) {
isDragging = true
onScrubberClick(e)
const onMove = (ev: MouseEvent) => { if (isDragging) onScrubberClick(ev) }
const onUp = () => {
isDragging = false
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
function toggleFullscreen() {
if (!containerRef.value) return
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
containerRef.value.requestFullscreen()
}
}
function closePlayer() {
destroyPlayer()
store.close()
}
function flashCenterIcon() {
showCenterIcon.value = true
if (centerIconTimer) clearTimeout(centerIconTimer)
centerIconTimer = setTimeout(() => {
showCenterIcon.value = false
}, 600)
}
function onContainerClick(e: MouseEvent) {
// Only toggle on direct click on video area (not controls)
const target = e.target as HTMLElement
if (target === containerRef.value || target.closest('.flex-1.min-h-0')) {
toggle()
}
}
// Auto-hide controls
function showControls() {
controlsVisible.value = true
resetHideTimer()
}
function resetHideTimer() {
if (hideTimer) clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
if (isPlaying.value) controlsVisible.value = false
}, 3000)
}
// Keyboard shortcuts
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
closePlayer()
return
}
if (e.key === ' ' || e.key === 'k') {
e.preventDefault()
toggle()
showControls()
return
}
if (e.key === 'ArrowLeft') {
e.preventDefault()
seek(-10)
return
}
if (e.key === 'ArrowRight') {
e.preventDefault()
seek(10)
return
}
if (e.key === 'f') {
e.preventDefault()
toggleFullscreen()
return
}
}
function formatTime(seconds: number): string {
const s = Math.floor(seconds)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
return `${m}:${String(sec).padStart(2, '0')}`
}
// Global escape handler (captures even when focus is elsewhere)
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && store.isOpen) {
e.preventDefault()
e.stopPropagation()
closePlayer()
}
}
// Watch store open/close
watch(() => store.isOpen, async (open) => {
if (open) {
await nextTick()
containerRef.value?.focus()
initPlayer(store.videoUrl)
showControls()
} else {
destroyPlayer()
}
})
onMounted(() => {
window.addEventListener('keydown', onGlobalKeydown, true)
})
onBeforeUnmount(() => {
destroyPlayer()
window.removeEventListener('keydown', onGlobalKeydown, true)
if (hideTimer) clearTimeout(hideTimer)
if (centerIconTimer) clearTimeout(centerIconTimer)
})
</script>
<style scoped>
.video-player-enter-active,
.video-player-leave-active {
transition: opacity 0.25s ease;
}
.video-player-enter-from,
.video-player-leave-to {
opacity: 0;
}
.center-icon-enter-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.center-icon-leave-active {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.center-icon-enter-from {
opacity: 0;
transform: scale(0.8);
}
.center-icon-leave-to {
opacity: 0;
transform: scale(1.3);
}
/* Make YouTube iframe fill container */
:deep(iframe) {
width: 100% !important;
height: 100% !important;
border: 0;
pointer-events: none;
}
</style>
+51 -1
View File
@@ -31,6 +31,47 @@ const podcastContext = mockPodcasts.map((p) =>
`- [${p.id}] "${p.title}" by ${p.host ?? 'Unknown'}${p.year ? ` (${p.year})` : ''} | ${(p.genres ?? []).join(', ')} | On: ${p.sources.map(x => x.type).join(', ')}`
).join('\n')
// ─── Wavlake catalog context (fetched at runtime) ────────────
interface WavlakeCatalogTrack {
title?: string
artist?: string
albumTitle?: string
duration?: number
}
const wavlakeCatalog = ref<WavlakeCatalogTrack[]>([])
let wavlakeFetchedAt = 0
const WAVLAKE_REFRESH_INTERVAL = 30 * 60 * 1000 // 30 minutes
async function refreshWavlakeCatalog() {
if (Date.now() - wavlakeFetchedAt < WAVLAKE_REFRESH_INTERVAL && wavlakeCatalog.value.length > 0) return
try {
const BASE = import.meta.env.BASE_URL || '/'
const res = await fetch(`${BASE}api/music/rankings?days=30&limit=40`)
if (!res.ok) return
const data = await res.json()
if (Array.isArray(data)) {
wavlakeCatalog.value = data.map((t: Record<string, unknown>) => ({
title: t.title as string,
artist: t.artist as string,
albumTitle: t.albumTitle as string | undefined,
duration: t.duration as number | undefined,
}))
wavlakeFetchedAt = Date.now()
}
} catch {
// Silently fail — catalog is optional context
}
}
function buildWavlakeContext(): string {
if (wavlakeCatalog.value.length === 0) return ''
const lines = wavlakeCatalog.value.map((t) =>
`- "${t.title}" by ${t.artist}${t.albumTitle ? ` (${t.albumTitle})` : ''}`
)
return `\n\n**Wavlake trending tracks** (these are confirmed playable — prefer recommending from this list when relevant):\n${lines.join('\n')}`
}
const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library (films, songs, and podcasts).
**News/Factual queries:** When the user asks for "news", "latest", "recent", or current information, lead with a direct answer summarizing the news/facts. You MAY add "For deeper coverage:" with [[podcast_ext:...]] tags only. Do NOT use [[song_ext:...]] or [[film_ext:...]] for news queries—podcasts are the appropriate follow-up. Never substitute an answer with only recommendations.
@@ -55,7 +96,7 @@ Prioritize Podcasting 2.0friendly platforms: Fountain.fm, Podcast Index, Cast
**Websites / "Best places to check":** When listing resources, places to check online, or websites for the user to visit, use markdown links: [Name](https://full-url). For simple domains use **Name** (domain.com), e.g. **Bitcoin Mailing List** (gnusha.org).
**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.
**Music discovery:** All music plays from **Wavlake** — a Lightning-powered, Nostr-native music platform. When recommending songs, prefer tracks from the Wavlake trending list (provided below) since those are confirmed playable. For genre requests, use [[song_ext:Title|Artist]] tags — the UI will search Wavlake automatically. Songs not on Wavlake won't play, so stick to Wavlake artists when you can. The user can zap (tip) artists with Lightning directly through the platform.
Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out.
@@ -344,6 +385,9 @@ function buildSystemPrompt(chatStore: ReturnType<typeof useChatStore>): string {
}
}
// Append Wavlake catalog context
prompt += buildWavlakeContext()
// Append memory facts
const memoryStore = useMemoryStore()
prompt += memoryStore.buildMemoryContext()
@@ -470,6 +514,9 @@ export async function streamWithModel(
export function useAI() {
const chatStore = useChatStore()
// Fetch Wavlake catalog on first use (non-blocking)
refreshWavlakeCatalog()
function stopGeneration() {
if (currentAbort) {
currentAbort.abort()
@@ -479,6 +526,9 @@ export function useAI() {
}
async function sendMessage(userText: string, images?: ImageAttachment[]) {
// Refresh Wavlake catalog if stale (non-blocking, fire-and-forget)
refreshWavlakeCatalog()
const provider = activeProvider.value
currentAbort = new AbortController()
const signal = currentAbort.signal
+75 -14
View File
@@ -1,7 +1,7 @@
import { ref, readonly } from 'vue'
import { archyBridge } from '@/services/archyBridge'
type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files'
type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files' | 'bitcoin'
interface ArchyApp {
id: string
@@ -20,10 +20,17 @@ interface ArchyNetworkInfo {
}
export interface ArchyWalletInfo {
balanceSats?: number
channelCount?: number
totalCapacitySats?: number
nodePubkey?: string
available?: boolean
status?: string
alias?: string
num_active_channels?: number
num_peers?: number
synced_to_chain?: boolean
block_height?: number
balance_sats?: number
channel_balance_sats?: number
pending_open_balance?: number
message?: string
}
export interface ArchyFileEntry {
@@ -34,6 +41,15 @@ export interface ArchyFileEntry {
type: 'file' | 'folder'
}
export interface ArchyBitcoinInfo {
available: boolean
block_height?: number
sync_progress?: number
chain?: string
mempool_tx_count?: number
mempool_size?: number
}
// Singleton reactive state (shared across all components using this composable)
const isEmbedded = ref(false)
const isInitialized = ref(false)
@@ -44,6 +60,7 @@ const systemInfo = ref<ArchySystemInfo>({})
const networkInfo = ref<ArchyNetworkInfo>({})
const walletInfo = ref<ArchyWalletInfo>({})
const fileList = ref<ArchyFileEntry[]>([])
const bitcoinInfo = ref<ArchyBitcoinInfo>({ available: false })
let cleanups: (() => void)[] = []
/**
@@ -125,6 +142,16 @@ export function useArchy() {
)
}
if (cats.includes('bitcoin')) {
fetches.push(
archyBridge.requestContext('bitcoin').then((res) => {
if (res.permitted && res.data) {
bitcoinInfo.value = res.data as ArchyBitcoinInfo
}
}).catch(() => {}),
)
}
if (cats.includes('files')) {
fetches.push(
archyBridge.requestContext('files').then((res) => {
@@ -150,6 +177,26 @@ export function useArchy() {
return archyBridge.requestAction(action, params)
}
/** Read a file's text content via FileBrowser */
async function readFile(path: string): Promise<{ content: string; truncated: boolean; size: number } | null> {
const res = await requestAction('read-file', { path })
const data = (res as unknown as Record<string, unknown>).data
if (res.success && data) {
return data as { content: string; truncated: boolean; size: number }
}
return null
}
/** Tail recent logs for an app */
async function tailLogs(appId: string, lines = 50): Promise<string[] | null> {
const res = await requestAction('tail-logs', { appId, lines: String(lines) })
const data = (res as unknown as Record<string, unknown>).data
if (res.success && data) {
return (data as { lines: string[] }).lines
}
return null
}
/** Apply accent color as CSS custom property */
function applyAccentColor(color: string) {
document.documentElement.style.setProperty('--color-accent', color)
@@ -165,7 +212,7 @@ export function useArchy() {
const appList = installedApps.value
.map((a) => `- ${a.name} (${a.state}${a.status ? ', ' + a.status : ''})`)
.join('\n')
sections.push(`**Installed apps on this node:**\n${appList}`)
sections.push(`**Installed apps on this node:**\n${appList}\nYou can view recent app logs by requesting the tail-logs action with an appId.`)
}
if (permissions.value.includes('system') && systemInfo.value.name) {
@@ -178,21 +225,32 @@ export function useArchy() {
sections.push(`**Network:** ${net.connected ? 'Connected' : 'Disconnected'}`)
}
if (permissions.value.includes('wallet') && walletInfo.value.balanceSats !== undefined) {
if (permissions.value.includes('wallet') && walletInfo.value.available) {
const w = walletInfo.value
const balance = w.balanceSats!
const parts = [`Balance: ${balance.toLocaleString()} sats`]
if (w.channelCount !== undefined) parts.push(`${w.channelCount} channels`)
if (w.totalCapacitySats !== undefined) parts.push(`Total capacity: ${w.totalCapacitySats.toLocaleString()} sats`)
if (w.nodePubkey) parts.push(`Pubkey: ${w.nodePubkey.slice(0, 8)}...`)
sections.push(`**Lightning Wallet:** ${parts.join(' | ')}`)
const parts: string[] = []
if (w.alias) parts.push(w.alias)
if (w.num_active_channels !== undefined) parts.push(`${w.num_active_channels} channels`)
if (w.num_peers !== undefined) parts.push(`${w.num_peers} peers`)
if (w.balance_sats !== undefined) parts.push(`On-chain: ${w.balance_sats.toLocaleString()} sats`)
if (w.channel_balance_sats !== undefined) parts.push(`In channels: ${w.channel_balance_sats.toLocaleString()} sats`)
if (w.synced_to_chain !== undefined) parts.push(w.synced_to_chain ? 'synced' : 'syncing')
sections.push(`**Lightning (LND):** ${parts.join(' | ')}`)
}
if (permissions.value.includes('bitcoin') && bitcoinInfo.value.available) {
const btc = bitcoinInfo.value
const syncPct = btc.sync_progress ? (btc.sync_progress * 100).toFixed(2) + '%' : 'unknown'
const parts = [`Block ${btc.block_height?.toLocaleString() ?? '?'}`, `${syncPct} synced`]
if (btc.chain) parts.push(btc.chain)
if (btc.mempool_tx_count) parts.push(`mempool: ${btc.mempool_tx_count.toLocaleString()} txs`)
sections.push(`**Bitcoin:** ${parts.join(', ')}`)
}
if (permissions.value.includes('files') && fileList.value.length > 0) {
const files = fileList.value
const recent = files.slice(0, 20)
const fileNames = recent.map((f) => f.name).join(', ')
sections.push(`**Files:** ${files.length} files in Nextcloud. Recent: ${fileNames}`)
sections.push(`**Files:** ${files.length} files in Nextcloud. Recent: ${fileNames}\nYou can read file contents by requesting the read-file action with a file path.`)
}
if (sections.length === 0) return ''
@@ -218,10 +276,13 @@ export function useArchy() {
networkInfo: readonly(networkInfo),
walletInfo: readonly(walletInfo),
fileList: readonly(fileList),
bitcoinInfo: readonly(bitcoinInfo),
init,
destroy,
refreshContext,
requestAction,
readFile,
tailLogs,
buildArchyContext,
}
}
@@ -387,6 +387,24 @@ export async function fetchMusicCover(
const cached = musicCoverCache.get(key)
if (cached) return cached
// Try Wavlake first — our primary music source
try {
const base = import.meta.env.BASE_URL || '/'
const params = new URLSearchParams({ q: title, title, artist })
const wlRes = await fetch(`${base}api/music/search?${params}`)
if (wlRes.ok) {
const wlData = (await wlRes.json()) as { coverUrl?: string }
if (wlData.coverUrl) {
musicCoverCache.set(key, wlData.coverUrl)
saveMusicCache()
return wlData.coverUrl
}
}
} catch {
// Fall through to iTunes
}
// Fallback: iTunes Search API
try {
const term = `${artist} ${title}`.trim().replace(/\s+/g, '+')
const res = await fetch(
+72 -2
View File
@@ -14,6 +14,7 @@ export interface NostrNote {
id: string
pubkey: string
authorName?: string
authorPicture?: string
nip05?: string
kind: number
content: string
@@ -69,8 +70,14 @@ const events = shallowRef<NostrNote[]>([])
const isConnected = ref(false)
const relayStates = ref<RelayInfo[]>([])
// Profile metadata cache: pubkey → { name, picture, nip05 }
interface ProfileMeta { name?: string; picture?: string; nip05?: string }
const profileCache = new Map<string, ProfileMeta>()
const pendingProfiles = new Set<string>()
let relays: RelayState[] = []
let subscriptionId: string | null = null
let profileSubId: string | null = null
let initialized = false
function generateSubId(): string {
@@ -92,11 +99,71 @@ function parseEvent(data: unknown): NostrEvent | null {
return evt as NostrEvent
}
function handleMetadataEvent(evt: NostrEvent) {
if (evt.kind !== 0) return
try {
const meta = JSON.parse(evt.content) as Record<string, unknown>
const profile: ProfileMeta = {
name: (meta.display_name ?? meta.name ?? '') as string || undefined,
picture: (meta.picture ?? '') as string || undefined,
nip05: (meta.nip05 ?? '') as string || undefined,
}
profileCache.set(evt.pubkey, profile)
pendingProfiles.delete(evt.pubkey)
// Update existing notes with this profile data
const updated = events.value.map(n => {
if (n.pubkey !== evt.pubkey) return n
return {
...n,
authorName: profile.name || n.authorName,
authorPicture: profile.picture,
nip05: profile.nip05 || n.nip05,
}
})
events.value = updated
} catch { /* malformed kind 0 */ }
}
function enrichWithProfile(note: NostrNote): NostrNote {
const cached = profileCache.get(note.pubkey)
if (cached) {
return {
...note,
authorName: cached.name || note.authorName,
authorPicture: cached.picture,
nip05: cached.nip05 || note.nip05,
}
}
return note
}
function requestProfiles(pubkeys: string[]) {
const needed = pubkeys.filter(pk => !profileCache.has(pk) && !pendingProfiles.has(pk))
if (needed.length === 0) return
for (const pk of needed) pendingProfiles.add(pk)
// Send kind 0 REQ to connected relays
const subId = 'prof-' + Math.random().toString(36).slice(2, 8)
for (const relay of relays) {
if (relay.connected && relay.ws && relay.read) {
relay.ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: needed, limit: needed.length }]))
}
}
}
function addEvent(evt: NostrEvent) {
// Handle metadata events (kind 0) for profile pictures
if (evt.kind === 0) {
handleMetadataEvent(evt)
return
}
const existing = events.value.find(e => e.id === evt.id)
if (existing) return
const note: NostrNote = {
const note = enrichWithProfile({
id: evt.id,
pubkey: evt.pubkey,
authorName: truncatePubkey(evt.pubkey),
@@ -104,13 +171,16 @@ function addEvent(evt: NostrEvent) {
content: evt.content,
created_at: evt.created_at,
tags: evt.tags ?? [],
}
})
const newEvents = [...events.value, note]
.sort((a, b) => b.created_at - a.created_at)
.slice(0, 200)
events.value = newEvents
// Queue profile fetch for this author
requestProfiles([evt.pubkey])
}
function connectRelay(relayState: RelayState) {
+86 -65
View File
@@ -4,11 +4,16 @@ import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
interface MusicSearchResult {
source: string
type: 'stream' | 'embed'
source: 'wavlake'
type: 'stream'
url: string
title?: string
artist?: string
coverUrl?: string
duration?: number
trackId?: string
albumTitle?: string
wavlakeUrl?: string
}
// ─── Global singleton state ───────────────────────────────────
@@ -30,7 +35,10 @@ let containerEl: HTMLDivElement | null = null
let audioEl: HTMLAudioElement | null = null
// Client-side search result cache — avoids re-searching songs
// Null results use a short TTL so transient failures don't stick
const NULL_CACHE_TTL = 2 * 60 * 1000 // 2 minutes
const resultCache = new Map<string, MusicSearchResult | null>()
const nullCacheTimestamps = new Map<string, number>()
// Active search abort controller — cancel stale searches on rapid switching
let activeSearchController: AbortController | null = null
@@ -45,9 +53,17 @@ export function usePlayer() {
// ─── Search with abort + cache ────────────────────────────
async function searchMusic(query: string, title?: string, artist?: string): Promise<MusicSearchResult | null> {
const cacheKey = `${query}|${title ?? ''}|${artist ?? ''}`
const cacheKey = `${title ?? query}|${artist ?? ''}`
const cached = resultCache.get(cacheKey)
if (cached !== undefined) return cached
if (cached !== undefined) {
// Positive results stay cached forever; null results expire after TTL
if (cached !== null) return cached
const ts = nullCacheTimestamps.get(cacheKey)
if (ts && Date.now() - ts < NULL_CACHE_TTL) return null
// Expired null — retry
resultCache.delete(cacheKey)
nullCacheTimestamps.delete(cacheKey)
}
// Cancel any in-flight search
activeSearchController?.abort()
@@ -58,13 +74,21 @@ export function usePlayer() {
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 base = import.meta.env.BASE_URL || '/'
const res = await fetch(`${base}api/music/search?${params}`, {
signal: controller.signal,
})
if (!res.ok) {
error.value = `Search failed (${res.status})`
resultCache.set(cacheKey, null)
nullCacheTimestamps.set(cacheKey, Date.now())
return null
}
const data = (await res.json()) as MusicSearchResult & { error?: string }
if (data.error || !data.url) {
error.value = data.error ?? 'No playable source found'
error.value = data.error ?? 'Not found on Wavlake'
resultCache.set(cacheKey, null)
nullCacheTimestamps.set(cacheKey, Date.now())
return null
}
const result = data as MusicSearchResult
@@ -91,66 +115,55 @@ export function usePlayer() {
function initPlayer(result: MusicSearchResult) {
if (!containerEl) return
if (result.type === 'stream') {
// Reuse existing audio element if we have one — just change src
if (audioEl && plyrInstance) {
audioEl.src = result.url
audioEl.load()
Promise.resolve(plyrInstance.play()).catch(() => { /* autoplay blocked */ })
return
}
// First time — create audio element and Plyr
destroyPlayer()
audioEl = document.createElement('audio')
// Reuse existing audio element if we have one — just change src
if (audioEl && plyrInstance) {
audioEl.src = result.url
audioEl.crossOrigin = 'anonymous'
audioEl.preload = 'auto'
containerEl.textContent = ''
containerEl.appendChild(audioEl)
plyrInstance = new Plyr(audioEl, {
controls: [],
autoplay: true,
muted: false,
})
plyrInstance.on('ready', () => {
Promise.resolve(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
if (currentIndex.value >= 0 && currentIndex.value < queue.value.length - 1) {
playNext()
}
})
plyrInstance.on('playing', () => {
isPlaying.value = true
isLoading.value = false
})
plyrInstance.on('pause', () => {
isPlaying.value = false
})
} else {
// Embed (Odysee etc.) — must recreate iframe
destroyPlayer()
const iframe = document.createElement('iframe')
iframe.src = result.url
iframe.style.width = '100%'
iframe.style.height = '100%'
iframe.style.border = 'none'
containerEl.textContent = ''
containerEl.appendChild(iframe)
plyrInstance = null
audioEl = null
duration.value = 0
currentTime.value = 0
audioEl.load()
Promise.resolve(plyrInstance.play()).catch(() => { /* autoplay blocked */ })
return
}
// First time — create audio element and Plyr
destroyPlayer()
audioEl = document.createElement('audio')
audioEl.src = result.url
audioEl.preload = 'auto'
containerEl.textContent = ''
containerEl.appendChild(audioEl)
plyrInstance = new Plyr(audioEl, {
controls: [],
autoplay: true,
muted: false,
})
plyrInstance.on('ready', () => {
Promise.resolve(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
if (currentIndex.value >= 0 && currentIndex.value < queue.value.length - 1) {
playNext()
}
})
plyrInstance.on('playing', () => {
isPlaying.value = true
isLoading.value = false
})
plyrInstance.on('pause', () => {
isPlaying.value = false
})
plyrInstance.on('error', (event: Plyr.PlyrEvent) => {
const mediaError = audioEl?.error
console.error('[player] Audio error:', mediaError?.code, mediaError?.message)
isLoading.value = false
error.value = 'Audio failed to load'
})
}
function destroyPlayer() {
@@ -192,9 +205,17 @@ export function usePlayer() {
isLoading.value = false
if (!result) {
if (!error.value) error.value = 'No playable source. Tried: Internet Archive, Jamendo, Odysee.'
if (!error.value) error.value = 'Not found on Wavlake'
return
}
// Enrich song with Wavlake metadata if available
if (result.coverUrl && currentSong.value && !currentSong.value.coverUrl) {
currentSong.value = { ...currentSong.value, coverUrl: result.coverUrl }
}
if (result.duration && currentSong.value && !currentSong.value.duration) {
currentSong.value = { ...currentSong.value, duration: result.duration }
}
error.value = null
playableSource.value = result
if (containerEl) {
+543
View File
@@ -0,0 +1,543 @@
// Free documentary films from InDeeHub prototype
// Combined catalog: InDeeHub originals + TopDocumentaryFilms (YouTube)
import type { Film, FilmSource } from '@aiui/core/types/content'
const B = import.meta.env.BASE_URL + 'assets/img/films'
const YT_THUMB = 'https://img.youtube.com/vi'
function yt(embedUrl: string): FilmSource {
return { type: 'youtube', name: 'Free on YouTube', url: embedUrl, icon: '▶️' }
}
function ih(_title: string): FilmSource {
return { type: 'indeehub', name: 'InDeeHub', url: `https://indeehub.com`, quality: 'HD', icon: '🎬' }
}
// ── TopDocumentaryFilms — YouTube-streamable ────────────────────
const topDocFilms: Film[] = [
{
id: 'tdf-god-bless-bitcoin',
title: 'God Bless Bitcoin',
year: 2024,
posterUrl: `${B}/posters/topdoc/god-bless-bitcoin.jpg`,
backdropUrl: `${B}/posters/god-bless-bitcoin.webp`,
synopsis: 'A groundbreaking documentary exploring the intersection of faith, finance, and the future of money through the lens of Bitcoin and its transformative impact on religious communities worldwide.',
genres: ['Documentary', 'Bitcoin', 'Religion'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/3XEuqixD2Zg')],
},
{
id: 'tdf-bitcoin-end-of-money',
title: 'Bitcoin: The End of Money as We Know It',
year: 2015,
posterUrl: `${B}/posters/topdoc/bitcoin-end-of-money.jpg`,
backdropUrl: `${YT_THUMB}/zpNlG3VtcBM/maxresdefault.jpg`,
synopsis: 'Tracing the history of money from barter to Bitcoin, this award-winning documentary examines how decentralized digital currency could upend the global financial system and redefine what money means.',
genres: ['Documentary', 'Bitcoin', 'Economics'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/zpNlG3VtcBM')],
},
{
id: 'tdf-bitcoin-beyond-bubble',
title: 'Bitcoin: Beyond the Bubble',
year: 2018,
posterUrl: `${B}/posters/topdoc/bitcoin-beyond-bubble.jpg`,
backdropUrl: `${YT_THUMB}/URrmfEu0cZ8/maxresdefault.jpg`,
synopsis: 'An accessible explainer for those intimidated by crypto jargon, tracing currency evolution from precious metals to the dollar to Bitcoin and its promise for the unbanked worldwide.',
genres: ['Documentary', 'Bitcoin', 'Economics'],
rating: 8.3,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/URrmfEu0cZ8')],
},
{
id: 'tdf-bitcoin-gospel',
title: 'The Bitcoin Gospel',
year: 2015,
posterUrl: `${B}/posters/topdoc/bitcoin-gospel.jpg`,
backdropUrl: `${YT_THUMB}/8zKuoqZLyKg/maxresdefault.jpg`,
synopsis: 'Following entrepreneurs and activists who believe Bitcoin offers an escape from bank and government financial control, examining whether it can truly redefine capitalism globally.',
genres: ['Documentary', 'Bitcoin', 'Economics'],
rating: 7.9,
runtime: 49,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/8zKuoqZLyKg')],
},
{
id: 'tdf-bitcoin-psyop',
title: 'The Bitcoin Psyop',
year: 2018,
posterUrl: `${B}/posters/topdoc/bitcoin-psyop.jpg`,
backdropUrl: `${YT_THUMB}/XBlai36NorA/maxresdefault.jpg`,
synopsis: 'A short film examining whether Bitcoin and blockchain are genuine technological innovations or hype, and whether they will decentralize power or enable greater government control.',
genres: ['Documentary', 'Bitcoin', 'Conspiracy'],
rating: 7.4,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/XBlai36NorA')],
},
{
id: 'tdf-missing-cryptoqueen',
title: 'The Missing Cryptoqueen: Dead or Alive?',
year: 2024,
posterUrl: `${B}/posters/topdoc/missing-cryptoqueen.jpg`,
backdropUrl: `${YT_THUMB}/FTnTToWEHvI/maxresdefault.jpg`,
synopsis: 'The extraordinary story of Ruja Ignatova, the self-styled Cryptoqueen who persuaded millions to invest in her cryptocurrency OneCoin before vanishing with billions.',
genres: ['Documentary', 'Crypto', 'Crime'],
rating: 0,
runtime: 53,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/FTnTToWEHvI')],
},
{
id: 'tdf-billion-dollar-scam',
title: 'The Billion Dollar Scam',
year: 2024,
posterUrl: `${B}/posters/topdoc/billion-dollar-scam.jpg`,
synopsis: 'An investigation into one of the largest financial frauds in history, tracing how billions disappeared through elaborate schemes and the investigators racing to uncover the truth.',
genres: ['Documentary', 'Crypto', 'Crime'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/3QpdU9LS540')],
},
{
id: 'tdf-money-banking-fed',
title: 'Money, Banking, and The Federal Reserve',
year: 0,
posterUrl: `${B}/posters/topdoc/money-banking-fed.jpg`,
backdropUrl: `${YT_THUMB}/YLYL_NVU1bg/maxresdefault.jpg`,
synopsis: "Featuring Ron Paul, Joseph Salerno, Hans Hoppe, and Lew Rockwell, this film explains the Federal Reserve's operations and history through Austrian economics principles.",
genres: ['Documentary', 'Economics', 'Money'],
rating: 5.3,
runtime: 42,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/YLYL_NVU1bg')],
},
{
id: 'tdf-american-dream',
title: 'The American Dream',
year: 0,
posterUrl: `${B}/posters/topdoc/american-dream.jpg`,
backdropUrl: `${YT_THUMB}/8NBSwDEf8a8/sddefault.jpg`,
synopsis: 'An animated film examining how money is created and how the Federal Reserve System affects daily life, connecting current economic problems to historical warnings about the financial system.',
genres: ['Documentary', 'Economics', 'Money'],
rating: 8.0,
runtime: 30,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/8NBSwDEf8a8')],
},
{
id: 'tdf-century-enslavement',
title: 'Century of Enslavement: The History of the Federal Reserve',
year: 0,
posterUrl: `${B}/posters/topdoc/century-enslavement.jpg`,
backdropUrl: `${YT_THUMB}/TmYtPfdwYtY/maxresdefault.jpg`,
synopsis: 'An exhaustive examination of how the Fed was created in 1913 to address banking panics, yet allowed the 2008 financial crisis to occur, with some economists viewing Bitcoin as an alternative.',
genres: ['Documentary', 'Economics', 'Money'],
rating: 8.2,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/TmYtPfdwYtY')],
},
{
id: 'tdf-money-power-wall-street',
title: 'Money, Power and Wall Street',
year: 2012,
posterUrl: `${B}/posters/topdoc/money-power-wall-street.jpg`,
backdropUrl: `${YT_THUMB}/W-Q9AOp2FW8/maxresdefault.jpg`,
synopsis: 'After almost 80 years, another global financial crisis threatened to bring the world economy to the brink of collapse. This traces the 2008 Wall Street crash and its devastating aftermath.',
genres: ['Documentary', 'Economics', 'Money'],
rating: 8.4,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/W-Q9AOp2FW8')],
},
{
id: 'tdf-gold-6000-year',
title: "Gold: The Story of Man's 6000 Year Obsession",
year: 2018,
posterUrl: `${B}/posters/topdoc/gold-6000-year.jpg`,
backdropUrl: `${YT_THUMB}/vM8CtejAelM/maxresdefault.jpg`,
synopsis: 'The story of gold is the story of civilizations. What is it about this precious metal that inspires such a level of devotion across millennia of human history?',
genres: ['Documentary', 'Economics', 'History'],
rating: 8.6,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/vM8CtejAelM')],
},
{
id: 'tdf-debtasized',
title: 'Debtasized',
year: 2024,
posterUrl: `${B}/posters/topdoc/debtasized.jpg`,
synopsis: 'Our reliance on credit has fundamentally altered how we perceive affordability. With a plethora of credit options readily available, the focus has shifted from the total cost to the monthly payment.',
genres: ['Documentary', 'Economics'],
rating: 7.7,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/A63afuvkbmk')],
},
{
id: 'tdf-crash-next-crisis',
title: 'Crash: Are We Ready for the Next Crisis?',
year: 2019,
posterUrl: `${B}/posters/topdoc/crash-next-crisis.jpg`,
backdropUrl: `${YT_THUMB}/O2pD_y61jx4/maxresdefault.jpg`,
synopsis: 'In 2008, the world was hit by a financial crisis, the worst since the Great Depression. Governments had to bail out banks to prevent total collapse. Are we prepared for the next one?',
genres: ['Documentary', 'Economics'],
rating: 8.2,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/O2pD_y61jx4')],
},
{
id: 'tdf-pension-gamble',
title: 'The Pension Gamble',
year: 2021,
posterUrl: `${B}/posters/topdoc/pension-gamble.jpg`,
backdropUrl: `${YT_THUMB}/lkOQNPIsO-Q/maxresdefault.jpg`,
synopsis: 'Older civil servant workers in America are worried. Despite steady jobs as firefighters, teachers, and police officers, they can no longer count on one of the most basic financial safety nets.',
genres: ['Documentary', 'Economics'],
rating: 9.2,
runtime: 54,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/lkOQNPIsO-Q')],
},
{
id: 'tdf-why-americans-poor',
title: 'Why Americans Feel So Poor?',
year: 2023,
posterUrl: `${B}/posters/topdoc/why-americans-poor.jpg`,
backdropUrl: `${YT_THUMB}/kCQiywN7pH4/maxresdefault.jpg`,
synopsis: 'The American middle class has been facing financial challenges and instability, despite being considered a symbol of the American dream in the past.',
genres: ['Documentary', 'Economics', 'Society'],
rating: 8.1,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/kCQiywN7pH4')],
},
{
id: 'tdf-chain-reaction',
title: 'Chain Reaction',
year: 2022,
posterUrl: `${B}/posters/topdoc/chain-reaction.jpg`,
synopsis: 'Many of us took for granted that online orders arrive in days. Delays were rare until the ongoing supply chain crisis hit the world in full force in 2021.',
genres: ['Documentary', 'Economics'],
rating: 7.8,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/HMmdPgtXUUA')],
},
{
id: 'tdf-usa-on-brink',
title: 'USA on the Brink',
year: 2020,
posterUrl: `${B}/posters/topdoc/usa-on-brink.jpg`,
backdropUrl: `${YT_THUMB}/G7z1kjkdRxU/maxresdefault.jpg`,
synopsis: 'When the COVID-19 pandemic hit, the magnitude of the economic upheaval it caused was similar to what the USA experienced during the Great Depression.',
genres: ['Documentary', 'Economics'],
rating: 7.3,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/G7z1kjkdRxU')],
},
{
id: 'tdf-big-four',
title: 'The Big Four: Accounting Firms Under Scrutiny',
year: 2021,
posterUrl: `${B}/posters/topdoc/big-four.jpg`,
backdropUrl: `${YT_THUMB}/C_0XEIFGK5o/maxresdefault.jpg`,
synopsis: "In 2020, Wirecard AG filed for bankruptcy after losing 1.9 billion euros. This film exposes the role of the world's biggest accounting firms in corporate scandals.",
genres: ['Documentary', 'Economics', 'Crime'],
rating: 7.8,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/C_0XEIFGK5o')],
},
{
id: 'tdf-congo-millionaires',
title: 'Congo: Millionaires of Chaos',
year: 2018,
posterUrl: `${B}/posters/topdoc/congo-millionaires.jpg`,
synopsis: 'The Democratic Republic of Congo is six times the size of Germany and home to over 100 million people. Armed uprisings, political upheavals and violence have marked its history.',
genres: ['Documentary', 'Economics', 'Politics'],
rating: 8.0,
runtime: 0,
director: '',
cast: [],
sources: [],
},
{
id: 'tdf-economics-of',
title: 'The Economics Of',
year: 2023,
posterUrl: `${B}/posters/topdoc/economics-of.jpg`,
backdropUrl: `${YT_THUMB}/grkHcEyZu04/maxresdefault.jpg`,
synopsis: "Chick-fil-A, IKEA, and others have built devoted customer bases by focusing on excellent service and unique business models. A look at what makes them work.",
genres: ['Documentary', 'Economics'],
rating: 6.8,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/3JKWmWAlMD4')],
},
{
id: 'tdf-big-business-food',
title: 'Big Business: Food Empires',
year: 2023,
posterUrl: `${B}/posters/topdoc/big-business-food.jpg`,
backdropUrl: `${YT_THUMB}/4ArVvrhhnyI/maxresdefault.jpg`,
synopsis: 'From dry-aged steaks to artisanal burger patties, exploring how third-generation butchers and food entrepreneurs build empires of quality and dedication.',
genres: ['Documentary', 'Economics'],
rating: 8.0,
runtime: 0,
director: '',
cast: [],
sources: [yt('https://www.youtube.com/embed/4ArVvrhhnyI')],
},
{
id: 'tdf-so-long-superstores',
title: 'So Long, Superstores?',
year: 2021,
posterUrl: `${B}/posters/topdoc/so-long-superstores.jpg`,
synopsis: 'Examining the changing retail landscape and the impact it is having on the traditional superstore model, from big-box to e-commerce.',
genres: ['Documentary', 'Economics'],
rating: 7.5,
runtime: 0,
director: '',
cast: [],
sources: [],
},
]
// ── InDeeHub originals — local posters, not yet streamable ──────
const indeeHubFilms: Film[] = [
{
id: 'ih-god-bless-bitcoin',
title: 'God Bless Bitcoin',
year: 2024,
posterUrl: `${B}/posters/god-bless-bitcoin.webp`,
synopsis: 'A groundbreaking documentary exploring the intersection of faith, finance, and the future of money through the lens of Bitcoin and its transformative impact on religious communities worldwide.',
genres: ['Documentary', 'Bitcoin', 'Religion'],
rating: 0,
runtime: 90,
director: '',
cast: [],
sources: [ih('God Bless Bitcoin')],
},
{
id: 'ih-hard-money',
title: 'Hard Money',
year: 0,
posterUrl: `${B}/posters/2b0d7349-c010-47a0-b584-49e1bf86ab2f.png`,
backdropUrl: `${B}/backdrops/2b0d7349-c010-47a0-b584-49e1bf86ab2f.jpg`,
synopsis: 'Understanding sound money principles and the importance of monetary sovereignty in the modern financial system.',
genres: ['Documentary', 'Finance', 'Bitcoin'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('Hard Money')],
},
{
id: 'ih-bitcoiners',
title: 'Bitcoiners',
year: 0,
posterUrl: `${B}/posters/665a4095-73b9-480d-a0a4-b2aafaf2bce4.png`,
backdropUrl: `${B}/backdrops/665a4095-73b9-480d-a0a4-b2aafaf2bce4.jpg`,
synopsis: 'Meet the passionate individuals building the Bitcoin ecosystem and changing the world of money.',
genres: ['Documentary', 'Bitcoin'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('Bitcoiners')],
},
{
id: 'ih-lekker-feeling',
title: 'Lekker Feeling: A Bitcoin Ekasi Story',
year: 0,
posterUrl: `${B}/posters/3c113b66-3bb5-4cac-90eb-965ecedc4aa2.png`,
backdropUrl: `${B}/backdrops/3c113b66-3bb5-4cac-90eb-965ecedc4aa2.jpg`,
synopsis: 'A heartwarming documentary about Bitcoin adoption in South African townships and its impact on local communities.',
genres: ['Documentary', 'Bitcoin'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('Lekker Feeling')],
},
{
id: 'ih-stranded',
title: 'STRANDED: A DIRTY COIN Short',
year: 0,
posterUrl: `${B}/posters/stranded.png`,
backdropUrl: `${B}/backdrops/stranded.png`,
synopsis: 'A companion short film exploring the environmental and energy aspects of Bitcoin mining.',
genres: ['Documentary', 'Bitcoin'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('STRANDED')],
},
{
id: 'ih-housing-bubble',
title: 'The Housing Bubble',
year: 0,
posterUrl: `${B}/posters/bbdb0178-0b96-4ab5-addf-ba1f029c1cb3.webp`,
backdropUrl: `${B}/backdrops/bbdb0178-0b96-4ab5-addf-ba1f029c1cb3.jpg`,
synopsis: 'An examination of the 2008 financial crisis, mortgage-backed securities, and the devastating impact on American families.',
genres: ['Documentary', 'Finance'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('The Housing Bubble')],
},
{
id: 'ih-menger',
title: 'Menger. Notes on the margin',
year: 0,
posterUrl: `${B}/posters/584f310b-2269-4b05-a09d-261a0a3c1f78.webp`,
backdropUrl: `${B}/backdrops/584f310b-2269-4b05-a09d-261a0a3c1f78.jpg`,
synopsis: "Exploring Austrian economics, Carl Menger's revolutionary ideas on subjective value, and the foundations of sound economic thinking.",
genres: ['Documentary', 'Economics'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('Menger')],
},
{
id: 'ih-things-we-carry',
title: 'The Things We Carry',
year: 0,
posterUrl: `${B}/posters/thethingswecarry.webp`,
backdropUrl: `${B}/backdrops/thethingswecarry.webp`,
synopsis: 'A compelling narrative exploring the emotional weight of our past and the baggage we carry through life.',
genres: ['Drama'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('The Things We Carry')],
},
{
id: 'ih-duel',
title: 'Duel',
year: 0,
posterUrl: `${B}/posters/duel.png`,
backdropUrl: `${B}/backdrops/duel.png`,
synopsis: 'An intense confrontation that tests the limits of human resolve.',
genres: ['Drama', 'Action'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('Duel')],
},
{
id: 'ih-plastic-money',
title: 'Plastic Money',
year: 0,
posterUrl: `${B}/posters/311f772f-6559-4982-8918-d0f4be9e1b76.webp`,
backdropUrl: `${B}/backdrops/311f772f-6559-4982-8918-d0f4be9e1b76.webp`,
synopsis: 'Examining the credit card industry, debt, and the future of digital payments.',
genres: ['Documentary', 'Finance'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('Plastic Money')],
},
{
id: 'ih-identity-theft',
title: 'Identity Theft',
year: 0,
posterUrl: `${B}/posters/identity-theft.png`,
backdropUrl: `${B}/backdrops/identity-theft.png`,
synopsis: "A tense thriller about stolen identity, digital security, and the fight to reclaim one's life in the modern age.",
genres: ['Thriller'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('Identity Theft')],
},
{
id: 'ih-forging-country',
title: 'Forging a Country',
year: 0,
posterUrl: `${B}/posters/forgingacountry.webp`,
backdropUrl: `${B}/backdrops/forgingacountry.webp`,
synopsis: 'The story of nation-building, collective identity, and what it means to create a country from scratch.',
genres: ['Documentary'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('Forging a Country')],
},
{
id: 'ih-home',
title: 'HOME',
year: 0,
posterUrl: `${B}/posters/home.webp`,
backdropUrl: `${B}/backdrops/home.webp`,
synopsis: 'A poignant exploration of what home means in our modern world and the universal search for belonging.',
genres: ['Drama'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('HOME')],
},
{
id: 'ih-down-the-pch',
title: 'Down the P.C.H.',
year: 0,
posterUrl: `${B}/posters/down-the-pch.png`,
backdropUrl: `${B}/backdrops/down-the-pch.png`,
synopsis: "A cinematic journey down California's legendary Pacific Coast Highway, exploring freedom and the open road.",
genres: ['Drama'],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [ih('Down the P.C.H.')],
},
]
// Deduplicate: TopDoc "God Bless Bitcoin" takes priority (has YouTube URL)
export const freeFilms: Film[] = [
...topDocFilms,
...indeeHubFilms.filter(f => f.id !== 'ih-god-bless-bitcoin'),
]
+29
View File
@@ -5,6 +5,30 @@ import App from './App.vue'
import './styles/main.css'
import { initializePlugins } from './plugins'
// PWA cache version — bump this when icons or critical assets change
const PWA_CACHE_VERSION = '2'
// Purge all service worker caches when version changes
;(async () => {
const key = 'aiui-cache-version'
const stored = localStorage.getItem(key)
if (stored !== PWA_CACHE_VERSION) {
localStorage.setItem(key, PWA_CACHE_VERSION)
if ('caches' in window) {
const names = await caches.keys()
await Promise.all(names.map((name) => caches.delete(name)))
}
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map((r) => r.unregister()))
}
if (stored !== null) {
// Only reload if this is an upgrade, not a fresh install
window.location.reload()
}
}
})()
// Capture embedded flag before router init (survives SPA navigation)
// Only embedded when explicitly requested via ?embedded param
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
@@ -33,6 +57,11 @@ const router = createRouter({
name: 'conversation-viewer',
component: () => import('./pages/ConversationViewerPage.vue'),
},
{
path: '/guide',
name: 'guide',
component: () => import('./pages/GuidePage.vue'),
},
],
})
+12 -6
View File
@@ -1,6 +1,6 @@
<template>
<div
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300 transition-[padding]"
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
:class="[]"
:style="isEmbedded
? { background: 'transparent' }
@@ -11,7 +11,11 @@
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
<!-- Desktop layout -->
<div class="flex-1 flex h-full p-3 md:p-4 gap-3 md:gap-4" :class="isMobile ? 'hidden' : ''">
<div
class="flex-1 flex h-full p-3 md:p-4 gap-3 md:gap-4 transition-[padding] duration-200"
:class="[isMobile ? 'hidden' : '']"
:style="playerActive && !isMobile ? { paddingBottom: '76px' } : {}"
>
<!-- Content surface (main area) -->
<main
@@ -307,7 +311,7 @@
</div>
<!-- Inline player bar on mobile (above tab bar) -->
<PlayerBar variant="inline" />
<PlayerBar variant="inline" compact />
<!-- Bottom tab bar (iOS HIG: 49pt + safe area + 24px margin) -->
<div
@@ -382,8 +386,10 @@ import CloseButton from '@/components/content/CloseButton.vue'
import ContextLoader from '@/components/content/ContextLoader.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import PlayerBar from '@/components/player/PlayerBar.vue'
import { usePlayer } from '@/composables/usePlayer'
import { useCodeContext } from '@/composables/useCodeContext'
const { hasTrack: playerActive } = usePlayer()
const chatStore = useChatStore()
const { activeFile: codeActiveFile, isCodeMode, exitCodeMode, clearActiveFile: clearCodeFile } = useCodeContext()
const { isDark } = useTheme()
@@ -476,13 +482,13 @@ onUnmounted(() => {
// Auto-switch to content tab on mobile when panel opens or content changes
watch(panelOpen, (open) => {
if (open && isMobile.value) mobileTab.value = 'content'
if (open && isMobile.value && !isEmbedded) mobileTab.value = 'content'
})
watch(panelTitle, () => {
if (panelOpen.value && isMobile.value && mobileTab.value === 'chat') mobileTab.value = 'content'
if (panelOpen.value && isMobile.value && mobileTab.value === 'chat' && !isEmbedded) mobileTab.value = 'content'
})
watch(activeTab, () => {
if (panelOpen.value && isMobile.value && mobileTab.value === 'chat') mobileTab.value = 'content'
if (panelOpen.value && isMobile.value && mobileTab.value === 'chat' && !isEmbedded) mobileTab.value = 'content'
})
const hasDetailOpen = computed(() =>
+470
View File
@@ -0,0 +1,470 @@
<template>
<div class="guide-page">
<header class="guide-header">
<button class="back-btn" @click="goBack">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<h1 class="guide-title">AIUI Guide</h1>
<span class="guide-version">v1.0</span>
</header>
<main class="guide-content">
<!-- Intro -->
<section class="guide-section">
<p class="guide-intro">
AIUI is your AI assistant running directly on your Archipelago node. It can see your installed apps,
read your files, check Bitcoin and Lightning status, and help you manage everything all privately,
with no data leaving your node.
</p>
</section>
<!-- Node Context -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F4E1;</span>
Node Awareness
</h2>
<p class="section-desc">
When running on your Archipelago node, AIUI automatically knows about your setup. It sees which apps
are installed, your system status, network connectivity, and more. Just ask naturally:
</p>
<div class="example-box">
<div class="example-prompt">"What apps do I have installed?"</div>
<div class="example-prompt">"Is my node connected to the network?"</div>
<div class="example-prompt">"What version of Archipelago am I running?"</div>
</div>
</section>
<!-- File Access -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F4C1;</span>
File Browsing & Reading
</h2>
<p class="section-desc">
AIUI can browse and read text files stored in your Nextcloud instance. It supports common text formats
like <code>.txt</code>, <code>.md</code>, <code>.json</code>, <code>.csv</code>, <code>.log</code>,
<code>.yaml</code>, <code>.conf</code>, and many more.
</p>
<div class="example-box">
<div class="example-prompt">"What files do I have?"</div>
<div class="example-prompt">"Read my config.yaml file"</div>
<div class="example-prompt">"Show me the contents of notes.md"</div>
<div class="example-prompt">"Summarize my todo.txt"</div>
</div>
<div class="info-note">
Files are read up to 100KB. Larger files are truncated with a note. Binary files (images, videos, etc.)
cannot be read as text.
</div>
</section>
<!-- Bitcoin -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x20BF;</span>
Bitcoin Node Status
</h2>
<p class="section-desc">
If you have Bitcoin Core running on your node, AIUI can check the blockchain sync status,
current block height, and mempool information in real-time.
</p>
<div class="example-box">
<div class="example-prompt">"How's my Bitcoin node doing?"</div>
<div class="example-prompt">"What block height am I on?"</div>
<div class="example-prompt">"Is my node fully synced?"</div>
<div class="example-prompt">"How many transactions are in the mempool?"</div>
</div>
</section>
<!-- Lightning -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x26A1;</span>
Lightning Network (LND)
</h2>
<p class="section-desc">
AIUI can query your LND node for channel information, peer count, on-chain and channel balances,
and sync status. Your private keys and macaroons are never exposed.
</p>
<div class="example-box">
<div class="example-prompt">"What's my Lightning balance?"</div>
<div class="example-prompt">"How many channels do I have open?"</div>
<div class="example-prompt">"How many peers is my node connected to?"</div>
<div class="example-prompt">"Is my Lightning node synced?"</div>
</div>
</section>
<!-- App Logs -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F4CB;</span>
App Logs
</h2>
<p class="section-desc">
When an app isn't working right, AIUI can pull recent log output to help diagnose issues.
It reads the last 50 lines by default (up to 200).
</p>
<div class="example-box">
<div class="example-prompt">"Why is Mempool not working?"</div>
<div class="example-prompt">"Show me the Bitcoin Core logs"</div>
<div class="example-prompt">"What errors is Nextcloud showing?"</div>
<div class="example-prompt">"Show me the last 100 lines of LND logs"</div>
</div>
</section>
<!-- App Management -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F4E6;</span>
App Management
</h2>
<p class="section-desc">
AIUI can help you navigate your node, open installed apps, and even install new ones from the
marketplace. It checks what's already installed before recommending anything.
</p>
<div class="example-box">
<div class="example-prompt">"Open Mempool"</div>
<div class="example-prompt">"Install BTCPay Server"</div>
<div class="example-prompt">"Take me to the Settings page"</div>
<div class="example-prompt">"What apps are available to install?"</div>
</div>
</section>
<!-- Chat Features -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F4AC;</span>
Chat Features
</h2>
<div class="feature-grid">
<div class="feature-card">
<h3>Conversation History</h3>
<p>All chats are saved locally on your node. Switch between conversations using the history panel.</p>
</div>
<div class="feature-card">
<h3>Edit Messages</h3>
<p>Click any of your sent messages to edit and re-send them. The AI will regenerate its response.</p>
</div>
<div class="feature-card">
<h3>Branch Conversations</h3>
<p>Fork a conversation at any point to explore a different direction without losing the original thread.</p>
</div>
<div class="feature-card">
<h3>Web Search</h3>
<p>When enabled, AIUI can search the web to find current information and include sources in its responses.</p>
</div>
<div class="feature-card">
<h3>Image Support</h3>
<p>Attach images to your messages for visual questions. AIUI can analyze screenshots, photos, and diagrams.</p>
</div>
<div class="feature-card">
<h3>Dark / Light Theme</h3>
<p>AIUI adapts to your preferred theme. Toggle between dark and light mode in the settings.</p>
</div>
</div>
</section>
<!-- Permissions -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F512;</span>
Privacy & Permissions
</h2>
<p class="section-desc">
AIUI only accesses what you allow. Your node data categories (apps, files, wallet, bitcoin, network, system)
are permission-gated. You control exactly what context AIUI can see through the Archy permissions panel.
</p>
<div class="info-note">
All processing happens through your node's Claude proxy. Your conversations and node data never touch
third-party servers beyond the AI model API itself. Private keys, seeds, and macaroons are never exposed.
</div>
</section>
<!-- Tips -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F4A1;</span>
Tips
</h2>
<ul class="tips-list">
<li>Be specific — "Read my bitcoin.conf" works better than "show me config files"</li>
<li>AIUI remembers context within a conversation, so you can ask follow-up questions</li>
<li>If something seems wrong with an app, ask AIUI to check the logs first</li>
<li>You can ask AIUI to explain what a config file does after reading it</li>
<li>Use the history panel to return to previous conversations at any time</li>
</ul>
</section>
<!-- Try It -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F680;</span>
Try It Out
</h2>
<p class="section-desc">
Load a demo conversation that showcases all the node search capabilities described above.
You'll see example exchanges for checking Bitcoin status, reading files, viewing logs, and more.
</p>
<button class="demo-button" :disabled="demoLoading" @click="loadDemo">
<span v-if="demoLoading" class="demo-spinner" />
<span v-else>{{ demoLoaded ? 'View Demo Chat' : 'Load Demo Conversation' }}</span>
</button>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useChatStore } from '@/stores/chat'
const router = useRouter()
const chatStore = useChatStore()
const demoLoading = ref(false)
const demoLoaded = ref(false)
function goBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
async function loadDemo() {
demoLoading.value = true
try {
await chatStore.loadNodeDemoChat()
demoLoaded.value = true
// Navigate to chat page to see the demo
router.push('/')
} finally {
demoLoading.value = false
}
}
</script>
<style scoped>
.guide-page {
min-height: 100vh;
background: #0a0a0a;
color: rgba(255, 255, 255, 0.9);
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
}
.guide-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: all 0.2s ease;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.guide-title {
font-size: 18px;
font-weight: 600;
flex: 1;
}
.guide-version {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
padding: 2px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
}
.guide-content {
max-width: 680px;
margin: 0 auto;
padding: 24px 20px 80px;
}
.guide-intro {
font-size: 15px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.7);
}
.guide-section {
margin-bottom: 36px;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 17px;
font-weight: 600;
margin-bottom: 12px;
color: rgba(255, 255, 255, 0.95);
}
.section-icon {
font-size: 20px;
}
.section-desc {
font-size: 14px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 16px;
}
.section-desc code {
background: rgba(255, 255, 255, 0.08);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
font-family: Menlo, monospace;
color: rgba(255, 255, 255, 0.8);
}
.example-box {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.example-prompt {
padding: 10px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
font-style: italic;
}
.info-note {
padding: 12px 16px;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.2);
border-radius: 10px;
font-size: 13px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.6);
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.feature-card {
padding: 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
}
.feature-card h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.9);
}
.feature-card p {
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.5);
}
.tips-list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.tips-list li {
padding: 10px 14px;
padding-left: 28px;
position: relative;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.6);
}
.tips-list li::before {
content: '\2022';
position: absolute;
left: 14px;
color: #F7931A;
}
.demo-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 14px 20px;
border-radius: 12px;
border: 1px solid rgba(247, 147, 26, 0.3);
background: rgba(247, 147, 26, 0.1);
color: #F7931A;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.demo-button:hover:not(:disabled) {
background: rgba(247, 147, 26, 0.18);
border-color: rgba(247, 147, 26, 0.5);
transform: translateY(-1px);
}
.demo-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.demo-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(247, 147, 26, 0.3);
border-top-color: #F7931A;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
+1 -1
View File
@@ -5,7 +5,7 @@
* Never make direct HTTP requests to the host machine.
*/
type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files'
type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files' | 'bitcoin'
interface ContextResponse {
data: unknown
+77 -1
View File
@@ -173,7 +173,10 @@ export const useChatStore = defineStore('chat', () => {
_loaded = true
}
loadChats()
loadChats().then(() => {
// Seed guide + node demo conversations on first use
seedDemoConversations()
})
// Persist active conversation ID to localStorage
watch(activeConversationId, (id) => {
@@ -416,6 +419,76 @@ export const useChatStore = defineStore('chat', () => {
}
}
/** Load node demo conversation showing local node capabilities */
async function loadNodeDemoChat(): Promise<number> {
try {
const { nodeDemoToConversation } = await import('@/__tests__/fixtures/nodeDemoPrompts')
const conv = nodeDemoToConversation() as Conversation
if (conversations.value.has(conv.id)) {
activeConversationId.value = conv.id
return 0
}
const merged = new Map(conversations.value)
merged.set(conv.id, conv)
conversations.value = merged
activeConversationId.value = conv.id
immediateIDBSave(conv)
return conv.messages.length / 2
} catch {
return 0
}
}
/** Load guide conversation (auto-loaded on first visit) */
async function loadGuide(): Promise<void> {
try {
const { guideToConversation } = await import('@/__tests__/fixtures/guideConversation')
const conv = guideToConversation() as Conversation
if (conversations.value.has(conv.id)) {
activeConversationId.value = conv.id
return
}
const merged = new Map(conversations.value)
merged.set(conv.id, conv)
conversations.value = merged
activeConversationId.value = conv.id
immediateIDBSave(conv)
} catch {
// ignore
}
}
/** Seed demo conversations (guide + node demo) on first use */
async function seedDemoConversations(): Promise<void> {
if (conversations.value.has('aiui-guide') && conversations.value.has('node-demo')) return
try {
const { guideToConversation } = await import('@/__tests__/fixtures/guideConversation')
const { nodeDemoToConversation } = await import('@/__tests__/fixtures/nodeDemoPrompts')
const guide = guideToConversation() as Conversation
const demo = nodeDemoToConversation() as Conversation
const merged = new Map(conversations.value)
if (!merged.has(guide.id)) {
merged.set(guide.id, guide)
immediateIDBSave(guide)
}
if (!merged.has(demo.id)) {
merged.set(demo.id, demo)
immediateIDBSave(demo)
}
conversations.value = merged
// Show guide on first load
if (!activeConversationId.value) {
activeConversationId.value = guide.id
}
} catch {
// ignore
}
}
return {
conversations,
activeConversationId,
@@ -443,5 +516,8 @@ export const useChatStore = defineStore('chat', () => {
branchFromMessage,
getSiblingBranches,
loadSeedChats,
loadNodeDemoChat,
loadGuide,
seedDemoConversations,
}
})
+25
View File
@@ -0,0 +1,25 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useVideoPlayerStore = defineStore('videoPlayer', () => {
const isOpen = ref(false)
const videoUrl = ref('')
const title = ref('')
const posterUrl = ref<string | null>(null)
function open(url: string, filmTitle: string, filmPoster?: string | null) {
videoUrl.value = url
title.value = filmTitle
posterUrl.value = filmPoster ?? null
isOpen.value = true
}
function close() {
isOpen.value = false
videoUrl.value = ''
title.value = ''
posterUrl.value = null
}
return { isOpen, videoUrl, title, posterUrl, open, close }
})
+31
View File
@@ -646,6 +646,37 @@ input:focus-visible {
50% { opacity: 1; }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.animate-shimmer {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.06) 40%,
rgba(255, 255, 255, 0.12) 50%,
rgba(255, 255, 255, 0.06) 60%,
rgba(255, 255, 255, 0) 100%
);
background-size: 200% 100%;
animation: shimmer 1.8s ease-in-out infinite;
}
.light .animate-shimmer {
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.04) 40%,
rgba(0, 0, 0, 0.08) 50%,
rgba(0, 0, 0, 0.04) 60%,
rgba(0, 0, 0, 0) 100%
);
background-size: 200% 100%;
animation: shimmer 1.8s ease-in-out infinite;
}
@keyframes panelSlideIn {
0% {
opacity: 0;
+214 -195
View File
@@ -1,29 +1,63 @@
import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import { loadEnv } from 'vite'
export interface MusicSearchResult {
source: string
type: 'stream' | 'embed'
source: 'wavlake'
type: 'stream'
url: string
title?: string
artist?: string
coverUrl?: string
duration?: number
trackId?: string
albumTitle?: string
wavlakeUrl?: string
}
// ─── Wavlake API types ───────────────────────────────────────
interface WavlakeSearchItem {
id: string
title?: string
name?: string
type: 'track' | 'artist' | 'album'
albumArtUrl?: string
artistArtUrl?: string
artistId?: string
albumId?: string
albumTitle?: string
mediaUrl?: string
artist?: string
duration?: number
}
interface WavlakeTrack {
id: string
title: string
albumTitle?: string
artist: string
artistId?: string
albumId?: string
artistArtUrl?: string
albumArtUrl?: string
mediaUrl: string
duration?: number
msatTotal?: string
url?: string
}
// ─── Server-side LRU cache ────────────────────────────────────
const CACHE_MAX = 200
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
const CACHE_TTL = 60 * 60 * 1000 // 1 hour for positive results
const NULL_CACHE_TTL = 5 * 60 * 1000 // 5 minutes for not-found results
const searchCache = new Map<string, { result: MusicSearchResult | null; ts: number }>()
function cacheKey(q: string, title?: string, artist?: string): string {
return `${q}|${title ?? ''}|${artist ?? ''}`
}
function getCached(key: string): MusicSearchResult | null | undefined {
const entry = searchCache.get(key)
if (!entry) return undefined
if (Date.now() - entry.ts > CACHE_TTL) {
const ttl = entry.result ? CACHE_TTL : NULL_CACHE_TTL
if (Date.now() - entry.ts > ttl) {
searchCache.delete(key)
return undefined
}
@@ -38,32 +72,17 @@ function setCache(key: string, result: MusicSearchResult | null) {
searchCache.set(key, { result, ts: Date.now() })
}
// ─── Scoring helpers ──────────────────────────────────────────
// ─── Scoring ─────────────────────────────────────────────────
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
}
function scoreJamendoTrack(
track: { name: string; artist_name: string },
function scoreTrack(
track: { title?: string; name?: string; artist?: 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)
const t = (track.title ?? track.name ?? '').toLowerCase()
const a = (track.artist ?? '').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
@@ -74,172 +93,131 @@ function scoreJamendoTrack(
return score
}
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
// ─── Wavlake search ──────────────────────────────────────────
const WAVLAKE_API = 'https://wavlake.com/api/v1'
async function wavlakeSearch(term: string): Promise<WavlakeSearchItem[]> {
const res = await fetch(
`${WAVLAKE_API}/content/search?term=${encodeURIComponent(term)}`,
{ signal: AbortSignal.timeout(8000) },
)
if (!res.ok) return []
const items = (await res.json()) as WavlakeSearchItem[]
return Array.isArray(items) ? items : []
}
// ─── Provider search functions ────────────────────────────────
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' }, signal: AbortSignal.timeout(8000) },
)
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}`, {
signal: AbortSignal.timeout(5000),
})
.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
}
/** Strip parenthetical suffixes, "feat.", and other noise from titles */
function cleanTitle(t: string): string {
return t
.replace(/\s*[\(\[].*?[\)\]]/g, '') // (feat. X), [Remix], etc.
.replace(/\s*[-–—]\s*(feat|ft|featuring)\.?\s*.*/i, '')
.trim()
}
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}`, {
signal: AbortSignal.timeout(6000),
})
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 (!/^https?:\/\//i.test(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
}
}
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`,
{ signal: AbortSignal.timeout(6000) },
)
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
}
}
// ─── Parallel search with preference ordering ─────────────────
async function searchAllProviders(
async function searchWavlake(
q: string,
jamendoClientId: string | undefined,
title?: string,
artist?: string,
): Promise<MusicSearchResult | null> {
// Fire all providers in parallel — streams are preferred over embeds
const providers = [
searchInternetArchive(q, title, artist),
jamendoClientId ? searchJamendo(q, jamendoClientId, title, artist) : Promise.resolve(null),
searchOdysee(q, title, artist),
]
try {
// Wavlake search works best with short queries — combining title+artist
// often returns nothing. Strategy: search title first, fall back to
// cleaned title, combined query, then artist-only.
const searches: string[] = []
if (title) searches.push(title)
const cleaned = title ? cleanTitle(title) : ''
if (cleaned && cleaned !== title) searches.push(cleaned)
if (title && artist) searches.push(`${title} ${artist}`)
if (artist) searches.push(artist)
if (!title && !artist) searches.push(q)
const results = await Promise.allSettled(providers)
let allTracks: WavlakeSearchItem[] = []
// Prefer streams (IA, Jamendo) over embeds (Odysee)
for (const r of results) {
if (r.status === 'fulfilled' && r.value?.type === 'stream') return r.value
for (const term of searches) {
const items = await wavlakeSearch(term)
const tracks = items.filter(
(item): item is WavlakeSearchItem & { mediaUrl: string } =>
item.type === 'track' && !!item.mediaUrl,
)
if (tracks.length > 0) {
allTracks = tracks
break
}
}
if (allTracks.length === 0) return null
// Score and pick best match using both title and artist
const best =
title && artist && allTracks.length > 1
? allTracks.reduce((a, b) =>
scoreTrack(b, title, artist) > scoreTrack(a, title, artist) ? b : a,
)
: allTracks[0]
return {
source: 'wavlake',
type: 'stream',
url: best.mediaUrl,
title: best.title ?? best.name,
artist: best.artist,
coverUrl: best.albumArtUrl ?? best.artistArtUrl,
duration: best.duration,
trackId: best.id,
albumTitle: best.albumTitle,
wavlakeUrl: `https://wavlake.com/track/${best.id}`,
}
} catch {
return null
}
for (const r of results) {
if (r.status === 'fulfilled' && r.value) return r.value
}
return null
}
// ─── Vite middleware ──────────────────────────────────────────
// ─── Wavlake rankings ────────────────────────────────────────
function createMusicSearchMiddleware(
jamendoClientId: string | undefined,
) {
async function getWavlakeRankings(
days: number,
genre?: string,
limit?: number,
): Promise<MusicSearchResult[]> {
try {
const params = new URLSearchParams({
sort: 'sats',
days: String(days),
limit: String(limit ?? 20),
})
if (genre) params.set('genre', genre)
const res = await fetch(`${WAVLAKE_API}/content/rankings?${params}`, {
signal: AbortSignal.timeout(8000),
})
if (!res.ok) return []
const tracks = (await res.json()) as WavlakeTrack[]
if (!Array.isArray(tracks)) return []
return tracks
.filter(t => !!t.mediaUrl)
.map(t => ({
source: 'wavlake' as const,
type: 'stream' as const,
url: t.mediaUrl,
title: t.title,
artist: t.artist,
coverUrl: t.albumArtUrl ?? t.artistArtUrl,
duration: t.duration,
trackId: t.id,
albumTitle: t.albumTitle,
wavlakeUrl: t.url ?? `https://wavlake.com/track/${t.id}`,
}))
} catch {
return []
}
}
// ─── Vite middleware: search ─────────────────────────────────
function createSearchMiddleware() {
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'}`)
@@ -252,24 +230,27 @@ function createMusicSearchMiddleware(
return
}
try {
const key = cacheKey(q, title ?? undefined, artist ?? undefined)
// Normalize cache key: use title|artist when available (q is ignored by searchWavlake in that case)
const key = (title || artist) ? `search|${title ?? ''}|${artist ?? ''}` : `search|${q}||`
const cached = getCached(key)
if (cached !== undefined) {
res.setHeader('Content-Type', 'application/json')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=3600')
res.setHeader('X-Cache', 'HIT')
res.end(JSON.stringify(cached ?? { error: 'No results from any source' }))
res.end(JSON.stringify(cached ?? { error: 'No results on Wavlake' }))
return
}
const result = await searchAllProviders(q, jamendoClientId, title ?? undefined, artist ?? undefined)
console.log(`[music-search] Wavlake query: q="${q}" title="${title ?? ''}" artist="${artist ?? ''}"`)
const result = await searchWavlake(q, title ?? undefined, artist ?? undefined)
console.log(`[music-search] Result: ${result ? `"${result.title}" by ${result.artist}` : 'null'}`)
setCache(key, result)
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' }))
res.end(JSON.stringify(result ?? { error: 'No results on Wavlake' }))
} catch (err) {
console.error('[music-search]', err)
res.writeHead(502, { 'Content-Type': 'application/json' })
@@ -278,20 +259,58 @@ function createMusicSearchMiddleware(
}
}
export function musicSearchPlugin(): Plugin {
let jamendoClientId: string | undefined
// ─── Vite middleware: rankings ────────────────────────────────
function createRankingsMiddleware() {
const rankingsCache = new Map<string, { data: MusicSearchResult[]; ts: number }>()
const RANKINGS_TTL = 10 * 60 * 1000 // 10 minutes
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 days = parseInt(url.searchParams.get('days') ?? '7', 10)
const genre = url.searchParams.get('genre')?.trim() || undefined
const limit = parseInt(url.searchParams.get('limit') ?? '20', 10)
const cacheKey = `rankings|${days}|${genre ?? ''}|${limit}`
const cached = rankingsCache.get(cacheKey)
if (cached && Date.now() - cached.ts < RANKINGS_TTL) {
res.setHeader('Content-Type', 'application/json')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=600')
res.setHeader('X-Cache', 'HIT')
res.end(JSON.stringify(cached.data))
return
}
try {
const results = await getWavlakeRankings(days, genre, limit)
rankingsCache.set(cacheKey, { data: results, ts: Date.now() })
res.setHeader('Content-Type', 'application/json')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=600')
res.end(JSON.stringify(results))
} catch (err) {
console.error('[music-rankings]', err)
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
}
}
// ─── Plugin ──────────────────────────────────────────────────
export function musicSearchPlugin(): Plugin {
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))
server.middlewares.use('/api/music/search', createSearchMiddleware())
server.middlewares.use('/api/music/rankings', createRankingsMiddleware())
},
configurePreviewServer(server) {
server.middlewares.use('/api/music/search', createMusicSearchMiddleware(jamendoClientId))
server.middlewares.use('/api/music/search', createSearchMiddleware())
server.middlewares.use('/api/music/rankings', createRankingsMiddleware())
},
}
}
+19 -3
View File
@@ -23,7 +23,13 @@ export default defineConfig({
fsPlugin(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icon.svg', 'apple-touch-icon-180x180.png'],
includeAssets: [
'favicon.svg',
'icon.svg',
'apple-touch-icon-180x180.png',
'pwa-192x192.png',
'pwa-512x512.png',
],
manifest: {
id: './',
name: 'AIUI',
@@ -58,7 +64,9 @@ export default defineConfig({
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
globPatterns: ['**/*.{js,css,html,svg,woff2}'],
globIgnores: ['**/assets/img/films/**', '**/assets/img/tv/**'],
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.anthropic\.com\/.*/i,
@@ -100,10 +108,18 @@ export default defineConfig({
expiration: { maxEntries: 200, maxAgeSeconds: 604800 },
},
},
{
urlPattern: /^https:\/\/d12wklypp119aj\.cloudfront\.net\/image\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'wavlake-images',
expiration: { maxEntries: 300, maxAgeSeconds: 604800 },
},
},
],
},
devOptions: {
enabled: false,
enabled: true,
},
}),
],
+1 -1
View File
@@ -21,7 +21,7 @@ export interface Film {
}
export interface FilmSource {
type: 'plex' | 'nextcloud' | 'youtube' | 'free-web'
type: 'plex' | 'nextcloud' | 'youtube' | 'free-web' | 'indeehub'
name: string
url: string
quality?: string