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:
co-authored by
Claude Opus 4.6
parent
c84c0fb424
commit
b77c93607a
@@ -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.
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>
|
||||
@@ -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.0–friendly 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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">📡</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">📁</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">₿</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">⚡</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">📋</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">📦</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">💬</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">🔒</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">💡</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">🚀</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>
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
@@ -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())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user