rename to client

This commit is contained in:
Laan Tungir
2026-04-17 16:52:51 -04:00
commit a8bbbbafb9
465 changed files with 522173 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
hamburger_morphing/
client/
ndk/
blossom/
todosv/
strudel/
strudel.cc/
nips/
/routstr-chat/
/routstr/
/routstr-core/
/NostrTerminal_5/
/reference_repos/
/Trash/
# Generated Strudel docs (large files, regenerate with extract-strudel-docs.js)
www/js/strudel-docs-raw.json
www/js/strudel-docs.json
www/js/strudel-docs.md
www/js/strudel-api.md

14
.gitmodules vendored Normal file
View File

@@ -0,0 +1,14 @@
[submodule "greyscale"]
path = greyscale
url = ssh://git@git.laantungir.net:2222/laantungir/greyscale.git
branch = master
[submodule "ndk"]
path = ndk
url = ssh://git@git.laantungir.net:2222/laantungir/ndk.git
branch = master
[submodule "nostr_login_lite"]
path = nostr_login_lite
url = ssh://git@git.laantungir.net:2222/laantungir/nostr_login_lite.git
branch = master

View File

@@ -0,0 +1,9 @@
---
description: "Safely syntax-check the module script embedded in www/post.html without heredoc chaining issues"
---
Run the following two commands exactly, as separate lines:
python3 -c 'import re,pathlib;html=pathlib.Path("www/post.html").read_text(encoding="utf-8");m=re.search(r"<script type=\"module\">([\\s\\S]*?)</script>\\s*</body>",html);assert m,"module script not found in post.html";pathlib.Path("build/post-module-check.mjs").write_text(m.group(1),encoding="utf-8");print("extracted module script to build/post-module-check.mjs")'
node --check build/post-module-check.mjs

9
.roo/commands/push.md Normal file
View File

@@ -0,0 +1,9 @@
---
description: "First, we run increment and commit, which updates git, and pushes to the remote repo. Then we upload our changed files to our web server."
---
Run: ./increment_and_commit.sh "This is a meaningful git commit message"
Run: ./upload_www.sh
Do not use bash for these commands. Just run them as shown. Don't check git status.

0
.roo/mcp.json Normal file
View File

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"liveServer.settings.port": 5501
}

182
README.md Normal file
View File

@@ -0,0 +1,182 @@
# client
A nostr web application framework built on [NDK (Nostr Development Kit)](https://github.com/nostr-dev-kit/ndk). Uses a SharedWorker architecture where a single NDK instance manages all relay connections, caching, and subscriptions across multiple browser tabs.
## Architecture Overview
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ index.html │ │ post.html │ │ cashu.html │ ... (any page)
│ init-ndk.mjs│ │ init-ndk.mjs│ │ init-ndk.mjs│
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ postMessage │ │
└─────────────┬────┘─────────────────┘
┌─────────────────┐
│ ndk-worker.js │ (SharedWorker — single instance)
│ ┌─────────────┐ │
│ │ NDK │ │
│ │ + Dexie │ │ ← IndexedDB cache (ndk-shared)
│ │ + Signer │ │
│ └──────┬──────┘ │
└─────────┼────────┘
┌────────────────────────┐
│ Nostr Relay Pool │
│ wss://relay1 relay2 │
└────────────────────────┘
```
## Event Management Strategy
This section defines **when and how** nostr events are fetched, cached, and subscribed to. It serves as the single source of truth for event lifecycle management.
### Cache Behavior
All events flowing through NDK are automatically cached in **IndexedDB via the Dexie adapter** (database: `ndk-shared`). This means:
- Any `ndk.fetchEvents()` call caches results automatically
- Any subscription event received is cached via `cacheAdapter.setEvent()`
- Pages can query the cache directly via `queryCache()` without hitting relays
- NDK's `CACHE_FIRST` mode returns cached data immediately, then updates from relays
### Startup Events (fetched by worker on init)
These events are fetched once during `handleInit()` when the first tab connects. They populate the Dexie cache and provide essential app-wide state.
| Priority | Kind | NIP | Name | Filter | Subscription | Purpose |
|----------|------|-----|------|--------|-------------|---------|
| 1 | `0` | NIP-01 | User Metadata | `authors:[pubkey]` | No — one-shot | Profile display (name, picture, about) |
| 2 | `10002` | NIP-65 | Relay List | `authors:[pubkey], limit:1` | **Yes** — persistent `closeOnEose:false` | Configure relay pool; live updates |
| 3 | `30078` | NIP-78 | App Settings | `authors:[pubkey], #d:[user-settings]` | No — one-shot | Encrypted user preferences (NIP-44) |
| 4 | `17375` | NIP-60 | Cashu Wallet | `authors:[pubkey]` | **Yes** — startup fetch + persistent | Wallet metadata, mint list, and live wallet changes |
| 5 | `7375` | NIP-60 | Cashu Tokens | `authors:[pubkey]` | **Yes** — startup fetch + persistent | Unspent proofs and live token changes |
| 6 | `5` | NIP-09 | Event Deletion | `authors:[pubkey], #k:[7375]` | **Yes** — startup fetch + persistent | Track spent/deleted tokens |
**Startup sequence (current implementation):**
```
handleInit(pubkey)
├── initNDK() // Create NDK, connect relays
├── hydrateUserSettingsForPubkey() // kind 30078
├── fetchUserProfile() // kind 0
├── fetchUserRelays() // kind 10002 (one-shot)
├── subscribe kind 10002 // persistent relay list updates
├── fetchStartupEventDownloads() // one-shot preload: kinds 3, 17375, 7375, 5(#k=7375)
└── ensureStartupWalletSubscriptions() // persistent wallet subscriptions: 17375, 7375, 5(#k=7375)
```
This means wallet information is not only prefetched at startup; it is now also continuously updated by long-lived worker subscriptions.
### On-Demand Events (fetched by individual pages)
These events are fetched when a specific page loads. They benefit from the Dexie cache — if the same events were previously fetched, they're served from cache first.
| Kind | NIP | Name | Pages | Pattern |
|------|-----|------|-------|---------|
| `0` | NIP-01 | User Metadata | profile, msg, post, npub | `fetchEventsFromAllRelays` or `subscribe` |
| `1` | NIP-01 | Short Text Notes | post, index, links, strudel, template | `subscribe` with `CACHE_FIRST` |
| `3` | NIP-02 | Contact List | post, profile, cashu | `subscribe` + `queryCache` + `fetchEventsFromAllRelays` |
| `4` | NIP-04 | Encrypted DMs | msg | `subscribe` with `CACHE_FIRST` |
| `1059` | NIP-44 | Gift Wrap (DMs) | msg | `subscribe` with `CACHE_FIRST` |
| `5` | NIP-09 | Event Deletion | (via startup sub) | Persistent subscription |
| `7` | NIP-25 | Reactions | post (via interactions) | `fetchEventsFromAllRelays` |
| `6` | NIP-18 | Reposts | post (via interactions) | `fetchEventsFromAllRelays` |
| `9735` | NIP-57 | Zap Receipts | post (via interactions) | `fetchEventsFromAllRelays` |
| `10019` | NIP-60 | Mint List | cashu (mint discovery) | `fetchEventsFromAllRelays` |
| `10063` | NIP-96 | Blossom Server List | blobs | `subscribe` with `CACHE_FIRST` |
| `17375` | NIP-60 | Cashu Wallet | cashu (full wallet ops) | Via NDKCashuWallet |
| `7375` | NIP-60 | Cashu Tokens | cashu (full wallet ops) | Via NDKCashuWallet (cache-first) |
| `7376` | NIP-60 | Spending History | cashu (transaction list) | Via NDKCashuWallet |
| `30023` | NIP-23 | Long-form Content | note | `subscribe` with `CACHE_FIRST` |
| `30024` | NIP-23 | Draft Long-form | note | `subscribe` with `CACHE_FIRST` |
| `30078` | NIP-78 | App-specific Data | todo, cal, links, post (viewed) | `subscribe` or `fetchEvents` |
| `38000` | NIP-89 | Recommendations | cashu (mint discovery) | `fetchEventsFromAllRelays` |
| `38421` | — | AI Service Announcements | ai | `subscribe` with `CACHE_FIRST` |
### Subscription Patterns
The app uses three distinct patterns for fetching events:
#### 1. One-shot Fetch
```javascript
// Fetch once, cache result, done
const events = await ndk.fetchEvents(filter);
```
Used for: startup profile, relay list, wallet definition, user settings
#### 2. Subscribe with CACHE_FIRST
```javascript
// Returns cached events immediately, then live events from relays
subscribe(filter, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
```
Used for: most page-level subscriptions (posts, contacts, notes, DMs)
#### 3. Persistent Worker Subscription
```javascript
// Long-lived subscription in the worker, survives page navigation
ndk.subscribe(filter, { closeOnEose: false });
```
Used for: relay list updates (kind 10002), wallet metadata (kind 17375), token events (kind 7375), and token deletions (kind 5 with #k=7375)
### Cache-First Advantage
Because startup events are fetched early and cached in Dexie, subsequent page loads benefit:
- **post.html** subscribes to kind 3 (contacts) → if profile.html already fetched it, cache hit
- **cashu.html** starts NDKCashuWallet → kind 7375 tokens already in cache from startup → instant optimistic balance
- **Any page** fetching kind 0 (profile) → already cached from startup
### Modifying Event Timing
To change when an event kind is fetched:
1. **Move to startup:** Add the fetch to `fetchStartupWalletState()` or create a new startup function in `handleInit()`. This pre-populates the cache for all pages.
2. **Move to on-demand:** Remove from startup, let individual pages fetch as needed. First load will be slower but startup will be faster.
3. **Add a subscription:** For events that change frequently, add a persistent subscription in the worker. This keeps the cache fresh without page-level polling.
**Trade-offs:**
- More startup events = slower initial load, faster page navigation
- Fewer startup events = faster initial load, slower first page that needs the data
- Persistent subscriptions = always fresh data, more relay bandwidth
## Project Structure
```
www/
├── ndk-worker.js # SharedWorker — NDK instance, cache, subscriptions
├── js/
│ ├── init-ndk.mjs # Client-side NDK API (shared by all pages)
│ ├── relay-ui.mjs # Footer relay status + balance display
│ ├── cashu-wallet.mjs # Cashu wallet controller (cashu.html only)
│ ├── post-interactions.mjs # Social interactions (reactions, reposts, zaps)
│ └── utilities.mjs # Shared utilities
├── css/client.css # Shared styles
├── *.html # Individual pages
└── ndk-core.bundle.js # Bundled NDK library
build/
├── ndk-entry.js # Bundle entry point
└── ndk-core.bundle.js # Built bundle
ndk/ # NDK source (submodule/clone)
├── core/ # Core NDK library
├── wallet/ # Cashu wallet implementation
├── cache-browser/ # Browser cache adapter
├── cache-dexie/ # Dexie (IndexedDB) cache
└── ...
plans/ # Architecture and implementation plans
```
## Building
```bash
# Build the NDK bundle
node build-ndk-bundle.js
# Serve locally
python serve_local.py
```

Binary file not shown.

125
agent_browser_login.sh Executable file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# agent_browser_login.sh
# Opens a headed, isolated agent-browser session and logs in to any client
# page using the nsec from ~/.wsb_key/private_key.txt.
#
# Usage:
# ./agent_browser_login.sh [session-name] [url]
#
# Defaults:
# session-name : auto-login-$$ (unique per run)
# url : https://laantungir.net/client/index.html
set -euo pipefail
SESSION="${1:-auto-login-$$}"
RAW_URL="${2:-index.html}"
BASE_URL="https://laantungir.net/client"
# If just a page name (no http/https/slash prefix), expand to full client URL
if [[ "$RAW_URL" != http* && "$RAW_URL" != /* ]]; then
URL="${BASE_URL}/${RAW_URL}"
else
URL="$RAW_URL"
fi
KEY_FILE="${HOME}/.wsb_key/private_key.txt"
if [[ ! -f "$KEY_FILE" ]]; then
echo "ERROR: Key file not found: $KEY_FILE" >&2
exit 1
fi
NSEC=$(cat "$KEY_FILE" | tr -d '[:space:]')
if [[ -z "$NSEC" ]]; then
echo "ERROR: Key file is empty: $KEY_FILE" >&2
exit 1
fi
echo "[agent_browser_login] Session: $SESSION"
echo "[agent_browser_login] URL: $URL"
echo "[agent_browser_login] Key: ${NSEC:0:12}..."
# Open the page in an isolated headed session
agent-browser --session-name "$SESSION" open "$URL"
agent-browser --session-name "$SESSION" wait --load networkidle
# Check if already logged in (Logout button present = already authed)
IS_LOGGED_IN=$(agent-browser --session-name "$SESSION" eval \
"document.querySelector('button')?.textContent?.includes('Logout') || \
[...document.querySelectorAll('button')].some(b=>b.textContent.trim()==='Logout') \
? 'yes' : 'no'" 2>/dev/null || echo "no")
if [[ "$IS_LOGGED_IN" == "yes" ]]; then
echo "[agent_browser_login] Already logged in, logging out first..."
agent-browser --session-name "$SESSION" eval \
"[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Logout')?.click()"
agent-browser --session-name "$SESSION" wait 1000
fi
# Launch the login modal
echo "[agent_browser_login] Launching login modal..."
agent-browser --session-name "$SESSION" eval "window.NOSTR_LOGIN_LITE?.launch()"
agent-browser --session-name "$SESSION" wait 500
# Click "Local Key"
echo "[agent_browser_login] Clicking Local Key..."
agent-browser --session-name "$SESSION" eval \
"[...document.querySelectorAll('button')].find(b=>b.textContent.includes('Local Key'))?.click()"
agent-browser --session-name "$SESSION" wait 500
# Fill in the nsec key
echo "[agent_browser_login] Filling in nsec..."
agent-browser --session-name "$SESSION" eval \
"(()=>{
const i = document.querySelector('input[type=password], input[placeholder*=nsec], input[placeholder*=secret], textarea[placeholder*=nsec]')
|| [...document.querySelectorAll('input,textarea')].find(el=>el.placeholder?.toLowerCase().includes('secret'));
if(!i) return 'ERROR: no key input found';
i.value = '$NSEC';
i.dispatchEvent(new Event('input',{bubbles:true}));
i.dispatchEvent(new Event('change',{bubbles:true}));
return 'filled';
})()"
agent-browser --session-name "$SESSION" wait 300
# Click "Import Key"
echo "[agent_browser_login] Clicking Import Key..."
agent-browser --session-name "$SESSION" eval \
"[...document.querySelectorAll('button')].find(b=>b.textContent.includes('Import Key') && !b.disabled)?.click()"
agent-browser --session-name "$SESSION" wait 1000
# Click "Continue" if shown
agent-browser --session-name "$SESSION" eval \
"[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Continue')?.click()" 2>/dev/null || true
agent-browser --session-name "$SESSION" wait 2000
# Verify login
PUBKEY=$(agent-browser --session-name "$SESSION" eval \
"window.nostr?.getPublicKey ? 'pending' : 'none'" 2>/dev/null || echo "none")
agent-browser --session-name "$SESSION" eval \
"window.nostr?.getPublicKey().then(pk=>window._loginPubkey=pk)" 2>/dev/null || true
agent-browser --session-name "$SESSION" wait 1000
PUBKEY=$(agent-browser --session-name "$SESSION" eval \
"window._loginPubkey || 'not logged in'" 2>/dev/null || echo "not logged in")
echo "[agent_browser_login] Logged in as: $PUBKEY"
# If this is bunker.html, extract and print the bunker URI
if [[ "$URL" == *"bunker.html"* ]]; then
agent-browser --session-name "$SESSION" wait 2000
BUNKER_URI=$(agent-browser --session-name "$SESSION" eval \
"[...document.querySelectorAll('textarea')].map(t=>t.value).find(v=>v.startsWith('bunker://'))" \
| tr -d '"')
if [[ -n "$BUNKER_URI" && "$BUNKER_URI" != "null" ]]; then
echo "[agent_browser_login] Bunker URI: $BUNKER_URI"
echo "BUNKER_URI=$BUNKER_URI"
else
echo "[agent_browser_login] WARNING: Could not read bunker URI (bunker may not be started yet)"
fi
fi
echo "[agent_browser_login] Session '$SESSION' is ready."
echo ""
echo "To use this session in subsequent agent-browser commands:"
echo " agent-browser --session-name \"$SESSION\" snapshot"

82
agent_browser_remote_login.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# agent_browser_remote_login.sh
# Opens a headed browser window in a NEW isolated session (separate
# localStorage/cookies from agent_browser_login.sh sessions) for testing
# NIP-46 remote login.
#
# Usage:
# ./agent_browser_remote_login.sh [session-name] [page] [bunker-uri]
#
# Examples:
# ./agent_browser_remote_login.sh remote1 index.html
# ./agent_browser_remote_login.sh remote1 index.html "bunker://..."
set -euo pipefail
SESSION="${1:-remote-$$}"
RAW_URL="${2:-index.html}"
BUNKER_URI="${3:-}"
BASE_URL="https://laantungir.net/client"
# Expand bare page name to full URL
if [[ "$RAW_URL" != http* && "$RAW_URL" != /* ]]; then
URL="${BASE_URL}/${RAW_URL}"
else
URL="$RAW_URL"
fi
echo "[remote_login] Session: $SESSION (isolated from other sessions)"
echo "[remote_login] URL: $URL"
# Open the page — --session-name gives each session its own isolated storage
agent-browser --session-name "$SESSION" open "$URL"
agent-browser --session-name "$SESSION" wait --load networkidle
# Clear any stored auth so this session starts fresh
agent-browser --session-name "$SESSION" eval \
"(()=>{
const keys = Object.keys(localStorage).filter(k=>k.includes('auth')||k.includes('nostr'));
keys.forEach(k=>localStorage.removeItem(k));
const skeys = Object.keys(sessionStorage).filter(k=>k.includes('auth')||k.includes('nostr'));
skeys.forEach(k=>sessionStorage.removeItem(k));
return 'cleared ' + keys.length + ' localStorage + ' + skeys.length + ' sessionStorage keys';
})()"
# Reload so the cleared state takes effect
agent-browser --session-name "$SESSION" eval "location.reload()"
agent-browser --session-name "$SESSION" wait --load networkidle
agent-browser --session-name "$SESSION" wait 1000
echo "[remote_login] Page loaded fresh (no stored auth)."
# If a bunker URI was provided, auto-fill and connect
if [[ -n "$BUNKER_URI" ]]; then
echo "[remote_login] Auto-connecting with bunker URI..."
# Click Nostr Connect
agent-browser --session-name "$SESSION" eval \
"[...document.querySelectorAll('button')].find(b=>b.textContent.includes('Nostr Connect'))?.click()"
agent-browser --session-name "$SESSION" wait 500
# Fill bunker URI — write to a global var first to avoid quoting issues
agent-browser --session-name "$SESSION" eval "window._bunkerUri = atob('$(echo -n "$BUNKER_URI" | base64 -w0)')"
agent-browser --session-name "$SESSION" eval \
"(()=>{
const i = document.querySelector('input[placeholder*=\"bunker\"]');
if (!i) return 'ERROR: no bunker input found';
i.value = window._bunkerUri;
i.dispatchEvent(new Event('input', {bubbles:true}));
return 'filled: ' + i.value.substring(0,40);
})()"
agent-browser --session-name "$SESSION" wait 300
# Click Connect to Bunker
agent-browser --session-name "$SESSION" eval \
"[...document.querySelectorAll('button')].find(b=>b.textContent.includes('Connect to Bunker')&&!b.disabled)?.click()"
echo "[remote_login] Connect initiated — approve in the bunker window if needed."
fi
echo ""
echo "[remote_login] Session '$SESSION' is ready."
echo "To control: agent-browser --session-name \"$SESSION\" snapshot"

213
build-ndk-bundle.js Normal file
View File

@@ -0,0 +1,213 @@
/**
* NDK Browser Bundle Builder
*
* Creates a browser-compatible IIFE bundle of NDK with:
* - Core NDK functionality
* - Browser cache
* - SQLite WASM cache
* - Sessions
* - Messages
* - Web of Trust (WoT)
* - Blossom
* - Uses the existing window.NostrTools from nostr.bundle.js
* - Exposes NDK as window.NDK
*/
const esbuild = require('esbuild');
const path = require('path');
const fs = require('fs');
async function buildNDKBundle() {
console.log('🔧 Building NDK browser bundle with additional packages...');
console.log('📦 Including: core, wallet, cache-browser, cache-dexie, cache-sqlite-wasm, sessions, messages, wot, blossom');
const __dirname = path.resolve();
// Create a virtual entry point that imports all packages using absolute paths
const entryContent = `
// Import the default NDK class
import NDK from '${path.join(__dirname, 'ndk/core/src/index.ts')}';
// Import default exports from cache packages
import NDKCacheBrowser from '${path.join(__dirname, 'ndk/cache-browser/src/index.ts')}';
import NDKCacheAdapterDexie from '${path.join(__dirname, 'ndk/cache-dexie/src/index.ts')}';
import NDKCacheAdapterSqliteWasm from '${path.join(__dirname, 'ndk/cache-sqlite-wasm/src/index.ts')}';
import { CashuMint, CashuWallet, getDecodedToken, getEncodedTokenV4 } from '${path.join(__dirname, 'ndk/node_modules/@cashu/cashu-ts/lib/cashu-ts.es.js')}';
// Re-export everything as named exports
export * from '${path.join(__dirname, 'ndk/core/src/index.ts')}';
export * from '${path.join(__dirname, 'ndk/cache-browser/src/index.ts')}';
export * from '${path.join(__dirname, 'ndk/cache-dexie/src/index.ts')}';
export * from '${path.join(__dirname, 'ndk/cache-sqlite-wasm/src/index.ts')}';
export * from '${path.join(__dirname, 'ndk/sessions/src/index.ts')}';
export * from '${path.join(__dirname, 'ndk/messages/src/index.ts')}';
export * from '${path.join(__dirname, 'ndk/wot/src/index.ts')}';
export * from '${path.join(__dirname, 'ndk/blossom/src/index.ts')}';
export * from '${path.join(__dirname, 'ndk/wallet/src/index.ts')}';
// Re-export default exports as named exports
export {
NDKCacheBrowser,
NDKCacheAdapterDexie,
NDKCacheAdapterSqliteWasm,
CashuMint,
CashuWallet,
getDecodedToken,
getEncodedTokenV4
};
// Export the main NDK class as default
export default NDK;
`;
// Write the virtual entry point
const entryPath = path.join(__dirname, 'build/ndk-entry.js');
fs.mkdirSync('build', { recursive: true });
fs.writeFileSync(entryPath, entryContent);
try {
const result = await esbuild.build({
entryPoints: [entryPath],
bundle: true,
format: 'iife',
globalName: 'NDK',
outfile: 'build/ndk-core.bundle.js',
platform: 'browser',
target: 'es2020',
minify: false, // Set to true for production
sourcemap: true,
metafile: true, // Enable metafile for analysis
// Plugin to map nostr-tools imports to window.NostrTools
plugins: [{
name: 'nostr-tools-external',
setup(build) {
// Intercept all nostr-tools imports (including subpaths like nostr-tools/nip49)
build.onResolve({ filter: /^nostr-tools/ }, args => {
return {
path: args.path,
namespace: 'nostr-tools-external'
};
});
// Replace with window.NostrTools reference
build.onLoad({ filter: /.*/, namespace: 'nostr-tools-external' }, (args) => {
// Extract subpath if present (e.g., "nostr-tools/nip49" -> "nip49")
const subpath = args.path.replace(/^nostr-tools\/?/, '');
return {
contents: `
// Map nostr-tools to window.NostrTools
if (typeof window === 'undefined' || !window.NostrTools) {
throw new Error('NDK: nostr.bundle.js must be loaded before ndk-core.bundle.js');
}
${subpath ? `
// Access subpath: ${subpath}
if (!window.NostrTools.${subpath}) {
console.warn('nostr-tools/${subpath} not found in window.NostrTools');
module.exports = {};
} else {
module.exports = window.NostrTools.${subpath};
}
` : `
module.exports = window.NostrTools;
`}
`,
loader: 'js'
};
});
}
}],
// Define environment variables for browser
define: {
'process.env.NODE_ENV': '"production"',
'global': 'window'
},
// Banner to add at the top of the bundle
banner: {
js: `/**
* NDK (Nostr Development Kit) - Browser Bundle
* Version: 3.0.0-beta.64
*
* IMPORTANT: This bundle requires nostr.bundle.js to be loaded first!
*
* Usage:
* <script src="nostr.bundle.js"></script>
* <script src="ndk-core.bundle.js"></script>
*
* Then access via: window.NDK
*
* Generated: ${new Date().toISOString()}
*/
`
}
});
// Save metafile for analysis
fs.writeFileSync('build/meta.json', JSON.stringify(result.metafile, null, 2));
const stats = fs.statSync('build/ndk-core.bundle.js');
const sizeKB = (stats.size / 1024).toFixed(2);
console.log('✅ NDK bundle created successfully!');
console.log(`📏 Bundle size: ${sizeKB} KB`);
console.log('📄 Output: build/ndk-core.bundle.js');
console.log('📄 Source map: build/ndk-core.bundle.js.map');
console.log('');
// Analyze bundle composition
console.log('📊 Bundle Composition Analysis:');
console.log('');
const inputs = result.metafile.inputs;
const packageSizes = {};
for (const [file, info] of Object.entries(inputs)) {
let packageName = 'other';
if (file.includes('ndk/core/')) packageName = 'core';
else if (file.includes('ndk/cache-browser/')) packageName = 'cache-browser';
else if (file.includes('ndk/cache-dexie/')) packageName = 'cache-dexie';
else if (file.includes('ndk/cache-sqlite-wasm/')) packageName = 'cache-sqlite-wasm';
else if (file.includes('ndk/sessions/')) packageName = 'sessions';
else if (file.includes('ndk/messages/')) packageName = 'messages';
else if (file.includes('ndk/wot/')) packageName = 'wot';
else if (file.includes('ndk/blossom/')) packageName = 'blossom';
else if (file.includes('node_modules/@noble/')) packageName = 'noble-crypto';
else if (file.includes('node_modules/')) packageName = 'dependencies';
if (!packageSizes[packageName]) packageSizes[packageName] = 0;
packageSizes[packageName] += info.bytes;
}
// Sort by size
const sorted = Object.entries(packageSizes)
.sort((a, b) => b[1] - a[1])
.map(([name, bytes]) => ({
name,
kb: (bytes / 1024).toFixed(2),
percent: ((bytes / stats.size) * 100).toFixed(1)
}));
for (const item of sorted) {
const bar = '█'.repeat(Math.floor(item.percent / 2));
console.log(` ${item.name.padEnd(20)} ${item.kb.padStart(8)} KB ${item.percent.padStart(5)}% ${bar}`);
}
console.log('');
console.log('📋 Usage:');
console.log(' 1. Load nostr.bundle.js first');
console.log(' 2. Load ndk-core.bundle.js second');
console.log(' 3. Access via window.NDK');
} catch (error) {
console.error('❌ Build failed:', error);
process.exit(1);
}
}
// Run the build
buildNDKBundle();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

369
build/.feed-check.mjs Normal file
View File

@@ -0,0 +1,369 @@
import {
initNDKPage,
getPubkey,
injectHeaderAvatar,
subscribe,
disconnect,
getVersion,
queryCache,
fetchEventsFromAllRelays,
fetchCachedProfile,
storeProfile,
publishEvent,
getUserSettings,
patchUserSettings,
onUserSettings
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initPostCards } from './js/post-interactions2.mjs';
const INITIAL_POSTS_LOAD = 10;
const SEE_MORE_INCREMENT = 20;
let currentPubkey = null;
let updateIntervalId = null;
let unsubscribeUserSettings = null;
let hamburgerInstance = null;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
let isNavOpen = false;
const posts = [];
const postIds = new Set();
const renderedPostIds = new Set();
let displayCount = INITIAL_POSTS_LOAD;
let followedPubkeys = [];
const feedPubkeys = new Set();
const divBody = document.getElementById('divBody');
const divSideNav = document.getElementById('divSideNav');
const divFooterRight = document.getElementById('divFooterRight');
const divFeed = document.getElementById('divFeed');
const divHint = document.getElementById('divHint');
const btnSeeMore = document.getElementById('btnSeeMore');
const chkAutoplayVideos = document.getElementById('chkAutoplayVideos');
let autoplayVideosEnabled = false;
let liveFeedSubscriptionKey = '';
const cards = initPostCards({
subscribe,
publishEvent,
getPubkey,
fetchEventsFromAllRelays,
fetchCachedProfile,
storeProfile
});
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = '50vw';
isNavOpen = true;
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
}
function closeNav() {
divSideNav.style.width = '0vw';
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
}
function toggleNav() {
isNavOpen ? closeNav() : openNav();
}
function isOriginalPost(event) {
const eTags = event.tags?.filter(t => t[0] === 'e') || [];
if (eTags.length === 0) return true;
return eTags.every(t => t[3] === 'mention');
}
function applyVideoAutoplaySetting(enabled) {
autoplayVideosEnabled = !!enabled;
if (chkAutoplayVideos) chkAutoplayVideos.checked = autoplayVideosEnabled;
const videoEls = Array.from(divFeed.querySelectorAll('video'));
videoEls.forEach((videoEl) => {
videoEl.controls = true;
videoEl.playsInline = true;
videoEl.setAttribute('playsinline', '');
videoEl.preload = 'metadata';
if (autoplayVideosEnabled) {
videoEl.autoplay = true;
videoEl.muted = true;
videoEl.setAttribute('autoplay', '');
videoEl.setAttribute('muted', '');
const playPromise = videoEl.play?.();
if (playPromise?.catch) playPromise.catch(() => {});
} else {
videoEl.autoplay = false;
videoEl.muted = false;
videoEl.removeAttribute('autoplay');
videoEl.removeAttribute('muted');
}
});
}
function applySettings(settings) {
const nextAutoplayEnabled = !!settings?.feed?.videoAutoplay;
applyVideoAutoplaySetting(nextAutoplayEnabled);
}
async function persistAutoplaySetting(enabled) {
try {
await patchUserSettings({
feed: {
videoAutoplay: !!enabled
}
});
} catch (error) {
console.warn('[feed.html] Failed to persist feed autoplay setting:', error?.message || error);
}
}
function renderFeed() {
posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
const toShow = posts.slice(0, displayCount);
toShow.forEach((post) => {
if (renderedPostIds.has(post.id)) return;
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled });
divFeed.appendChild(postEl);
renderedPostIds.add(post.id);
});
cards.wireInteractions(toShow.map(p => p.id), { currentPubkey });
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
divHint.textContent = followedPubkeys.length > 0
? `${posts.length} posts from ${followedPubkeys.length} follows`
: 'Follow users to populate your feed.';
divFooterRight.textContent = `${posts.length} posts`;
}
function ensureLiveFeedSubscription() {
const authors = Array.from(feedPubkeys).filter(Boolean);
if (authors.length === 0) return;
const key = authors.slice().sort().join(',');
if (key === liveFeedSubscriptionKey) return;
const newestKnown = posts.reduce((max, p) => Math.max(max, p?.created_at || 0), 0);
const now = Math.floor(Date.now() / 1000);
const since = newestKnown > 0 ? Math.max(0, newestKnown - 30) : (now - 3600);
subscribe(
{ kinds: [1], authors, since },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
liveFeedSubscriptionKey = key;
console.log('[feed.html] Live feed subscription active', { authors: authors.length, since });
}
async function ingestContactList(evt) {
if (!evt || evt.kind !== 3 || evt.pubkey !== currentPubkey) return;
const pTags = evt.tags?.filter(tag => tag[0] === 'p' && tag[1]) || [];
followedPubkeys = pTags.map(tag => tag[1]);
const newAuthors = followedPubkeys.filter((pk) => !feedPubkeys.has(pk));
newAuthors.forEach((pk) => feedPubkeys.add(pk));
ensureLiveFeedSubscription();
if (newAuthors.length > 0) {
try {
const [cached, freshByRelay] = await Promise.all([
queryCache({ kinds: [1], authors: Array.from(feedPubkeys), limit: 120 }).catch(() => []),
fetchEventsFromAllRelays({ kinds: [1], authors: newAuthors, limit: INITIAL_POSTS_LOAD }).catch(() => ({}))
]);
cached.forEach((evt1) => {
if (!evt1?.id || postIds.has(evt1.id) || !isOriginalPost(evt1)) return;
postIds.add(evt1.id);
posts.push(evt1);
});
Object.values(freshByRelay).flat().forEach((evt1) => {
if (!evt1?.id || postIds.has(evt1.id) || !isOriginalPost(evt1)) return;
postIds.add(evt1.id);
posts.push(evt1);
});
renderFeed();
} catch (_) {
renderFeed();
}
} else {
renderFeed();
}
}
async function logout() {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
cards.destroy();
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
if (unsubscribeUserSettings) {
unsubscribeUserSettings();
unsubscribeUserSettings = null;
}
localStorage.clear();
sessionStorage.clear();
location.reload(true);
}
async function updateFooter() {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
} catch (_) {}
}
(async function main() {
try {
const versionInfo = await getVersion();
document.getElementById('versionDisplay').textContent = versionInfo.VERSION;
initHamburgerMenu();
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
document.getElementById('themeToggleButton')?.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
document.getElementById('logoutButton')?.addEventListener('click', logout);
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
const nextVisible = !cards.getCommentsVisible();
cards.setCommentsVisible(nextVisible);
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
});
btnSeeMore.addEventListener('click', () => {
displayCount += SEE_MORE_INCREMENT;
renderFeed();
});
chkAutoplayVideos?.addEventListener('change', async () => {
const nextEnabled = !!chkAutoplayVideos.checked;
applyVideoAutoplaySetting(nextEnabled);
await persistAutoplaySetting(nextEnabled);
});
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
try {
const settings = await getUserSettings();
applySettings(settings || {});
} catch (error) {
console.warn('[feed.html] Failed to load user settings:', error?.message || error);
applySettings({});
}
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings((settings) => {
applySettings(settings || {});
});
}
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
await updateFooter();
updateIntervalId = setInterval(updateFooter, 1000);
window.addEventListener('ndkRelayActivity', (e) => {
setRelayActivityState(e.detail.relayUrl, e.detail.activity);
});
window.addEventListener('ndkEvent', async (e) => {
const evt = e.detail;
if (!evt) return;
if (evt.kind === 3 && evt.pubkey === currentPubkey) {
await ingestContactList(evt);
return;
}
if (evt.kind === 1 && feedPubkeys.has(evt.pubkey) && isOriginalPost(evt) && !postIds.has(evt.id)) {
postIds.add(evt.id);
posts.push(evt);
renderFeed();
}
});
subscribe({ kinds: [3], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
const cachedContactEvents = await queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 }).catch(() => []);
if (cachedContactEvents.length > 0) {
const latest = cachedContactEvents.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
await ingestContactList(latest);
}
if (feedPubkeys.size === 0) {
divHint.textContent = 'Follow users to populate your feed.';
}
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
} catch (error) {
console.error('[feed.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:red;">❌ Error</div><div style="font-size:16px;">${error.message}</div></div>`;
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,505 @@
/* ================================================================
IMPORTS
================================================================ */
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
// Version will be loaded asynchronously
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[index.html ${VERSION}] Loading...`);
/* ================================================================
GLOBAL VARIABLES
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
let startupStatusMessage = '';
let startupStatusUntil = 0;
let walletFooterStatusMessage = '';
let walletFooterStatusUntil = 0;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
/* ================================================================
DOM VARIABLES
================================================================ */
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
/* ================================================================
APP DEFINITIONS
================================================================ */
const arrApps = [
{
id: `relays`,
svg: `<circle r="1" cx="7" cy="3" /> <circle r="1" cx="3" cy="3" /> <circle r="1" cx="3" cy="7" /> <circle r="1" cx="7" cy="7" />`,
name: `RELAYS`,
},
{
id: `db`,
svg: `<line x1="2" y1="2" x2="8" y2="2" /> <line x1="2" y1="5" x2="8" y2="5" /> <line x1="2" y1="8" x2="8" y2="8" />`,
name: `DB`
},
{
id: `tools`,
svg: `<path d="M5,8 L5,3 L8,4 L8,2 L5,3 L2,3 "/>`,
name: `TOOLS`,
},
// {
// id: `files`,
// svg: `<path d="M2,2 L2,8 "/> <path d="M4,2 L4,8 "/><path d="M6,2 L6,8"/><path d="M8,2 L8,8"/> `,
// name: `FILES`,
// },
{
id: `links`,
svg: `<path d="M2,2 L2,2 M2,5 L2,5 M2,8 L2,8 M5,2 L5,2 M5,5 L5,5 M5,8 L5,8 M8,2 L8,2 M8,5 L8,5 M8,8 L8,8 "/>`,
name: `LINKS`,
},
{
id: `profile`,
svg: `<circle cx=5 cy=5 r=3 fill="none" />`,
name: `P-FILE`,
},
{
id: `ws`,
svg: `<circle r="2.8284271247461903" cx="5" cy="5" />
<line x1="6" y1="6" x2="6" y2="6" />
<line x1="4" y1="4" x2="4" y2="4" /> `,
name: `WS`,
},
{
id: `post`,
svg: `<path d="M2,8 L8,2 L5,2 L8,2 L8,5 " /> `,
name: `POST`,
},
{
id: `feed`,
svg: `<path d="M2,5 L2,8 L5,8 L2,8 L8,2 " />`,
name: `FEED`,
},
{
id: `view`,
svg: `<path d="M2,5 L2,8 L5,8 L2,8 L8,2 " />`,
name: `VIEW`,
},
{
id: `love`,
svg: `<circle cx="3.5" cy="5" r="1.5" /><circle cx="7" cy="5" r="1.5" />`,
name: `LOVE`,
},
{
id: `msg`,
svg: `<path d="M5,2 L8,2 L8,5 L8,2 L2,8 L2,5 L2,8 L5,8 " /> `,
name: `MSG`,
},
{
id: `event`,
svg: `<circle cx=5 cy=5 r=3 fill="none" />`,
name: `EVENT`,
},
{
id: `note`,
svg: `<polygon fill="none" points="6,2 6,4 8,4 8,8 2,8 2,2 " />`,
name: `NOTE`,
},
{ id: `todo`, svg: `<path d=" M2,6 L4,8 L8,2"/>`, name: `TODO` },
{ id: `map`, svg: `<path d="M2,8 L2,2 L8,2 L8,8 L4,8 L4,4 L6,4 L6,6 "/>`, name: `MAP` },
{
id: `people`,
svg: `<path d="M2,2 L6,2 L8,6 L7,6 L7,8 L2,8 L2,2 " /> `,
name: `PEOPLE`,
},
{
id: `cal`,
svg: `<polygon points="2,3 3,6 3,8 7,8 7,6 8,3 6,8 5,8 " />
<line x1="6" y1="3" x2="6" y2="3" />
<line x1="4" y1="2" x2="4" y2="2" />
<line x1="5" y1="5" x2="5" y2="5" />
<rect x="3" y="6" width="4" height="2" />`,
name: `CAL`,
},
{
id: `block`,
svg: `<circle cx="5" cy="5" r="3" fill="none" /> <path d="M3,3 L7,7" />`,
name: `BLOCK`,
},
{
id: `conway`,
svg: `<circle cx="5" cy="2.45" r="0.85" fill="currentColor" stroke="none" />
<circle cx="7.55" cy="5" r="0.85" fill="currentColor" stroke="none" />
<circle cx="2.45" cy="7.55" r="0.85" fill="currentColor" stroke="none" />
<circle cx="5" cy="7.55" r="0.85" fill="currentColor" stroke="none" />
<circle cx="7.55" cy="7.55" r="0.85" fill="currentColor" stroke="none" />`,
name: `CONWAY`,
},
{
id: `slide-show`,
svg: `<circle cx="2" cy="2" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="5" cy="2" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="8" cy="2" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="2" cy="5" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="5" cy="5" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="8" cy="5" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="2" cy="8" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="5" cy="8" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="8" cy="8" r="1.3" fill="currentColor" stroke="none"/>`,
name: `SLIDES`,
},
{
id: `blobs`,
// svg: `<rect fill="none" x="2" y="2" width="6" height="6" />`,
svg: `<rect fill="none" x="4" y="4" width="2" height="2" />`,
name: `BLOBS`,
},
{ id: `ai`, svg: `<rect x="3" y="4" width="4" height="4" /><line x1="4" y1="6" x2="4" y2="6" /><line x1="6" y1="6" x2="6" y2="6" /><line x1="5" y1="4" x2="5" y2="2" /><line x1="5" y1="2" x2="5" y2="2" />`, name: `AI` },
{ id: `skills-tv`, svg: `<rect fill="none" x="2" y="3" width="6" height="4" /><line x1="3" y1="8" x2="7" y2="8" /><line x1="5" y1="3" x2="5" y2="2" />`, name: `SKILLS TV` },
{ id: `skills-edit`, svg: ``, name: `SKILLS EDIT` },
{ id: `cashu`, svg: `<circle cx="5" cy="4" r="2.5" /><circle cx="5" cy="6" r="2.5" />`, name: `CASHU` },
{ id: `didactyl`, svg: `<path stroke-width="3.98" d="M3,3 L3,7 L7,7 L7,3" />`, name: `AGENT` },
{ id: `strudel`, svg: `<circle cx="4" cy="7" r="1" /><line x1="5" y1="7" x2="5" y2="2" /><path d="M5,2 C6,2 7,3 7,4" />`, name: `STRUDEL` },
{ id: `music`, svg: `<path d="M2,6 C2,3 3,2 5,2 C7,2 8,3 8,6" /><rect x="2" y="6" width="1" height="2" /><rect x="7" y="6" width="1" height="2" />`, name: `MUSIC` },
{ id: `keep-alive`, svg: ``, name: `KEEP ALIVE` },
{ id: `vj`, svg: ``, name: `VJ` },
{ id: `ai-tv`, svg: ``, name: `AI TV` },
];
/* ================================================================
HAMBURGER MENU
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
/* ================================================================
SIDENAV FUNCTIONS
================================================================ */
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
divSideNavBody.innerHTML = "Settings and options coming soon...";
// Initialize version bar buttons when sidenav opens (lazy load)
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
// Determine current theme
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
const initialShape = isDarkMode ? 'moon' : 'circle';
themeToggleHamburger.animateTo(initialShape);
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
/* ================================================================
LOAD APP SCREEN
================================================================ */
const LoadAppScreen = async (providedPubkey = null) => {
const xmlns = "http://www.w3.org/2000/svg";
divBody.innerHTML = "";
const pubkey = providedPubkey || currentPubkey || null;
let divTitle = document.createElement(`div`);
divTitle.setAttribute(`id`, `divNpubTitle`);
divTitle.innerHTML = "NPUB";
let div = document.createElement(`a`);
div.setAttribute(`id`, `divNpub`);
div.setAttribute(`class`, `divAppButtons`);
if (pubkey) {
const isDark = document.body.classList.contains('dark-mode');
const svgNpub = QRCode({ msg: pubkey, dim: 170, ecl: 'L', pal: isDark ? ['#d9d9d9', '#000000'] : ['#000000', '#ffffff'] });
svgNpub.style.background = isDark ? '#000000' : '#ffffff';
svgNpub.style.padding = '6px';
svgNpub.style.borderRadius = '8px';
div.setAttribute(`href`, `./npub.html?npub=${pubkey}`);
div.setAttribute(`target`, `_blank`);
div.setAttribute(`rel`, `noopener noreferrer`);
div.appendChild(svgNpub);
div.appendChild(divTitle);
}
divBody.appendChild(div);
let htmlOut = "";
for (let Each of arrApps) {
const url = Each.id === 'feed'
? './feed.html'
: Each.id === 'view'
? './post.html?follows=true&post=false&viewed=true'
: Each.id === 'love'
? './notifications.html'
: Each.id === 'event'
? './event-management.html'
: `./${Each.id}.html`;
htmlOut += `<a id="div${Each.id}" class="divAppButtons" href="${url}" target="_blank" rel="noopener noreferrer">
<svg id="${Each.id}" class="svgAppButtons" viewBox="0 0 10 10" >
${Each.svg}
</svg>
${Each.name}
</a>`;
}
divBody.innerHTML = divBody.innerHTML + htmlOut;
divBody.style.justifyContent = `space-around`;
divBody.style.alignContent = `space-around`;
divBody.style.alignItems = ``;
divBody.style.height = "";
};
/* ================================================================
UPDATE FOOTER
================================================================ */
const UpdateFooter = async () => {
try {
// Update relay status visuals in footer and sidenav
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
// Keep startup/wallet status visible in footer center while active
const now = Date.now();
if (now < walletFooterStatusUntil && walletFooterStatusMessage) {
divFooterCenter.textContent = walletFooterStatusMessage;
} else if (now < startupStatusUntil && startupStatusMessage) {
divFooterCenter.textContent = startupStatusMessage;
} else {
divFooterCenter.innerHTML = '';
}
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[index.html] Error updating footer:', error);
}
};
/* ================================================================
LOGOUT
================================================================ */
const Logout = async () => {
console.log("[index.html] Starting logout process...");
// Stop the update loop
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
// Disconnect from worker
disconnect();
// Logout from nostr-login-lite
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
// Clear all storage
localStorage.clear();
sessionStorage.clear();
// Clear IndexedDB
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
console.log("[index.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
EVENT LISTENERS
================================================================ */
/* ================================================================
INITIALIZATION
================================================================ */
(async function main() {
console.log("[index.html] Starting initialization...");
try {
// Initialize hamburger menu first
initHamburgerMenu();
// Add click handler to hamburger
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
// Initialize version bar buttons
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
if (isDarkMode) {
localStorage.setItem('theme', 'dark');
} else {
localStorage.setItem('theme', 'light');
}
// Save sidenav state before reload
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
await Logout();
} catch (error) {
console.error('Logout failed:', error);
}
});
}
// Render app shell immediately so worker startup does not block first paint
await LoadAppScreen();
// Initialize NDK (handles authentication automatically)
await initNDKPage();
// Get authenticated pubkey
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
console.log("[index.html] Authenticated as:", currentPubkey);
// Initialize relay UI components
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
// Listen for relay activity broadcasts from worker
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[index.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
// Show startup progress messages from worker in center footer section
window.addEventListener('ndkStartupStatus', (event) => {
const msg = String(event?.detail?.message || '').trim();
if (!msg) return;
console.log(`[index.html] Startup status: ${msg}`, event?.detail || {});
startupStatusMessage = msg;
startupStatusUntil = Date.now() + 12000;
divFooterCenter.textContent = msg;
});
// Show wallet balance/status updates from worker in center footer section
window.addEventListener('ndkWalletBalance', (event) => {
const balance = Number(event?.detail?.balance || 0);
const msg = `Wallet balance: ${balance.toLocaleString()} sats`;
walletFooterStatusMessage = msg;
walletFooterStatusUntil = Date.now() + 12000;
console.log(`[index.html] ${msg}`, event?.detail || {});
divFooterCenter.textContent = msg;
});
window.addEventListener('ndkWalletStatus', (event) => {
const status = String(event?.detail?.status || '').trim();
if (!status) return;
const msg = `Wallet status: ${status}`;
walletFooterStatusMessage = msg;
walletFooterStatusUntil = Date.now() + 12000;
console.log(`[index.html] ${msg}`, event?.detail || {});
divFooterCenter.textContent = msg;
});
// Hydrate app screen with authenticated data (NPUB QR)
await LoadAppScreen(currentPubkey);
// Restore sidenav state if it was open before theme toggle
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
// Start update loop (updates footer every second)
updateIntervalId = setInterval(UpdateFooter, 1000);
// Update version display
await updateVersionDisplay();
console.log('[index.html] Initialization complete');
} catch (error) {
console.error('[index.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
<div style="font-size: 16px; color: #666;">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();

View File

@@ -0,0 +1,500 @@
/* ================================================================
IMPORTS
================================================================ */
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
// Version will be loaded asynchronously
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[index.html ${VERSION}] Loading...`);
/* ================================================================
GLOBAL VARIABLES
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
let startupStatusMessage = '';
let startupStatusUntil = 0;
let walletFooterStatusMessage = '';
let walletFooterStatusUntil = 0;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
/* ================================================================
DOM VARIABLES
================================================================ */
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
/* ================================================================
APP DEFINITIONS
================================================================ */
const arrApps = [
{
id: `relays`,
svg: `<circle r="1" cx="7" cy="3" /> <circle r="1" cx="3" cy="3" /> <circle r="1" cx="3" cy="7" /> <circle r="1" cx="7" cy="7" />`,
name: `RELAYS`,
},
{
id: `db`,
svg: `<line x1="2" y1="2" x2="8" y2="2" /> <line x1="2" y1="5" x2="8" y2="5" /> <line x1="2" y1="8" x2="8" y2="8" />`,
name: `DB`
},
{
id: `tools`,
svg: `<path d="M5,8 L5,3 L8,4 L8,2 L5,3 L2,3 "/>`,
name: `TOOLS`,
},
// {
// id: `files`,
// svg: `<path d="M2,2 L2,8 "/> <path d="M4,2 L4,8 "/><path d="M6,2 L6,8"/><path d="M8,2 L8,8"/> `,
// name: `FILES`,
// },
{
id: `links`,
svg: `<path d="M2,2 L2,2 M2,5 L2,5 M2,8 L2,8 M5,2 L5,2 M5,5 L5,5 M5,8 L5,8 M8,2 L8,2 M8,5 L8,5 M8,8 L8,8 "/>`,
name: `LINKS`,
},
{
id: `profile`,
svg: `<circle cx=5 cy=5 r=3 fill="none" />`,
name: `P-FILE`,
},
{
id: `ws`,
svg: `<circle r="2.8284271247461903" cx="5" cy="5" />
<line x1="6" y1="6" x2="6" y2="6" />
<line x1="4" y1="4" x2="4" y2="4" /> `,
name: `WS`,
},
{
id: `post`,
svg: `<path d="M2,8 L8,2 L5,2 L8,2 L8,5 " /> `,
name: `POST`,
},
{
id: `feed`,
svg: `<path d="M2,5 L2,8 L5,8 L2,8 L8,2 " />`,
name: `FEED`,
},
{
id: `view`,
svg: `<path d="M2,5 L2,8 L5,8 L2,8 L8,2 " />`,
name: `VIEW`,
},
{
id: `love`,
svg: `<circle cx="3.5" cy="5" r="1.5" /><circle cx="7" cy="5" r="1.5" />`,
name: `LOVE`,
},
{
id: `msg`,
svg: `<path d="M5,2 L8,2 L8,5 L8,2 L2,8 L2,5 L2,8 L5,8 " /> `,
name: `MSG`,
},
{
id: `event`,
svg: `<circle cx=5 cy=5 r=3 fill="none" />
<path d=" M2,2 L8,8 M2,8 L8,2"/>`,
name: `EVENT`,
},
{
id: `note`,
svg: `<polygon fill="none" points="6,2 6,4 8,4 8,8 2,8 2,2 " />`,
name: `NOTE`,
},
{ id: `todo`, svg: `<path d=" M2,6 L4,8 L8,2"/>`, name: `TODO` },
{ id: `map`, svg: `<path d="M2,8 L2,2 L8,2 L8,8 L4,8 L4,4 L6,4 L6,6 "/>`, name: `MAP` },
{
id: `people`,
svg: `<path d="M2,2 L6,2 L8,6 L7,6 L7,8 L2,8 L2,2 " /> `,
name: `PEOPLE`,
},
{
id: `cal`,
svg: `<polygon points="2,3 3,6 3,8 7,8 7,6 8,3 6,8 5,8 " />
<line x1="6" y1="3" x2="6" y2="3" />
<line x1="4" y1="2" x2="4" y2="2" />
<line x1="5" y1="5" x2="5" y2="5" />
<rect x="3" y="6" width="4" height="2" />`,
name: `CAL`,
},
{
id: `block`,
svg: `<circle cx="5" cy="5" r="3" fill="none" /> <path d="M3,3 L7,7" />`,
name: `BLOCK`,
},
{
id: `conway`,
svg: `<circle cx="5" cy="2.45" r="0.85" fill="currentColor" stroke="none" />
<circle cx="7.55" cy="5" r="0.85" fill="currentColor" stroke="none" />
<circle cx="2.45" cy="7.55" r="0.85" fill="currentColor" stroke="none" />
<circle cx="5" cy="7.55" r="0.85" fill="currentColor" stroke="none" />
<circle cx="7.55" cy="7.55" r="0.85" fill="currentColor" stroke="none" />`,
name: `CONWAY`,
},
{
id: `slide-show`,
svg: `<circle cx="2" cy="2" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="5" cy="2" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="8" cy="2" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="2" cy="5" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="5" cy="5" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="8" cy="5" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="2" cy="8" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="5" cy="8" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="8" cy="8" r="1.3" fill="currentColor" stroke="none"/>`,
name: `SLIDES`,
},
{
id: `blobs`,
// svg: `<rect fill="none" x="2" y="2" width="6" height="6" />`,
svg: `<rect fill="none" x="4" y="4" width="2" height="2" />`,
name: `BLOBS`,
},
{ id: `ai`, svg: `<rect x="3" y="4" width="4" height="4" /><line x1="4" y1="6" x2="4" y2="6" /><line x1="6" y1="6" x2="6" y2="6" /><line x1="5" y1="4" x2="5" y2="2" /><line x1="5" y1="2" x2="5" y2="2" />`, name: `AI` },
{ id: `skills-tv`, svg: `<rect fill="none" x="2" y="3" width="6" height="4" /><line x1="3" y1="8" x2="7" y2="8" /><line x1="5" y1="3" x2="5" y2="2" />`, name: `SKILLS TV` },
{ id: `cashu`, svg: `<circle cx="5" cy="4" r="2.5" /><circle cx="5" cy="6" r="2.5" />`, name: `CASHU` },
{ id: `didactyl`, svg: `<path stroke-width="3.98" d="M3,3 L3,7 L7,7 L7,3" />`, name: `AGENT` },
{ id: `strudel`, svg: `<circle cx="4" cy="7" r="1" /><line x1="5" y1="7" x2="5" y2="2" /><path d="M5,2 C6,2 7,3 7,4" />`, name: `STRUDEL` },
{ id: `music`, svg: `<path d="M2,6 C2,3 3,2 5,2 C7,2 8,3 8,6" /><rect x="2" y="6" width="1" height="2" /><rect x="7" y="6" width="1" height="2" />`, name: `MUSIC` },
];
/* ================================================================
HAMBURGER MENU
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
/* ================================================================
SIDENAV FUNCTIONS
================================================================ */
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
divSideNavBody.innerHTML = "Settings and options coming soon...";
// Initialize version bar buttons when sidenav opens (lazy load)
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
// Determine current theme
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
const initialShape = isDarkMode ? 'moon' : 'circle';
themeToggleHamburger.animateTo(initialShape);
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
/* ================================================================
LOAD APP SCREEN
================================================================ */
const LoadAppScreen = async (providedPubkey = null) => {
const xmlns = "http://www.w3.org/2000/svg";
divBody.innerHTML = "";
const pubkey = providedPubkey || currentPubkey || null;
let divTitle = document.createElement(`div`);
divTitle.setAttribute(`id`, `divNpubTitle`);
divTitle.innerHTML = "NPUB";
let div = document.createElement(`a`);
div.setAttribute(`id`, `divNpub`);
div.setAttribute(`class`, `divAppButtons`);
if (pubkey) {
const isDark = document.body.classList.contains('dark-mode');
const svgNpub = QRCode({ msg: pubkey, dim: 170, ecl: 'L', pal: isDark ? ['#d9d9d9', '#000000'] : ['#000000', '#ffffff'] });
svgNpub.style.background = isDark ? '#000000' : '#ffffff';
svgNpub.style.padding = '6px';
svgNpub.style.borderRadius = '8px';
div.setAttribute(`href`, `./npub.html?npub=${pubkey}`);
div.setAttribute(`target`, `_blank`);
div.setAttribute(`rel`, `noopener noreferrer`);
div.appendChild(svgNpub);
div.appendChild(divTitle);
}
divBody.appendChild(div);
let htmlOut = "";
for (let Each of arrApps) {
const url = Each.id === 'feed'
? './feed.html'
: Each.id === 'view'
? './post.html?follows=true&post=false&viewed=true'
: Each.id === 'love'
? './notifications.html'
: `./${Each.id}.html`;
htmlOut += `<a id="div${Each.id}" class="divAppButtons" href="${url}" target="_blank" rel="noopener noreferrer">
<svg id="${Each.id}" class="svgAppButtons" viewBox="0 0 10 10" >
${Each.svg}
</svg>
${Each.name}
</a>`;
}
divBody.innerHTML = divBody.innerHTML + htmlOut;
divBody.style.justifyContent = `space-around`;
divBody.style.alignContent = `space-around`;
divBody.style.alignItems = ``;
divBody.style.height = "";
};
/* ================================================================
UPDATE FOOTER
================================================================ */
const UpdateFooter = async () => {
try {
// Update relay status visuals in footer and sidenav
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
// Keep startup/wallet status visible in footer center while active
const now = Date.now();
if (now < walletFooterStatusUntil && walletFooterStatusMessage) {
divFooterCenter.textContent = walletFooterStatusMessage;
} else if (now < startupStatusUntil && startupStatusMessage) {
divFooterCenter.textContent = startupStatusMessage;
} else {
divFooterCenter.innerHTML = '';
}
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[index.html] Error updating footer:', error);
}
};
/* ================================================================
LOGOUT
================================================================ */
const Logout = async () => {
console.log("[index.html] Starting logout process...");
// Stop the update loop
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
// Disconnect from worker
disconnect();
// Logout from nostr-login-lite
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
// Clear all storage
localStorage.clear();
sessionStorage.clear();
// Clear IndexedDB
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
console.log("[index.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
EVENT LISTENERS
================================================================ */
/* ================================================================
INITIALIZATION
================================================================ */
(async function main() {
console.log("[index.html] Starting initialization...");
try {
// Initialize hamburger menu first
initHamburgerMenu();
// Add click handler to hamburger
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
// Initialize version bar buttons
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
if (isDarkMode) {
localStorage.setItem('theme', 'dark');
} else {
localStorage.setItem('theme', 'light');
}
// Save sidenav state before reload
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
await Logout();
} catch (error) {
console.error('Logout failed:', error);
}
});
}
// Render app shell immediately so worker startup does not block first paint
await LoadAppScreen();
// Initialize NDK (handles authentication automatically)
await initNDKPage();
// Get authenticated pubkey
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
console.log("[index.html] Authenticated as:", currentPubkey);
// Initialize relay UI components
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
// Listen for relay activity broadcasts from worker
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[index.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
// Show startup progress messages from worker in center footer section
window.addEventListener('ndkStartupStatus', (event) => {
const msg = String(event?.detail?.message || '').trim();
if (!msg) return;
console.log(`[index.html] Startup status: ${msg}`, event?.detail || {});
startupStatusMessage = msg;
startupStatusUntil = Date.now() + 12000;
divFooterCenter.textContent = msg;
});
// Show wallet balance/status updates from worker in center footer section
window.addEventListener('ndkWalletBalance', (event) => {
const balance = Number(event?.detail?.balance || 0);
const msg = `Wallet balance: ${balance.toLocaleString()} sats`;
walletFooterStatusMessage = msg;
walletFooterStatusUntil = Date.now() + 12000;
console.log(`[index.html] ${msg}`, event?.detail || {});
divFooterCenter.textContent = msg;
});
window.addEventListener('ndkWalletStatus', (event) => {
const status = String(event?.detail?.status || '').trim();
if (!status) return;
const msg = `Wallet status: ${status}`;
walletFooterStatusMessage = msg;
walletFooterStatusUntil = Date.now() + 12000;
console.log(`[index.html] ${msg}`, event?.detail || {});
divFooterCenter.textContent = msg;
});
// Hydrate app screen with authenticated data (NPUB QR)
await LoadAppScreen(currentPubkey);
// Restore sidenav state if it was open before theme toggle
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
// Start update loop (updates footer every second)
updateIntervalId = setInterval(UpdateFooter, 1000);
// Update version display
await updateVersionDisplay();
console.log('[index.html] Initialization complete');
} catch (error) {
console.error('[index.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
<div style="font-size: 16px; color: #666;">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,654 @@
import {
initNDKPage,
getPubkey,
injectHeaderAvatar,
subscribe,
publishEvent,
disconnect,
getVersion,
updateVersionDisplay,
queryCache,
fetchEventsFromAllRelays,
fetchCachedProfile,
storeProfile
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { createProfileCache } from './js/profile-cache.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[people.html ${VERSION}] Loading...`);
let updateIntervalId = null;
let currentPubkey = null;
let isNavOpen = false;
let hamburgerInstance = null;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
let followedPubkeys = [];
let latestContactContent = {};
const profileCacheApi = createProfileCache({
fetchCachedProfile,
fetchEventsFromAllRelays,
storeProfile,
queryCache
});
const profileRelayRequested = new Set();
const PROFILE_BATCH_SIZE = 25;
const DEBUG_PEOPLE = true;
function dlog(...args) {
if (!DEBUG_PEOPLE) return;
console.log('[people.html]', ...args);
}
const divBody = document.getElementById('divBody');
const divSideNav = document.getElementById('divSideNav');
const divSideNavBody = document.getElementById('divSideNavBody');
const divFooterCenter = document.getElementById('divFooterCenter');
const divFooterRight = document.getElementById('divFooterRight');
const inpSearchPeople = document.getElementById('inpSearchPeople');
const inpAddPerson = document.getElementById('inpAddPerson');
const btnAddPerson = document.getElementById('btnAddPerson');
const divPeopleStatus = document.getElementById('divPeopleStatus');
const divPeopleGrid = document.getElementById('divPeopleGrid');
function setStatus(msg) {
divPeopleStatus.textContent = msg;
}
function shortPubkey(pk = '') {
return `${pk.slice(0, 8)}${pk.slice(-4)}`;
}
function parseFollowedPubkeysFromKind3(evt) {
const tags = Array.isArray(evt?.tags) ? evt.tags : [];
return tags
.filter((t) => Array.isArray(t) && t[0] === 'p' && typeof t[1] === 'string' && t[1].length === 64)
.map((t) => t[1]);
}
function dedupePubkeys(arr) {
return [...new Set((arr || []).filter((v) => typeof v === 'string' && v.length === 64))];
}
function normalizePubkeyInput(raw) {
const value = String(raw || '').trim();
if (!value) throw new Error('Enter npub or hex pubkey');
if (/^[a-f0-9]{64}$/i.test(value)) return value.toLowerCase();
if (value.startsWith('npub1')) {
const decoded = window?.NostrTools?.nip19?.decode?.(value);
if (decoded?.type === 'npub' && typeof decoded.data === 'string' && decoded.data.length === 64) {
return decoded.data;
}
}
throw new Error('Invalid pubkey format');
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = '50vw';
isNavOpen = true;
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
divSideNavBody.innerHTML = 'Settings and options coming soon...';
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
}
function closeNav() {
divSideNav.style.width = '0vw';
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
}
function toggleNav() {
if (isNavOpen) closeNav(); else openNav();
}
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
async function UpdateFooter() {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[people.html] Error updating footer:', error);
}
}
async function Logout() {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) await window.NOSTR_LOGIN_LITE.logout();
localStorage.clear();
sessionStorage.clear();
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) if (db.name) window.indexedDB.deleteDatabase(db.name);
}
location.reload(true);
}
function personMatchesQuery(pubkey, profile, query) {
const q = String(query || '').trim().toLowerCase();
if (!q) return true;
const name = String(profile?.display_name || profile?.name || '').toLowerCase();
return name.includes(q) || pubkey.toLowerCase().includes(q);
}
function createAvatarElement(pubkey, profile) {
const wrap = document.createElement('div');
wrap.className = 'personAvatarWrap';
const img = document.createElement('img');
img.className = 'personAvatar';
img.alt = '';
img.loading = 'lazy';
img.decoding = 'async';
img.referrerPolicy = 'no-referrer';
const picture = String(profile?.picture || '').trim();
if (picture) {
img.addEventListener('error', () => {
img.removeAttribute('src');
}, { once: true });
img.src = picture;
}
wrap.appendChild(img);
wrap.addEventListener('click', () => {
window.location.href = `./post.html?npub=${encodeURIComponent(pubkey)}&profile=true&post=false`;
});
return wrap;
}
function getCachedProfile(pubkey) {
return profileCacheApi.getCachedProfile(pubkey) || null;
}
function getDisplayNameForSort(pubkey) {
const profile = getCachedProfile(pubkey);
const name = String(profile?.display_name || profile?.name || '').trim();
return name;
}
function getOrderedPubkeys() {
const rows = followedPubkeys.map((pk) => {
const displayName = getDisplayNameForSort(pk);
return {
pubkey: pk,
hasDisplayName: Boolean(displayName),
key: (displayName || '').toLowerCase(),
fallback: pk.toLowerCase()
};
});
rows.sort((a, b) => {
if (a.hasDisplayName !== b.hasDisplayName) return a.hasDisplayName ? -1 : 1;
if (a.key !== b.key) return a.key.localeCompare(b.key);
return a.fallback.localeCompare(b.fallback);
});
return rows.map((r) => r.pubkey);
}
async function renderPeople() {
const query = inpSearchPeople.value;
const ordered = getOrderedPubkeys();
const visible = ordered.filter((pk) => personMatchesQuery(pk, getCachedProfile(pk), query));
divPeopleGrid.innerHTML = '';
if (visible.length === 0) {
const empty = document.createElement('div');
empty.className = 'personCard';
empty.textContent = followedPubkeys.length === 0 ? 'No followed people yet.' : 'No people match your search.';
divPeopleGrid.appendChild(empty);
return;
}
for (const pubkey of visible) {
const profile = getCachedProfile(pubkey);
const card = document.createElement('div');
card.className = 'personCard';
const avatar = createAvatarElement(pubkey, profile);
const main = document.createElement('div');
main.className = 'personMain';
main.addEventListener('click', () => {
window.location.href = `./post.html?npub=${encodeURIComponent(pubkey)}&profile=true&post=false`;
});
const name = document.createElement('div');
name.className = 'personName';
name.textContent = String(profile?.display_name || profile?.name || shortPubkey(pubkey));
const pk = document.createElement('div');
pk.className = 'personPubkey';
pk.textContent = pubkey;
main.appendChild(name);
main.appendChild(pk);
const btnRemove = document.createElement('button');
btnRemove.className = 'personRemove';
btnRemove.textContent = 'Remove';
btnRemove.addEventListener('click', async (ev) => {
ev.stopPropagation();
await removePerson(pubkey);
});
card.appendChild(avatar);
card.appendChild(main);
card.appendChild(btnRemove);
divPeopleGrid.appendChild(card);
}
}
let hydrateInFlight = null;
const hydratePendingPubkeys = new Set();
async function runHydrateProfiles(pubkeys) {
const ordered = getOrderedPubkeys().filter((pk) => pubkeys.includes(pk));
dlog('hydrateProfiles:start', {
requested: pubkeys.length,
ordered: ordered.length,
cachedAlready: ordered.filter((pk) => Boolean(getCachedProfile(pk))).length,
relayRequestedAlready: ordered.filter((pk) => profileRelayRequested.has(pk)).length
});
let cacheHitCount = 0;
let cacheMissCount = 0;
const cacheTasks = ordered.map(async (pk) => {
if (getCachedProfile(pk)) {
cacheHitCount++;
return;
}
try {
const profile = await profileCacheApi.fetchProfile(pk, { allowNetwork: false, backgroundRefresh: true, logPrefix: '[people.html]' });
if (profile) {
cacheHitCount++;
} else {
cacheMissCount++;
}
} catch (_e) {
cacheMissCount++;
// non-fatal
}
});
await Promise.all(cacheTasks);
dlog('hydrateProfiles:cache-phase-complete', { cacheHitCount, cacheMissCount, totalCachedNow: ordered.filter((pk) => Boolean(getCachedProfile(pk))).length });
await renderPeople();
const missing = ordered.filter((pk) => !getCachedProfile(pk) && !profileRelayRequested.has(pk));
dlog('hydrateProfiles:missing-after-cache', { missing: missing.length });
if (missing.length === 0) return;
setStatus(`Fetching ${missing.length} missing profiles from relays in batches...`);
for (let i = 0; i < missing.length; i += PROFILE_BATCH_SIZE) {
const batch = missing.slice(i, i + PROFILE_BATCH_SIZE);
batch.forEach((pk) => profileRelayRequested.add(pk));
const batchIndex = Math.floor(i / PROFILE_BATCH_SIZE) + 1;
const batchTotal = Math.ceil(missing.length / PROFILE_BATCH_SIZE);
dlog('hydrateProfiles:relay-batch', { batchIndex, batchTotal, size: batch.length, first: batch[0], last: batch[batch.length - 1] });
setStatus(`Fetching profile batch ${batchIndex}/${batchTotal}...`);
subscribe(
{ kinds: [0], authors: batch },
{ closeOnEose: true, cacheUsage: 'ONLY_RELAY' }
);
await new Promise((resolve) => setTimeout(resolve, 150));
}
setStatus(`Loaded ${followedPubkeys.length} followed people`);
dlog('hydrateProfiles:relay-phase-queued', { queued: missing.length });
const unresolved = ordered.filter((pk) => !getCachedProfile(pk));
if (unresolved.length > 0) {
dlog('hydrateProfiles:relay-fallback:needed', { unresolved: unresolved.length });
await fetchMissingProfilesFromAllRelays(unresolved);
}
}
async function hydrateProfiles(pubkeys) {
const requested = dedupePubkeys(pubkeys);
requested.forEach((pk) => hydratePendingPubkeys.add(pk));
if (hydrateInFlight) {
dlog('hydrateProfiles:guard-queued', {
requested: requested.length,
pending: hydratePendingPubkeys.size
});
return hydrateInFlight;
}
hydrateInFlight = (async () => {
try {
while (hydratePendingPubkeys.size > 0) {
const cyclePubkeys = Array.from(hydratePendingPubkeys);
hydratePendingPubkeys.clear();
dlog('hydrateProfiles:guard-cycle-start', { cycleCount: cyclePubkeys.length });
await runHydrateProfiles(cyclePubkeys);
}
} finally {
hydrateInFlight = null;
dlog('hydrateProfiles:guard-idle');
}
})();
return hydrateInFlight;
}
async function fetchMissingProfilesFromAllRelays(missingPubkeys) {
const rows = dedupePubkeys(missingPubkeys);
if (!rows.length) return;
dlog('hydrateProfiles:relay-fallback:start', { count: rows.length });
for (let i = 0; i < rows.length; i += PROFILE_BATCH_SIZE) {
const batch = rows.slice(i, i + PROFILE_BATCH_SIZE);
const batchIndex = Math.floor(i / PROFILE_BATCH_SIZE) + 1;
const batchTotal = Math.ceil(rows.length / PROFILE_BATCH_SIZE);
try {
const byRelay = await fetchEventsFromAllRelays({ kinds: [0], authors: batch });
const newestByPubkey = new Map();
for (const events of Object.values(byRelay || {})) {
if (!Array.isArray(events)) continue;
for (const evt of events) {
if (!evt || evt.kind !== 0 || typeof evt.pubkey !== 'string') continue;
const prev = newestByPubkey.get(evt.pubkey);
if (!prev || Number(evt.created_at || 0) > Number(prev.created_at || 0)) {
newestByPubkey.set(evt.pubkey, evt);
}
}
}
let applied = 0;
for (const evt of newestByPubkey.values()) {
try {
const parsed = JSON.parse(evt.content || '{}');
profileCacheApi.seedProfileCache(evt.pubkey, parsed);
applied++;
} catch (_e) {
// non-fatal
}
}
dlog('hydrateProfiles:relay-fallback:batch-done', {
batchIndex,
batchTotal,
requested: batch.length,
applied
});
await renderPeople();
await new Promise((resolve) => setTimeout(resolve, 120));
} catch (error) {
dlog('hydrateProfiles:relay-fallback:batch-error', {
batchIndex,
batchTotal,
message: error?.message || String(error)
});
}
}
}
function followListsEqual(a, b) {
if (a.length !== b.length) return false;
const aSet = new Set(a);
for (const pk of b) {
if (!aSet.has(pk)) return false;
}
return true;
}
function applyKind3Event(evt, source = 'unknown') {
const next = dedupePubkeys(parseFollowedPubkeysFromKind3(evt));
const changed = !followListsEqual(followedPubkeys, next);
followedPubkeys = next;
for (const pk of [...profileRelayRequested]) {
if (!followedPubkeys.includes(pk)) profileRelayRequested.delete(pk);
}
dlog('applyKind3Event', {
source,
followCount: followedPubkeys.length,
changed,
preview: followedPubkeys.slice(0, 5)
});
if (typeof evt?.content === 'string' && evt.content.trim()) {
try {
latestContactContent = JSON.parse(evt.content);
} catch (_err) {
latestContactContent = {};
}
}
setStatus(`Loaded ${followedPubkeys.length} followed people (${source})`);
renderPeople();
if (changed || source === 'cache') {
hydrateProfiles(followedPubkeys);
}
}
async function publishContacts() {
const tags = followedPubkeys.map((pk) => ['p', pk]);
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: 3,
tags,
content: JSON.stringify(latestContactContent || {})
};
return await publishEvent(event);
}
async function addPersonFromInput() {
try {
const pubkey = normalizePubkeyInput(inpAddPerson.value);
if (pubkey === currentPubkey) throw new Error('Cannot follow yourself from this page');
if (followedPubkeys.includes(pubkey)) {
setStatus('Already followed');
return;
}
followedPubkeys = dedupePubkeys([...followedPubkeys, pubkey]);
await renderPeople();
setStatus(`Adding ${shortPubkey(pubkey)}...`);
const result = await publishContacts();
setStatus(`Added ${shortPubkey(pubkey)} | relays ok: ${result.relayResults.successful.length}`);
inpAddPerson.value = '';
hydrateProfiles([pubkey]);
} catch (error) {
setStatus(`Add failed: ${error.message}`);
}
}
async function removePerson(pubkey) {
const ok = window.confirm(`Remove ${shortPubkey(pubkey)} from follows?`);
if (!ok) return;
const next = followedPubkeys.filter((pk) => pk !== pubkey);
followedPubkeys = next;
await renderPeople();
setStatus(`Removing ${shortPubkey(pubkey)}...`);
try {
const result = await publishContacts();
setStatus(`Removed ${shortPubkey(pubkey)} | relays ok: ${result.relayResults.successful.length}`);
} catch (error) {
setStatus(`Remove publish failed: ${error.message}`);
}
}
function wireEvents() {
inpSearchPeople.addEventListener('input', () => renderPeople());
btnAddPerson.addEventListener('click', addPersonFromInput);
inpAddPerson.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') addPersonFromInput();
});
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
if (!evt || !evt.kind) return;
if (evt.kind === 3 && evt.pubkey === currentPubkey) {
dlog('ndkEvent:kind3', { created_at: evt.created_at, tags: Array.isArray(evt.tags) ? evt.tags.length : 0 });
applyKind3Event(evt, 'subscription');
}
if (evt.kind === 0 && followedPubkeys.includes(evt.pubkey)) {
try {
const parsed = JSON.parse(evt.content || '{}');
profileCacheApi.seedProfileCache(evt.pubkey, parsed);
dlog('ndkEvent:kind0-profile', {
pubkey: evt.pubkey,
hasName: Boolean(parsed?.display_name || parsed?.name),
hasPicture: Boolean(parsed?.picture)
});
renderPeople();
} catch (_e) {
dlog('ndkEvent:kind0-profile-parse-failed', { pubkey: evt.pubkey });
// non-fatal
}
}
});
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity } = event.detail;
setRelayActivityState(relayUrl, activity);
});
}
async function loadInitialContacts() {
setStatus('Loading follows from cache...');
try {
const cached = await queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 });
dlog('loadInitialContacts:cache-result', { count: cached?.length || 0 });
if (cached?.length) {
const latest = [...cached].sort((a, b) => Number(b.created_at || 0) - Number(a.created_at || 0))[0];
dlog('loadInitialContacts:using-cached-kind3', { created_at: latest?.created_at, tagCount: Array.isArray(latest?.tags) ? latest.tags.length : 0 });
applyKind3Event(latest, 'cache');
} else {
setStatus('No cached follows yet; waiting for relay data...');
}
} catch (error) {
console.warn('[people.html] cache load failed:', error.message);
}
dlog('loadInitialContacts:subscribe-kind3-cache-first');
subscribe({ kinds: [3], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
dlog('loadInitialContacts:fetch-kind3-all-relays:start');
fetchEventsFromAllRelays({ kinds: [3], authors: [currentPubkey], limit: 1 })
.then((byRelay) => {
const relayCount = Object.keys(byRelay || {}).length;
const totalEvents = Object.values(byRelay || {}).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
dlog('loadInitialContacts:fetch-kind3-all-relays:done', { relayCount, totalEvents });
})
.catch((err) => {
dlog('loadInitialContacts:fetch-kind3-all-relays:error', { message: err?.message || String(err) });
});
}
async function main() {
try {
initHamburgerMenu();
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
themeToggleButton?.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
logoutButton?.addEventListener('click', async () => {
try { await Logout(); } catch (error) { console.error(error); }
});
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
await UpdateFooter();
wireEvents();
await loadInitialContacts();
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
updateIntervalId = setInterval(UpdateFooter, 1000);
await updateVersionDisplay();
setStatus(`Ready | ${followedPubkeys.length} followed`);
} catch (error) {
console.error('[people.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align:center;padding:40px;color:var(--accent-color);">${error.message}</div>`;
}
}
main();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

40
build/enable_hls_rtmp.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
from pathlib import Path
nginx_conf = Path('/etc/nginx/nginx.conf')
text = nginx_conf.read_text()
old_block = """ application live {
live on;
record off;
allow publish 127.0.0.1;
deny publish all;
allow play all;
}
"""
new_block = """ application live {
live on;
record off;
hls on;
hls_path /var/www/html/hls;
hls_fragment 2s;
hls_playlist_length 12s;
hls_continuous on;
hls_cleanup on;
allow publish 127.0.0.1;
deny publish all;
allow play all;
}
"""
if new_block in text:
print('HLS already enabled in live application block')
raise SystemExit(0)
if old_block not in text:
raise SystemExit('Could not find expected live application block to patch')
text = text.replace(old_block, new_block, 1)
nginx_conf.write_text(text)
print('Patched nginx rtmp live application block with HLS settings')

7397
build/meta.json Normal file

File diff suppressed because it is too large Load Diff

1576
build/music-inline-check.mjs Normal file

File diff suppressed because it is too large Load Diff

77846
build/ndk-core.bundle.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

35
build/ndk-entry.js Normal file
View File

@@ -0,0 +1,35 @@
// Import the default NDK class
import NDK from '/home/user/lt/client/ndk/core/src/index.ts';
// Import default exports from cache packages
import NDKCacheBrowser from '/home/user/lt/client/ndk/cache-browser/src/index.ts';
import NDKCacheAdapterDexie from '/home/user/lt/client/ndk/cache-dexie/src/index.ts';
import NDKCacheAdapterSqliteWasm from '/home/user/lt/client/ndk/cache-sqlite-wasm/src/index.ts';
import { CashuMint, CashuWallet, getDecodedToken, getEncodedTokenV4 } from '/home/user/lt/client/ndk/node_modules/@cashu/cashu-ts/lib/cashu-ts.es.js';
// Re-export everything as named exports
export * from '/home/user/lt/client/ndk/core/src/index.ts';
export * from '/home/user/lt/client/ndk/cache-browser/src/index.ts';
export * from '/home/user/lt/client/ndk/cache-dexie/src/index.ts';
export * from '/home/user/lt/client/ndk/cache-sqlite-wasm/src/index.ts';
export * from '/home/user/lt/client/ndk/sessions/src/index.ts';
export * from '/home/user/lt/client/ndk/messages/src/index.ts';
export * from '/home/user/lt/client/ndk/wot/src/index.ts';
export * from '/home/user/lt/client/ndk/blossom/src/index.ts';
export * from '/home/user/lt/client/ndk/wallet/src/index.ts';
// Re-export default exports as named exports
export {
NDKCacheBrowser,
NDKCacheAdapterDexie,
NDKCacheAdapterSqliteWasm,
CashuMint,
CashuWallet,
getDecodedToken,
getEncodedTokenV4
};
// Export the main NDK class as default
export default NDK;

1611
build/post-module-check.mjs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,713 @@
/* ================================================================
IMPORTS
================================================================ */
import { initNDKPage, getPubkey, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[relays.html ${VERSION}] Loading...`);
/* ================================================================
SVG ICONS
================================================================ */
const SVG_UNCHECKED = `<svg class="svgBtn svgBtn_unsel" viewBox="0 0 10 10"><rect x="2" y="2" width="6" height="6" /></svg>`;
const SVG_CHECKED = `<svg class="svgBtn svgBtn_sel" viewBox="0 0 10 10"><path d="M2,6 L4,8 L8,2 " /></svg>`;
const SVG_CONNECTING = `<svg class="svgBtn" style="stroke: var(--muted-color); opacity: 0.5;" viewBox="0 0 10 10"><path d="M2,6 L4,8 L8,2 " /></svg>`;
const SVG_X_UNCHECKED = `<svg class="svgBtn svgBtn_unsel" viewBox="0 0 10 10"><path d="M2,2 L8,8 M8,2 L2,8" /></svg>`;
const SVG_X_PENDING = `<svg class="svgBtn svgBtn_sel" viewBox="0 0 10 10"><path d="M2,2 L8,8 M8,2 L2,8" /></svg>`;
/* ================================================================
GLOBAL VARIABLES
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
let reconnectingRelays = new Set(); // Track which relays are reconnecting
let currentRelayList = []; // Store current relay list with types
let pendingRemovalRelayUrl = null; // Two-click confirmation for relay deletion
let addRelayCanRead = true;
let addRelayCanWrite = true;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
/* ================================================================
DOM VARIABLES
================================================================ */
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divRelays = document.getElementById("divRelays");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
const txtRelayEvents = document.getElementById("txtRelayEvents");
/* ================================================================
HAMBURGER MENU
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
/* ================================================================
SIDENAV FUNCTIONS
================================================================ */
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "50vw";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
divSideNavBody.innerHTML = "Relay management options coming soon...";
// Initialize version bar buttons when sidenav opens (lazy load)
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
// Determine current theme
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
const initialShape = isDarkMode ? 'moon' : 'circle';
themeToggleHamburger.animateTo(initialShape);
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
/* ================================================================
RELAY TABLE FUNCTIONS
================================================================ */
const getStatusText = (status) => {
// NDK relay statuses:
// 0 = DISCONNECTED, 1 = DISCONNECTING, 2 = RECONNECTING
// 3 = FLAPPING, 4 = CONNECTING, 5 = CONNECTED
// 6 = AUTH_REQUIRED, 7 = AUTHENTICATING, 8 = AUTHENTICATED
const statusMap = {
0: 'Disconnected',
1: 'Disconnecting',
2: 'Reconnecting',
3: 'Flapping',
4: 'Connecting',
5: 'Connected',
6: 'Auth Required',
7: 'Authenticating',
8: 'Authenticated'
};
return statusMap[status] || 'Unknown';
};
const formatConnectionTime = (timestamp) => {
if (!timestamp || timestamp === 0) return '-';
const now = Math.floor(Date.now() / 1000);
const elapsed = now - timestamp;
if (elapsed < 0 || elapsed > 31536000) return '-'; // More than 1 year
if (elapsed < 60) return `${elapsed}s`;
if (elapsed < 3600) return `${Math.floor(elapsed / 60)}m`;
if (elapsed < 86400) return `${Math.floor(elapsed / 3600)}h`;
return `${Math.floor(elapsed / 86400)}d`;
};
const normalizeRelayUrlInput = (input) => {
const raw = (input || '').trim();
if (!raw) return null;
let candidate = raw;
if (!/^wss?:\/\//i.test(candidate)) {
candidate = `wss://${candidate}`;
}
try {
const parsed = new URL(candidate);
if (!['ws:', 'wss:'].includes(parsed.protocol)) return null;
const host = parsed.host.toLowerCase();
if (!host) return null;
const path = parsed.pathname && parsed.pathname !== '/'
? parsed.pathname.replace(/\/+$/, '')
: '';
return `wss://${host}${path}`;
} catch (error) {
return null;
}
};
const relayExists = (relayUrl) => {
return currentRelayList.some(relay => relay.url === relayUrl);
};
const getAddRelayType = () => {
if (addRelayCanRead && addRelayCanWrite) return 'both';
if (addRelayCanRead) return 'read';
if (addRelayCanWrite) return 'write';
return 'both';
};
const createRelayTable = async (relays) => {
const safeRelays = Array.isArray(relays) ? relays : [];
// Store current relay list for publishing
currentRelayList = safeRelays;
// Get relay stats for read/write counts
const relayStats = await getRelayStats();
let html = '<table id="tblRelays">';
// Header
html += '<thead><tr>';
html += '<th class="tblCol tblColCenter">X</th>';
html += '<th class="tblCol tblColLeft">Relay</th>';
html += '<th class="tblCol tblColCenter">Connected</th>';
html += '<th class="tblCol tblColCenter">Read</th>';
html += '<th class="tblCol tblColCenter">Write</th>';
html += '<th class="tblCol tblColRight">Reads</th>';
html += '<th class="tblCol tblColRight">Writes</th>';
html += '<th class="tblCol tblColRight">Connection Time</th>';
html += '</tr></thead>';
// Body
html += '<tbody>';
for (let i = 0; i < safeRelays.length; i++) {
const relay = safeRelays[i];
const isConnected = relay.status === 5; // 5 = CONNECTED
const isReconnecting = reconnectingRelays.has(relay.url);
const rowClass = isConnected ? 'tblRelayRow tblRowConnected' : 'tblRelayRow';
const stats = relayStats[relay.url] || { readCount: 0, writeCount: 0 };
const relayType = relay.type || 'both';
const removeIcon = pendingRemovalRelayUrl === relay.url ? SVG_X_PENDING : SVG_X_UNCHECKED;
// Determine which icon to show for connection status
let statusIcon;
if (isReconnecting) {
statusIcon = SVG_CONNECTING;
} else if (isConnected) {
statusIcon = SVG_CHECKED;
} else {
statusIcon = SVG_UNCHECKED;
}
// Determine read/write checkboxes
const canRead = relayType === 'read' || relayType === 'both';
const canWrite = relayType === 'write' || relayType === 'both';
const readCheckbox = canRead ? SVG_CHECKED : SVG_UNCHECKED;
const writeCheckbox = canWrite ? SVG_CHECKED : SVG_UNCHECKED;
html += `<tr class="${rowClass}">`;
html += `<td class="tblCol tblColCenter divSvg" data-remove-relay-url="${relay.url}" style="cursor: pointer;">${removeIcon}</td>`;
html += `<td class="tblCol tblColLeft tblColRelay" title="${relay.url}">${relay.url}</td>`;
html += `<td class="tblCol tblColCenter divSvg" data-relay-url="${relay.url}" style="cursor: pointer;">${statusIcon}</td>`;
html += `<td class="tblCol tblColCenter divSvg" data-relay-index="${i}" data-toggle-type="read" style="cursor: pointer;">${readCheckbox}</td>`;
html += `<td class="tblCol tblColCenter divSvg" data-relay-index="${i}" data-toggle-type="write" style="cursor: pointer;">${writeCheckbox}</td>`;
html += `<td class="tblCol tblColRight">${stats.readCount}</td>`;
html += `<td class="tblCol tblColRight">${stats.writeCount}</td>`;
html += `<td class="tblCol tblColRight">${formatConnectionTime(relay.connectionTime)}</td>`;
html += '</tr>';
}
const addReadCheckbox = addRelayCanRead ? SVG_CHECKED : SVG_UNCHECKED;
const addWriteCheckbox = addRelayCanWrite ? SVG_CHECKED : SVG_UNCHECKED;
html += '<tr class="tblRelayAddRow">';
html += '<td class="tblCol tblColCenter">-</td>';
html += '<td class="tblCol tblColLeft"><input id="txtNewRelayUrl" class="relayInput" type="text" placeholder="Add relay (example: relay.damus.io or wss://relay.damus.io)" title="Press Enter to add relay" /></td>';
html += '<td class="tblCol tblColCenter">-</td>';
html += `<td class="tblCol tblColCenter divSvg" data-add-toggle-type="read" style="cursor: pointer;">${addReadCheckbox}</td>`;
html += `<td class="tblCol tblColCenter divSvg" data-add-toggle-type="write" style="cursor: pointer;">${addWriteCheckbox}</td>`;
html += '<td class="tblCol tblColRight">-</td>';
html += '<td class="tblCol tblColRight">-</td>';
html += '<td class="tblCol tblColRight">-</td>';
html += '</tr>';
html += '</tbody></table>';
divRelays.innerHTML = html;
// Add click handlers to remove icons
document.querySelectorAll('.divSvg[data-remove-relay-url]').forEach(cell => {
cell.addEventListener('click', async () => {
const relayUrl = cell.getAttribute('data-remove-relay-url');
await handleRelayRemoveClick(relayUrl);
});
});
// Add click handlers to connection status icons
document.querySelectorAll('.divSvg[data-relay-url]').forEach(cell => {
cell.addEventListener('click', () => {
const relayUrl = cell.getAttribute('data-relay-url');
handleRelayReconnect(relayUrl);
});
});
// Add click handlers to read/write toggle checkboxes
document.querySelectorAll('.divSvg[data-relay-index]').forEach(cell => {
cell.addEventListener('click', () => {
const relayIndex = parseInt(cell.getAttribute('data-relay-index'));
const toggleType = cell.getAttribute('data-toggle-type');
handleRelayTypeToggle(relayIndex, toggleType);
});
});
// Add click handlers to add-row read/write toggle checkboxes
document.querySelectorAll('.divSvg[data-add-toggle-type]').forEach(cell => {
cell.addEventListener('click', async () => {
const toggleType = cell.getAttribute('data-add-toggle-type');
await handleAddRelayTypeToggle(toggleType);
});
});
// Add Enter handler for relay input
const txtNewRelayUrl = document.getElementById('txtNewRelayUrl');
if (txtNewRelayUrl) {
txtNewRelayUrl.addEventListener('keydown', async (event) => {
if (event.key === 'Enter') {
event.preventDefault();
await handleAddRelaySubmit(txtNewRelayUrl.value);
}
});
}
// Update footer stats
const connectedCount = safeRelays.filter(r => r.status === 5).length;
divFooterRight.innerHTML = `Total: ${safeRelays.length} | Connected: ${connectedCount}`;
};
const handleRelayTypeToggle = async (relayIndex, toggleType) => {
if (relayIndex < 0 || relayIndex >= currentRelayList.length) return;
const relay = currentRelayList[relayIndex];
const currentType = relay.type || 'both';
console.log(`[relays.html] Toggling ${toggleType} for ${relay.url}, current type: ${currentType}`);
// Determine new type based on toggle
let newType = currentType;
if (toggleType === 'read') {
if (currentType === 'both') {
newType = 'write'; // Remove read, keep write
} else if (currentType === 'write') {
newType = 'both'; // Add read to write
} else if (currentType === 'read') {
newType = ''; // Remove read (relay becomes inactive)
} else {
newType = 'read'; // Add read
}
} else if (toggleType === 'write') {
if (currentType === 'both') {
newType = 'read'; // Remove write, keep read
} else if (currentType === 'read') {
newType = 'both'; // Add write to read
} else if (currentType === 'write') {
newType = ''; // Remove write (relay becomes inactive)
} else {
newType = 'write'; // Add write
}
}
// Don't allow empty type - must have at least read or write
if (!newType) {
console.warn('[relays.html] Cannot remove both read and write from relay');
return;
}
// Update local relay list
relay.type = newType;
// Refresh table to show updated checkboxes
await createRelayTable(currentRelayList);
// Publish updated relay list to Nostr
await publishRelayList();
};
const handleRelayRemoveClick = async (relayUrl) => {
if (!relayUrl) return;
if (pendingRemovalRelayUrl === relayUrl) {
currentRelayList = currentRelayList.filter(relay => relay.url !== relayUrl);
pendingRemovalRelayUrl = null;
await createRelayTable(currentRelayList);
await publishRelayList();
return;
}
pendingRemovalRelayUrl = relayUrl;
await createRelayTable(currentRelayList);
};
const handleAddRelayTypeToggle = async (toggleType) => {
if (toggleType === 'read') {
if (!addRelayCanRead && !addRelayCanWrite) {
addRelayCanRead = true;
} else {
addRelayCanRead = !addRelayCanRead;
}
} else if (toggleType === 'write') {
if (!addRelayCanRead && !addRelayCanWrite) {
addRelayCanWrite = true;
} else {
addRelayCanWrite = !addRelayCanWrite;
}
}
if (!addRelayCanRead && !addRelayCanWrite) {
// At least one of read/write must remain enabled
if (toggleType === 'read') {
addRelayCanRead = true;
} else {
addRelayCanWrite = true;
}
}
await createRelayTable(currentRelayList);
};
const handleAddRelaySubmit = async (inputValue) => {
const normalizedRelayUrl = normalizeRelayUrlInput(inputValue);
if (!normalizedRelayUrl) {
divFooterCenter.innerHTML = '❌ Invalid relay URL';
return;
}
if (relayExists(normalizedRelayUrl)) {
divFooterCenter.innerHTML = '⚠️ Relay already exists in your kind 10002 list';
return;
}
const newType = getAddRelayType();
currentRelayList.push({
url: normalizedRelayUrl,
status: 0,
connectionTime: 0,
type: newType
});
pendingRemovalRelayUrl = null;
await createRelayTable(currentRelayList);
await publishRelayList();
};
const publishRelayList = async () => {
if (!currentPubkey) {
console.error('[relays.html] No pubkey available');
return;
}
try {
// Build kind 10002 event
const event = {
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: ''
};
// Add relay tags with read/write markers
for (const relay of currentRelayList) {
const tag = ['r', relay.url];
const relayType = relay.type || 'both';
if (relayType !== 'both') {
tag.push(relayType); // Add 'read' or 'write' marker
}
// If 'both', don't add third element (default behavior)
event.tags.push(tag);
}
console.log('[relays.html] Publishing relay list:', event);
const result = await publishEvent(event);
console.log('[relays.html] Relay list published to:', result.relayResults.successful);
// Show success message in footer
divFooterCenter.innerHTML = `✅ Published to ${result.relayResults.successful.length} relays`;
setTimeout(() => {
if (currentPubkey) {
divFooterCenter.innerHTML = `${currentPubkey.slice(0, 8)}...${currentPubkey.slice(-8)}`;
}
}, 3000);
} catch (error) {
console.error('[relays.html] Failed to publish relay list:', error);
divFooterCenter.innerHTML = `❌ Publish failed: ${error.message}`;
}
};
const handleRelayReconnect = (relayUrl) => {
console.log('[relays.html] Reconnecting relay:', relayUrl);
// Add to reconnecting set
reconnectingRelays.add(relayUrl);
// Trigger reconnect
reconnectRelay(relayUrl);
// Refresh table to show connecting state
refreshRelayData();
// Remove from reconnecting set after 5 seconds (timeout)
setTimeout(() => {
reconnectingRelays.delete(relayUrl);
refreshRelayData();
}, 5000);
};
/* ================================================================
RELAY EVENT LOGGING
================================================================ */
const FILTER_RELAY = 'wss://relay.laantungir.net/'; // Only show events from this relay
const logRelayEvent = (relayUrl, eventType, data) => {
// Filter: only log events from the specified relay
if (relayUrl !== FILTER_RELAY) return;
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1); // HH:MM:SS.mmm
let logLine = `[${timestamp}] ${relayUrl} - ${eventType.toUpperCase()}`;
if (data) {
if (typeof data === 'string') {
logLine += `: ${data}`;
} else {
logLine += `: ${JSON.stringify(data)}`;
}
}
// Add to top of textarea
const currentText = txtRelayEvents.value;
txtRelayEvents.value = logLine + '\n' + currentText;
// Limit to last 1000 lines to prevent memory issues
const lines = txtRelayEvents.value.split('\n');
if (lines.length > 1000) {
txtRelayEvents.value = lines.slice(0, 1000).join('\n');
}
};
const refreshRelayData = async () => {
console.log('[relay2.html] Refreshing relay data...');
try {
const relays = await getRelayData();
createRelayTable(relays);
} catch (error) {
console.error('[relay2.html] Error getting relay data:', error);
divRelays.innerHTML = '<div style="text-align: center; padding: 50px;">Error loading relay data</div>';
}
};
/* ================================================================
UPDATE FOOTER
================================================================ */
const UpdateFooter = async () => {
try {
// Update relay status visuals in footer and sidenav
await updateFooterRelayStatus();
await updateSidenavRelaySection();
// Update center section with pubkey
if (currentPubkey) {
divFooterCenter.innerHTML = `${currentPubkey.slice(0, 8)}...${currentPubkey.slice(-8)}`;
} else {
divFooterCenter.innerHTML = 'Not authenticated';
}
} catch (error) {
console.error('[relay2.html] Error updating footer:', error);
}
};
/* ================================================================
LOGOUT
================================================================ */
const Logout = async () => {
console.log("[relay2.html] Starting logout process...");
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
localStorage.clear();
sessionStorage.clear();
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
console.log("[relay2.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
EVENT LISTENERS
================================================================ */
/* ================================================================
INITIALIZATION
================================================================ */
(async function main() {
console.log("[relay2.html] Starting initialization...");
try {
// Initialize hamburger menu first
initHamburgerMenu();
// Add click handler to hamburger
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
// Initialize version bar buttons
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
if (isDarkMode) {
localStorage.setItem('theme', 'dark');
} else {
localStorage.setItem('theme', 'light');
}
// Save sidenav state before reload
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
await Logout();
} catch (error) {
console.error('Logout failed:', error);
}
});
}
// Initialize NDK (handles authentication automatically)
await initNDKPage();
// Get authenticated pubkey
currentPubkey = await getPubkey();
console.log("[relay2.html] Authenticated as:", currentPubkey);
// Initialize relay UI components
initFooterRelayStatus();
initSidenavRelaySection();
// Listen for relay events from worker (for debug logging)
// Note: We need to listen on the worker port, not window
// This will be set up via a custom event from init-ndk.mjs
window.addEventListener('ndkRelayEvent', (event) => {
const { relayUrl, event: eventType, data } = event.detail;
logRelayEvent(relayUrl, eventType, data);
});
// Listen for relay activity broadcasts from worker
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[relay2.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
// Listen for relay activity broadcasts from worker (alternative message format)
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
console.log(`[relay2.html] Relay activity: ${relayUrl} - ${activity}`);
setRelayActivityState(relayUrl, activity);
}
});
// Get initial relay data
await refreshRelayData();
// Restore sidenav state if it was open before theme toggle
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
// Start update loops
updateIntervalId = setInterval(UpdateFooter, 1000);
// Update version display
await updateVersionDisplay();
// Refresh relay table every 5 seconds
setInterval(refreshRelayData, 5000);
console.log('[relay2.html] Initialization complete');
} catch (error) {
console.error('[relay2.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
<div style="font-size: 16px; color: #666;">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();

34
build_and_copy.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
# Build NDK bundle and copy to www/ directory
# Usage: ./build_and_copy.sh
echo "🔧 Building NDK bundle..."
node build-ndk-bundle.js
if [ $? -ne 0 ]; then
echo "❌ Build failed"
exit 1
fi
echo ""
echo "📋 Copying NDK bundle to www/ directory..."
# Copy NDK bundle
cp build/ndk-core.bundle.js www/
cp build/ndk-core.bundle.js.map www/
if [ $? -eq 0 ]; then
echo "✅ NDK bundle files copied to www/"
echo ""
echo "📦 NDK Bundle:"
ls -lh www/ndk-core.bundle.js
echo ""
echo "💡 Note: Make sure nostr.bundle.js and nostr-lite.js are in www/"
echo " (Copy manually from nostr_login_lite/build/ if needed)"
echo ""
echo "🚀 Ready to deploy with: ./upload_www.sh"
else
echo "❌ Failed to copy files"
exit 1
fi

8
client.code-workspace Normal file
View File

@@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

106
docs/MUSIC_URL_STRUCTURE.md Normal file
View File

@@ -0,0 +1,106 @@
# Music & VJ URL Structure
Unified URL schema for `music.html` and `vj.html`.
## URL Anatomy
```
https://example.com/vj.html?show=reggae&npub=npub1abc#/playlist/ep-1744382400
\______________________/\________/ \_______________/ \________________________/
origin pathname query string hash fragment
```
## Query String Parameters
Query string parameters represent **identity and session context**. They persist across hash navigation changes.
| Parameter | Format | Pages | Required | Description |
|-----------|--------|-------|----------|-------------|
| `npub` | `npub1...` | both | no | Target user public key in npub format (preferred) |
| `pubkey` | 64-char hex | both | no | Target user public key in hex format (fallback) |
| `auth` | `required` \| `optional` \| `none` | both | no | Authentication mode override |
| `show` | slug string | both | no | Active show slug (e.g. `saturday-reggae`) |
| `episode` | identifier string | both | no | Active episode playlist identifier |
| `a` | `30311:{pubkey}:{slug}` | vj.html | no | Stream coordinate for direct stream targeting |
| `naddr` | `naddr1...` | vj.html | no | Nostr address encoding of stream event |
### Parameter Precedence
- `npub` takes priority over `pubkey` when both are present
- `pubkey` is deleted from URL when `npub` is set
- If `npub` or `pubkey` points to a different user than the logged-in user, the page enters **read-only mode**
## Hash Fragment Routes
Hash routes represent **in-page navigation state**. Changing the hash does not reload the page.
| Route | Description |
|-------|-------------|
| `#/` | Home — empty search view |
| `#/search/{query}` | Search results for the given query |
| `#/album/{albumId}` | Album detail drill-down |
| `#/artist/{artistId}` | Artist detail drill-down |
| `#/track/{trackId}` | Play a specific track by ID |
| `#/playlist/{identifier}` | Open own playlist/episode by identifier |
| `#/playlist/{pubkey}/{identifier}` | Open external playlist/episode by pubkey and identifier |
### Route Parsing
Routes are parsed by splitting the hash on `/`:
```
#/playlist/abc123/ep-42 → { page: 'playlist', parts: ['abc123', 'ep-42'] }
#/search/bob marley → { page: 'search', parts: ['bob marley'] }
#/track/98765 → { page: 'track', parts: ['98765'] }
```
## URL Examples
### VJ working on own show
```
vj.html?show=saturday-reggae&episode=ep-1744382400
```
### Sharing an episode for playback on music.html
```
music.html?npub=npub1abc123...#/playlist/npub1abc123.../ep-1744382400
```
### Sharing a VJ episode for viewing
```
vj.html?npub=npub1abc123...&show=saturday-reggae&episode=ep-1744382400
```
### Direct track link (works on either page)
```
music.html#/track/12345678
vj.html#/track/12345678
```
### Search link
```
music.html#/search/bob%20marley
```
### Album link
```
music.html#/album/album-id-here
```
## URL Update Behavior
| User Action | URL Change | Method |
|-------------|-----------|--------|
| Select show in dropdown | `?show={slug}` added/updated | `replaceState` |
| Select/create episode | `?episode={id}` added/updated | `replaceState` |
| Search for music | `#/search/{query}` | hash assignment |
| Click album/artist | `#/album/{id}` or `#/artist/{id}` | hash assignment |
| Play track from link | `#/track/{id}` | hash assignment |
| Select playlist | `#/playlist/{id}` | hash assignment |
| Login / auth change | `?npub={npub}` added | `replaceState` |
| Load external user | `?npub={npub}` or `?pubkey={hex}` | page navigation |
### replaceState vs hash assignment
- **`replaceState`** — Used for context changes (show, episode, auth). Does not create a new history entry. The user does not get a "back" step for every dropdown change.
- **Hash assignment** — Used for navigation changes (search, album, track, playlist). Creates a new history entry. Back/Forward buttons navigate between views.

544
docs/SETTINGS.md Normal file
View File

@@ -0,0 +1,544 @@
# Settings System — Kind 30078
This document is the definitive specification for how user settings and application-specific data are stored, encrypted, synced, and consumed across all projects in this ecosystem (client web pages, Didactyl agent, and future apps).
## Table of Contents
1. [Overview](#1-overview)
2. [NIP-78 Context](#2-nip-78-context)
3. [User-Centric Layout](#3-user-centric-layout)
4. [Centralized User Settings Event](#4-centralized-user-settings-event)
5. [Worker Lifecycle](#5-worker-lifecycle)
6. [Page-Level API](#6-page-level-api)
7. [Standalone Kind 30078 Events](#7-standalone-kind-30078-events)
8. [Cross-Project Alignment: Didactyl](#8-cross-project-alignment-didactyl)
9. [Encryption Standard](#9-encryption-standard)
10. [Adding a New Settings Namespace](#10-adding-a-new-settings-namespace)
11. [Audit Findings and Migration Plan](#11-audit-findings-and-migration-plan)
12. [Key Source Files](#12-key-source-files)
---
## 1. Overview
The app uses **NIP-78 Application-specific Data** (`kind:30078`) for two distinct purposes:
1. **Centralized User Settings** — A single addressable event (`d:user-settings`) containing a namespaced JSON object. This is the user's portable preference file, designed to work across apps.
2. **Standalone Page Data** — Individual addressable events with page-specific `d` tags for large or specialized data stores.
Both are **parameterized replaceable events**: for a given `pubkey + kind + d-tag`, only the latest event is retained by relays.
```mermaid
graph TD
subgraph Centralized - d:user-settings
W[SharedWorker ndk-worker.js]
W -->|hydrate on init| C[IndexedDB cache]
W -->|fetch + decrypt| R[Relays]
W -->|NIP-44 encrypt + publish| R
P1[Page A] -->|getUserSettings| W
P2[Page B] -->|patchUserSettings| W
P3[Page C] -->|onUserSettings| W
D[Didactyl Agent] -->|reads llm from user prefs| R
end
subgraph Standalone - per-page d-tags
S1[todo.html] -->|d:todo| R2[Relays]
S2[cal.html] -->|d:calorie_foods / d:calorie_diary| R2
S3[links.html] -->|d:links| R2
S4[ai.html] -->|d:convo-id with t:client-ai-chat-v1| R2
S5[keep-alive.html] -->|d:relay-list-N with t:relay-list| R2
S6[post.html] -->|d:viewed| R2
end
```
---
## 2. NIP-78 Context
[NIP-78](../reference_repos/nips/78.md) specifies kind `30078` as an addressable event with a `d` tag containing "some reference to the app name and context — or any other arbitrary string." Content and tags can be anything.
**NIP-78 is intentionally a blank canvas.** No NIP defines:
- A standard schema for user preferences
- A convention for d-tag naming
- A cross-app settings format
This means our `d:user-settings` convention is ours to define. If it proves useful across the Nostr ecosystem, it could eventually become a NIP proposal for standardized user preferences.
**Related NIPs:**
- **NIP-44** — The required encryption standard for settings content (NIP-04 is deprecated)
- **NIP-51** — Defines structured lists (kinds 10000-30007) but not app settings
- **NIP-37** — Defines draft wraps (kind 31234) with separate relay lists for private content
---
## 3. User-Centric Layout
From the user's npub perspective, their kind 30078 events should be organized into three categories:
### Category 1: Cross-App User Preferences (`d:user-settings`)
Settings that any compatible app should respect. These follow the user across devices and apps:
| Namespace | Purpose | Example consumers |
|-----------|---------|-------------------|
| `global_llm` | LLM provider, model, API key, multi-provider config | AI pages, skills-edit, Didactyl agent |
| `global_zaps` | Default zap amount, comment, preferred method, mint allowlist | Cashu page, post interactions, any zap button |
| `global_ui` | Theme, language, accessibility preferences | All pages |
| `global_relays` | Relay preferences | All pages |
| `global_experimental` | Feature flags | All pages |
The `global_` prefix distinguishes cross-app settings from page-specific ones at a glance.
### Category 2: Page-Specific Settings (also in `d:user-settings`)
Layout and UI state that only matters to specific pages within client:
| Namespace | Purpose | Owning page |
|-----------|---------|-------------|
| `feed` | Video autoplay | `feed.html` |
| `post` | Viewed scroll behavior | `post.html` |
| `notifications` | Filters, readAt timestamp | `notifications.html` |
| `blobs` | Grid columns, rows per page | `blobs.html` |
| `vjPage` | Streaming sites, columns, draft | `vj.html` |
| `strudel` | Strudel music coding settings | strudel pages |
No prefix — these are clearly page-local by their names.
### Category 3: Standalone Data Events (separate d-tags)
Large data stores that would bloat the centralized settings event:
| d-tag | Purpose | Why standalone |
|-------|---------|---------------|
| `todo` | Todo list items | Can grow large |
| `calorie_foods` / `calorie_diary` | Calorie tracking data | Separate data domains |
| `links` | Bookmarks (Netscape HTML) | Large, compressed |
| `viewed` | Read/unread tracking per follow | Updates frequently |
| `{conversation-id}` | AI chat conversations | Many events, large |
| `relay-list-{N}` | Relay registry chunks | Intentionally public |
| `show-playlist:{show}:{ts}` | VJ episode playlists | Intentionally public |
---
## 4. Centralized User Settings Event
### Nostr Event Shape
```json
{
"kind": 30078,
"tags": [["d", "user-settings"]],
"content": "<NIP-44 encrypted JSON>",
"created_at": 1708646400
}
```
- **`d` tag**: Always `"user-settings"`
- **Encryption**: NIP-44 self-encrypt (sender = recipient = user pubkey)
- **Replaceability**: Addressable — publishing a new one replaces the previous on relays
### Target Schema (v2)
```json
{
"v": 2,
"updatedAt": 1708646400,
"global_llm": {
"provider": "ppq",
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7,
"providers": [
{
"name": "ppq",
"base_url": "https://api.ppq.ai",
"api_key": "sk-...",
"models": ["claude-opus-4.6", "claude-haiku-4.5"]
}
],
"favorites": ["claude-opus-4.6"]
},
"global_zaps": {
"defaultAmountSats": 21,
"defaultComment": "",
"preferredMethod": "auto",
"receiveMintAllowlist": []
},
"global_ui": {},
"global_relays": {},
"global_experimental": {},
"feed": { "videoAutoplay": false },
"post": { "viewed": { "scrollToMark": false } },
"notifications": { "filters": {}, "readAt": 0 },
"blobs": { "gridColumns": 4, "rowsPerPage": 10 },
"vjPage": {
"streamingSites": [],
"selectedSiteName": "",
"streamDraft": {},
"autoAnnounce": {},
"columns": {}
},
"strudel": {}
}
```
### Current Schema (v1) — Supported
The current implementation uses these namespace names without the `global_` prefix:
| Current key | Target key | Status |
|-------------|------------|--------|
| `ui` | `global_ui` | Rename pending |
| `relays` | `global_relays` | Rename pending |
| `experimental` | `global_experimental` | Rename pending |
| `zaps` | `global_zaps` | Rename pending |
| `ai` | `global_llm` | Rename + schema alignment pending |
| `post` | `post` | No change |
| `feed` | `feed` | No change |
| `notifications` | `notifications` | No change |
| `blobs` | `blobs` | No change |
| `vjPage` | `vjPage` | No change |
| `strudel` | `strudel` | No change |
### Default Settings
Defined in [`getDefaultUserSettings()`](../www/ndk-worker.js:324):
```js
{
v: 1, // SETTINGS_SCHEMA_VERSION
updatedAt: 0,
ui: {},
post: { viewed: { scrollToMark: false } },
strudel: {},
relays: {},
experimental: {}
}
```
Feature namespaces not in the defaults (e.g. `feed`, `notifications`, `zaps`, `blobs`, `ai`, `vjPage`) are created on first patch. The [`deepMerge()`](../www/ndk-worker.js:344) function ensures new keys are added without destroying existing ones.
### Normalization
[`normalizeUserSettings()`](../www/ndk-worker.js:359) deep-merges any input with the defaults, then forces:
- `v` to the current `SETTINGS_SCHEMA_VERSION`
- `updatedAt` to a numeric value
---
## 5. Worker Lifecycle
### Hydration (startup)
Called by [`hydrateUserSettingsForPubkey()`](../www/ndk-worker.js:3992) during [`handleInit()`](../www/ndk-worker.js:4271):
```
1. Read from IndexedDB cache (ndk-shared-settings DB, kv store)
2. Normalize and broadcast to all connected tabs
3. Fetch kind 30078 d:user-settings from relays
4. NIP-44 decrypt the content
5. Compare updatedAt timestamps — relay wins if >= local
6. Write winner to IndexedDB cache
7. Broadcast final settings to all tabs
```
### Publishing
Called by [`publishUserSettingsNow()`](../www/ndk-worker.js:4018):
```
1. Set updatedAt to current unix timestamp
2. Normalize the settings object
3. JSON.stringify the normalized object
4. NIP-44 encrypt (self-encrypt: sender = recipient = user pubkey)
5. Create NDKEvent with kind:30078, tags:[['d','user-settings']]
6. Sign and publish to connected relays
```
### Debounced Publish
[`scheduleUserSettingsPublish()`](../www/ndk-worker.js:4055) debounces rapid patches with a 250-350ms delay so multiple quick UI changes result in a single relay publish.
---
## 6. Page-Level API
All functions are exported from [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs) and communicate with the SharedWorker via `postMessage`.
### `getUserSettings()` -> `Promise<Object>`
[Source](../www/js/init-ndk.mjs:956). Returns the current merged settings object. Times out after 7 seconds.
### `patchUserSettings(patch, options?)` -> `Promise<Object>`
[Source](../www/js/init-ndk.mjs:985). Deep-merges `patch` into current settings, writes to cache, broadcasts to all tabs, and (unless `options.publish === false`) schedules a debounced relay publish.
**Convention**: Each page patches only its own namespace:
```js
await patchUserSettings({
myFeature: { someSetting: true }
});
```
### `onUserSettings(callback)` -> `unsubscribe function`
[Source](../www/js/init-ndk.mjs:1015). Subscribes to live settings updates via the `ndkUserSettings` custom DOM event. Returns an unsubscribe function.
### Standard Page Pattern
```js
// 1. Import
import { getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs';
// 2. Initial read
let pageSettings = {};
try {
pageSettings = await getUserSettings();
} catch (error) {
pageSettings = {};
}
// 3. Subscribe to live updates
const unsubscribe = onUserSettings((settings) => {
pageSettings = settings || {};
applySettings(pageSettings);
});
// 4. Patch your namespace
await patchUserSettings({ myFeature: { key: value } });
```
---
## 7. Standalone Kind 30078 Events
These are **not** part of the centralized user-settings object. Each page manages its own `d`-tagged event independently.
| d-tag | Page | Encryption | Content Format | Description |
|-------|------|------------|----------------|-------------|
| `todo` | [`todo.html`](../www/todo.html:831) | NIP-04 (legacy) | `{ rows: [todo items] }` | User todo list |
| `calorie_foods` | [`cal.html`](../www/cal.html:975) | NIP-04 (legacy) | `{ rows: [food items] }` | Calorie food database |
| `calorie_diary` | [`cal.html`](../www/cal.html:975) | NIP-04 (legacy) | `{ rows: [diary entries] }` | Daily food diary |
| `links` | [`links.html`](../www/links.html:497) | NIP-04 + LZW (legacy) | Netscape bookmark HTML | Saved bookmarks |
| `viewed` | [`post.html`](../www/post.html) | NIP-44 | `{ v:1, lastGlobalView, follows: {} }` | Read/unread tracking |
| `relay-list-{N}` | [`keep-alive.html`](../www/keep-alive.html:1162) | Plaintext | JSON relay metadata chunks | Relay registry |
| `{conversation-id}` | [`ai.html`](../www/ai.html:788) | NIP-44 | `{ id, title, messages, ... }` | AI chat conversations |
| `show-playlist:{show}:{ts}` | [`vj-stream.mjs`](../www/js/vj-stream.mjs:5) | Plaintext | Episode playlist with track tags | VJ episode playlists |
### Why Standalone?
- **Size**: Todo lists, bookmarks, and conversations can be large
- **Update frequency**: Conversations and playlists update frequently and independently
- **Different encryption**: Some use NIP-04 (legacy), some NIP-44, some are plaintext
- **Different audiences**: Relay registry and playlists are intentionally public
---
## 8. Cross-Project Alignment: Didactyl
### Didactyl's Kind 30078 Usage
The Didactyl agent (a C binary with its own Nostr identity) uses kind 30078 with NIP-44 self-encrypted payloads:
| d-tag | Source | Content |
|-------|--------|---------|
| `user-settings` | [`main.c`](/home/user/lt/didactyl/src/main.c) | `{ v, updatedAt, global_llm, didactyl }` |
| `tasks` | [`tool_task.c:14`](/home/user/lt/didactyl/src/tools/tool_task.c:14) | Agent task memory |
| `memory` | [`tool_memory.c:14`](/home/user/lt/didactyl/src/tools/tool_memory.c:14) | Agent long-term memory |
| Any d-tag | [`tool_config.c:128`](/home/user/lt/didactyl/src/tools/tool_config.c:128) | Generic config_store/config_recall |
### Didactyl `user-settings` Shape
Didactyl now stores runtime LLM + agent metadata in a single `d:user-settings` event under the **agent's own pubkey**:
```json
{
"v": 2,
"updatedAt": 1712345678,
"global_llm": {
"provider": "ppq",
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7
},
"didactyl": {
"admin_pubkey": "npub1...",
"dm_protocol": "nip04"
}
}
```
`model_set` performs read-modify-write of this event by patching `global_llm` while preserving other namespaces.
### Cross-Project Reading
Didactyl does **not** read admin/user web `user-settings` for runtime startup. It only reads/writes:
```
kind:30078, authors:[agent_pubkey], #d:[user-settings]
```
If a web page wants to inspect agent runtime settings, it should query the agent pubkey's `d:user-settings` event and read:
- `global_llm` for model/provider/api settings
- `didactyl` for agent-specific runtime metadata
---
## 9. Encryption Standard
| Context | Method | Rationale |
|---------|--------|-----------|
| Centralized user-settings | NIP-44 self-encrypt | Modern standard; worker handles via messageSigner |
| `viewed` (post read state) | NIP-44 self-encrypt | Privacy — relays cannot see read state |
| AI conversations | NIP-44 self-encrypt | Contains private chat history |
| Didactyl configs | NIP-44 self-encrypt | Contains API keys |
| `todo` | **NIP-04 (legacy — migrate)** | Predates NIP-44 |
| `calorie_foods` / `calorie_diary` | **NIP-04 (legacy — migrate)** | Predates NIP-44 |
| `links` | **NIP-04 + LZW (legacy — migrate)** | Predates NIP-44 |
| `relay-list-{N}` | Plaintext | Intentionally public |
| VJ episode playlists | Plaintext | Intentionally public |
**NIP-04 is deprecated.** All legacy pages should migrate to NIP-44.
---
## 10. Adding a New Settings Namespace
### Step 1: Choose centralized vs standalone
- Small config that benefits from cross-tab sync -> centralized namespace
- Large data, high-frequency updates, or different encryption needs -> standalone d-tag
- Cross-app portable preference -> centralized with `global_` prefix
### Step 2: For centralized (recommended for most page settings)
```js
import { getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs';
// Read on init
const settings = await getUserSettings();
const myConfig = settings?.myNewFeature || {};
// Subscribe to live updates
onUserSettings((s) => {
const updated = s?.myNewFeature || {};
applyMyConfig(updated);
});
// Save changes
await patchUserSettings({
myNewFeature: { option1: true, option2: 'value' }
});
```
### Step 3: For standalone
```js
import { subscribe, publishEvent } from './js/init-ndk.mjs';
// Read
subscribe(
{ kinds: [30078], authors: [pubkey], '#d': ['my-feature-data'] },
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);
// Write (use NIP-44 encryption via worker messageSigner)
await publishEvent({
kind: 30078,
tags: [['d', 'my-feature-data']],
content: encryptedContent,
created_at: Math.floor(Date.now() / 1000)
});
```
### Step 4: Document
Add the new namespace to the appropriate table in this file.
---
## 11. Audit Findings and Migration Plan
### Issue 1: Duplicate AI Config Storage — RESOLVED
**Previous problem**: AI provider config was stored in two places:
- `skills-edit.html` used standalone user-pubkey `d:llm_config`
- Other AI pages used centralized user settings (`settings.ai` / `global_llm`) via `patchUserSettings`
**Resolution implemented**:
- `skills-edit.html` now reads from centralized user settings `global_llm` (with `ai` fallback for v1 compatibility)
- `skills-edit.html` now writes LLM updates via `patchUserSettings({ global_llm: ... })`
- Standalone user-pubkey `d:llm_config` is deprecated
- Agent-pubkey standalone `d:llm_config` remains the Didactyl runtime config format
### Issue 2: NIP-04 Legacy Encryption — HIGH PRIORITY
**Problem**: Three pages use deprecated NIP-04 encryption:
- [`todo.html`](../www/todo.html:822) — `window.nostr.nip04.encrypt()`
- [`cal.html`](../www/cal.html:972) — `window.nostr.nip04.encrypt()`
- [`links.html`](../www/links.html:501) — `window.nostr.nip04.encrypt()` + LZW
**Migration**: Switch to NIP-44. Add one-time migration: read NIP-04, re-encrypt with NIP-44, republish.
### Issue 3: Direct `window.nostr` Calls — MEDIUM PRIORITY
**Problem**: Standalone pages call `window.nostr.nip04.encrypt/decrypt` directly, bypassing the worker's `messageSigner` infrastructure. This breaks with remote signers/bunkers.
**Migration**: Route encryption through the worker's `messageSigner` for consistency.
### Issue 4: Inconsistent Filter Syntax — LOW PRIORITY
**Problem**: [`cal.html`](../www/cal.html:997) passes filter as an array `[{ kinds: [30078], ... }]` instead of a plain object.
**Migration**: Normalize to object form.
### Issue 5: Missing `cacheUsage: 'CACHE_FIRST'` — LOW PRIORITY
**Problem**: [`todo.html`](../www/todo.html:863), [`cal.html`](../www/cal.html:998), [`links.html`](../www/links.html:434) subscribe without `cacheUsage: 'CACHE_FIRST'`.
**Migration**: Add `cacheUsage: 'CACHE_FIRST'` for faster load times.
### Issue 6: Merge `calorie_foods` + `calorie_diary` — LOW PRIORITY
**Problem**: [`cal.html`](../www/cal.html:1103) publishes two separate events that are always loaded and saved together.
**Migration**: Combine into single `d:calorie` event: `{ foods: {...}, diary: {...} }`.
### Issue 7: Rename to `global_` Prefix — DEFERRED
**Problem**: Current cross-app namespaces (`ai`, `zaps`, `ui`, `relays`, `experimental`) lack the `global_` prefix.
**Migration**: Bump schema to v2. In `normalizeUserSettings()`, detect v1 and migrate: copy `ai` -> `global_llm`, `zaps` -> `global_zaps`, etc. Support reading both during transition.
### Migration Priority Order
1. Merge `skills-edit.html` `d:llm_config` into centralized settings (Issue 1)
2. Migrate `todo.html`, `cal.html`, `links.html` from NIP-04 to NIP-44 (Issue 2)
3. Route standalone encryption through worker messageSigner (Issue 3)
4. Fix `cal.html` filter syntax + add CACHE_FIRST everywhere (Issues 4, 5)
5. Merge calorie events (Issue 6)
6. Rename to `global_` prefix with v2 schema migration (Issue 7)
---
## 12. Key Source Files
| File | Role |
|------|------|
| [`www/ndk-worker.js`](../www/ndk-worker.js:311) | Settings state, hydration, publish, cache, normalize |
| [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs:956) | Page-facing API: getUserSettings, patchUserSettings, onUserSettings |
| [`reference_repos/nips/78.md`](../reference_repos/nips/78.md) | NIP-78 specification for kind 30078 |
| [`reference_repos/nips/44.md`](../reference_repos/nips/44.md) | NIP-44 encryption specification |
| `/home/user/lt/didactyl/src/main.c` | Didactyl agent llm_config and agent_config publish/recall |
| `/home/user/lt/didactyl/src/tools/tool_config.c` | Didactyl generic config_store/config_recall tool |
| `/home/user/lt/didactyl/src/tools/tool_model.c` | Didactyl model_set tool (persists to d:llm_config) |

View File

@@ -0,0 +1,551 @@
# Cache-First Page Implementation Patterns
## Lessons Learned & Best Practices for client Pages
---
## The Core Problem
Every page in this app communicates with Nostr relays through a shared Web Worker via `init-ndk.mjs`. Relay responses are inherently slow and unreliable — they may take seconds, time out after 15s, or never arrive at all. If page initialization `await`s relay fetches, the user stares at a blank screen.
Meanwhile, NDK maintains an IndexedDB cache (via Dexie) that contains previously-seen events. This cache is local and fast (sub-100ms). **Pages must render from cache first, then hydrate from relays in the background.**
---
## The Three Data APIs
### 1. `queryCache(filters)` — Local IndexedDB only (FAST)
- **Timeout**: 5 seconds
- **Source**: Dexie/IndexedDB only, no network
- **Use for**: Initial page render, instant UI population
- **Import**: `import { queryCache } from './js/init-ndk.mjs'`
### 2. `ndkFetchEvents(filters)` — Relay fetch (SLOW)
- **Timeout**: 15 seconds
- **Source**: Nostr relays via NDK in the worker
- **Use for**: Background hydration after cache render
- **Import**: `import { ndkFetchEvents } from './js/init-ndk.mjs'`
- **WARNING**: Never `await` this in the critical render path if cache data exists
### 3. `subscribe(filters, opts)` — Live streaming (ONGOING)
- **No timeout**: Stays open, delivers events via `window 'ndkEvent'` events
- **Source**: Cache first (if `cacheUsage: 'CACHE_FIRST'`), then relays
- **Use for**: Live updates after initial render
- **Import**: `import { subscribe } from './js/init-ndk.mjs'`
---
## The Golden Rule
```
NEVER await a relay call (ndkFetchEvents) in the critical render path
if you can get data from cache first.
```
---
## Standard Page Loading Pattern
```mermaid
sequenceDiagram
participant Page
participant Cache as queryCache - IndexedDB
participant Relay as ndkFetchEvents - Relays
participant Sub as subscribe - Live
Page->>Cache: queryCache filters
Cache-->>Page: cached events - instant
Page->>Page: render UI from cache
Page->>Relay: void ndkFetchEvents filters
Note over Relay: fire-and-forget, no await
Relay-->>Page: relay events - eventually
Page->>Page: merge and re-render if newer
Page->>Sub: subscribe filters, CACHE_FIRST
Note over Sub: stays open for live updates
Sub-->>Page: ndkEvent window events
Page->>Page: upsert and re-render
```
---
## Implementation Template
### Step 1: Import both `queryCache` and `ndkFetchEvents`
```javascript
import {
initNDKPage,
getPubkey,
queryCache,
ndkFetchEvents,
subscribe,
// ... other imports
} from './js/init-ndk.mjs';
```
### Step 2: Cache-first data loading function
```javascript
async function loadPageData() {
const pubkey = await getPubkey();
// Phase 1: Cache (blocking — but fast)
let items = [];
try {
const cached = await queryCache({
kinds: [MY_KIND],
authors: [pubkey],
limit: 50
});
items = Array.isArray(cached) ? cached : [];
renderItems(items);
console.log('[my-page] Rendered from cache:', items.length);
} catch (err) {
console.warn('[my-page] Cache query failed:', err?.message);
}
// Phase 2: Relay hydration (non-blocking — fire and forget)
void ndkFetchEvents({
kinds: [MY_KIND],
authors: [pubkey],
limit: 50
}).then((relayEvents) => {
if (Array.isArray(relayEvents) && relayEvents.length > 0) {
// Merge with existing items, dedupe by id
const merged = mergeAndDedupe(items, relayEvents);
renderItems(merged);
console.log('[my-page] Hydrated from relays:', relayEvents.length);
}
}).catch((err) => {
console.warn('[my-page] Relay hydration failed:', err?.message);
});
// Phase 3: Live subscription for ongoing updates
subscribe(
{ kinds: [MY_KIND], authors: [pubkey] },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
}
```
### Step 3: Handle live events via ndkEvent listener
```javascript
window.addEventListener('ndkEvent', async (event) => {
const evt = event.detail;
if (!evt || evt.kind !== MY_KIND) return;
// Upsert into your data structure and re-render
upsertItem(evt);
renderItems(getAllItems());
});
```
---
## Common Anti-Patterns to Avoid
### Anti-Pattern 1: Awaiting relay fetch in main()
```javascript
// BAD — blocks page for up to 15 seconds
async function main() {
const events = await ndkFetchEvents({ kinds: [1], authors: [pubkey] });
renderFeed(events);
}
```
```javascript
// GOOD — render from cache, hydrate in background
async function main() {
const cached = await queryCache({ kinds: [1], authors: [pubkey] });
renderFeed(cached);
void ndkFetchEvents({ kinds: [1], authors: [pubkey] }).then(renderFeed);
}
```
### Anti-Pattern 2: Sequential relay fetches in a loop
```javascript
// BAD — each iteration blocks for up to 15 seconds
for (const item of items) {
const refs = await ndkFetchEvents({ ids: [item.refId] });
item.image = extractImage(refs[0]);
}
```
```javascript
// GOOD — try cache first, relay in background
for (const item of items) {
let refs = [];
try { refs = await queryCache({ ids: [item.refId] }); } catch (_) {}
const ref = refs.find(r => r.id === item.refId);
item.image = ref ? extractImage(ref) : '';
if (!ref) {
// Fire-and-forget relay lookup
void ndkFetchEvents({ ids: [item.refId] }).then((relayRefs) => {
const relayRef = relayRefs?.find(r => r.id === item.refId);
if (relayRef) {
item.image = extractImage(relayRef);
// Re-render if needed
}
}).catch(() => {});
}
}
```
### Anti-Pattern 3: Awaiting relay fetch for a single event by ID
```javascript
// BAD — blocks detail page for up to 15 seconds
if (isEventMode) {
const events = await ndkFetchEvents({ ids: [targetEventId], limit: 1 });
renderSingleEvent(events[0]);
}
```
```javascript
// GOOD — render from cache instantly, relay hydrates in background
if (isEventMode) {
let matched = null;
try {
const cached = await queryCache({ ids: [targetEventId], limit: 1 });
matched = Array.isArray(cached) ? cached.find(e => e?.id === targetEventId) : null;
} catch (_) {}
if (matched) renderSingleEvent(matched);
void ndkFetchEvents({ ids: [targetEventId], limit: 1 }).then(relayEvents => {
const relayMatch = Array.isArray(relayEvents)
? relayEvents.find(e => e?.id === targetEventId) : null;
if (relayMatch) renderSingleEvent(relayMatch);
}).catch(() => {});
}
```
### Anti-Pattern 4: Embedded content hydration using only relays
When a rendered post contains `nostr:nevent1...` references, the hydration code
fetches the referenced event to display an inline preview. This is a secondary
fetch triggered *after* the main content renders — easy to miss.
```javascript
// BAD — embedded note hydration blocks on relay, shows "Failed to fetch" on timeout
const events = await ndkFetchEventsFn(filter);
```
```javascript
// GOOD — try cache first, fall back to relay
let events = [];
if (queryCacheFn) {
try { events = await queryCacheFn(filter); } catch (_) {}
}
if (!events || events.length === 0) {
events = await ndkFetchEventsFn(filter);
}
```
### Anti-Pattern 5: Interaction data fetched only from relays
Social interaction queries — likes, reposts, zaps, comments — use `#e` tag
filters. These should also be cache-first so the interaction bar populates
instantly on page refresh.
```javascript
// BAD — interaction counts blank for 15 seconds
const allEvents = await ndkFetchEvents(filters);
applyInteractions(allEvents);
```
```javascript
// GOOD — show cached counts immediately, relay updates in background
let hadCache = false;
if (queryCacheFn) {
try {
const cached = await queryCacheFn(filters);
hadCache = applyInteractions(cached, 'cache');
} catch (_) {}
}
if (hadCache) {
void ndkFetchEvents(filters).then(relay => applyInteractions(relay, 'relays')).catch(() => {});
} else {
const relay = await ndkFetchEvents(filters);
applyInteractions(relay, 'relays');
}
```
### Anti-Pattern 6: Module loading that blocks on relays
```javascript
// BAD — configureMuteList + loadMuteList blocks if relay is slow
configureMuteList({ ndkFetchEvents, getPubkey });
await loadMuteList(); // blocks for 15s if no cache
// GOOD — pass queryCache so cache loads instantly, relay hydrates in background
configureMuteList({ ndkFetchEvents, queryCache, getPubkey });
await loadMuteList(); // returns instantly from cache, relays fire-and-forget
```
---
## Module Configuration Checklist
When configuring shared modules like `mute-list.mjs`, always pass **both** `queryCache` and `ndkFetchEvents`:
```javascript
configureMuteList({
ndkFetchEvents,
queryCache, // <-- REQUIRED for cache-first loading
publishEvent,
getPubkey,
nip44Encrypt: ...,
nip44Decrypt: ...
});
```
---
## Profile Resolution
The `profile-cache.mjs` module already implements cache-first patterns internally. When creating a profile cache instance, pass `queryCache`:
```javascript
const profileCache = createProfileCache({
fetchCachedProfile,
ndkFetchEvents,
storeProfile,
queryCache, // <-- enables cache-first profile lookups
refreshIntervalMs: 20 * 60 * 1000
});
```
---
## Timeout Reference
| API | Timeout | Blocking? |
|-----|---------|-----------|
| `queryCache()` | 5s | Yes (but fast — local IndexedDB) |
| `ndkFetchEvents()` | 15s | **Only if you await it** |
| `subscribe()` | None | No (event-driven) |
| `publishEvent()` | 15s | Yes (must await for confirmation) |
---
## Worker Request Latency Pattern (Critical for Wallet Actions)
Some UI actions call worker RPCs via `sendWorkerRequest()` and have strict front-end timeouts (for example, 45s for `walletSendNutzap`, 90s for `walletPayInvoice`).
If a worker handler does both:
1. **critical state updates** (required for user-visible success), and
2. **slow relay-side writes** (proof republish, history events, cleanup),
then awaiting both in sequence can cause front-end timeout errors even when the payment actually succeeded.
### Anti-Pattern 7: Responding after non-critical relay writes
```javascript
// BAD — response blocked behind slow relay publishing
await publishDirectProofs();
await publishSpendingHistoryEvent(tx);
port.postMessage({ type: 'response', requestId, data: successPayload });
```
```javascript
// GOOD — respond after critical path, background the rest
const payload = getWalletBalancePayload();
port.postMessage({ type: 'response', requestId, data: successPayload });
broadcast({ type: 'walletBalanceUpdated', data: payload });
void (async () => {
try { await publishDirectProofs(); } catch (e) { console.warn(e); }
try { await publishSpendingHistoryEvent(tx); } catch (e) { console.warn(e); }
})();
```
### Rule of thumb for worker handlers
- **Await only critical path steps** needed to determine success/failure for the request.
- **Respond immediately** once local state is coherent.
- **Background non-critical relay writes** with `void` async blocks and warning logs.
- **Do not hide failures silently**: warn in logs so reconciliation/debug remains possible.
This is now the expected pattern for latency-sensitive wallet operations like `walletPayInvoice` and `walletSendNutzap`.
---
## Dependency Threading Checklist
When a page passes dependencies to a module, and that module passes them to a
sub-module, `queryCache` must be threaded through **every layer**. Missing it at
any hop means the inner module falls back to relay-only fetches.
```
page.html → post-interactions2.mjs → post-interactions.mjs
queryCache ✅ queryCache ✅ queryCacheFn ✅
```
**Rule**: If you add `queryCache` to a page's dependency object, grep for every
module in the chain that destructures and forwards those deps. Each one must
explicitly destructure and pass `queryCache` through.
```javascript
// post-interactions2.mjs — MUST destructure and forward queryCache
export function initPostCards(deps = {}) {
const { ndkFetchEvents, queryCache, /* ... */ } = deps;
const interactions = initInteractions({
ndkFetchEvents,
queryCache, // <-- MUST be forwarded here
// ...
});
}
```
```javascript
// post-interactions.mjs — MUST accept and store queryCache
let queryCacheFn = null;
export function initInteractions(ndkFunctions = {}) {
const { ndkFetchEvents, queryCache, /* ... */ } = ndkFunctions;
queryCacheFn = typeof queryCache === 'function' ? queryCache : null;
}
```
---
## Render & Decrypt Performance Lessons (from msg.html, v0.4.20v0.4.23)
These lessons were learned during the messaging page optimization cycle. They
apply to any page that does expensive async work (decryption, signing, heavy
DOM rendering) triggered by event-driven callbacks.
### Lesson 1: Measure first, then optimize
Add lightweight `performance.now()` instrumentation to every phase of your
page's critical path. Without timing data, you will guess wrong about where
the bottleneck is. In `msg.html`, we added a rolling perf report system
(`startMsgPerfReport` / `markMsgPerf` / `finalizeMsgPerfReport`) that logs
step-by-step timing to the console and exposes `window.__msgPerfReports` for
programmatic inspection. This immediately revealed that 63 seconds of wall
time was spent in eager decrypt — not in cache queries or DOM rendering.
### Lesson 2: Expensive async render functions need single-flight guards
If `renderThread()` (or any expensive async render function) can be called
from multiple event-driven paths — live subscription events, profile fetches,
gift-wrap processing, relay hydration callbacks — concurrent invocations will
pile up. Each one does the same expensive work (decrypt, DOM build), and all
but the last one get thrown away.
**Pattern**: Wrap the render function in a single-flight coalescing guard:
```javascript
let renderInFlight = null;
let renderQueued = false;
async function renderThread() {
if (renderInFlight) {
renderQueued = true;
return renderInFlight;
}
renderInFlight = (async () => {
do {
renderQueued = false;
await renderThreadInner();
} while (renderQueued);
})().finally(() => { renderInFlight = null; });
return renderInFlight;
}
```
This collapses N concurrent calls into at most 2 executions (current + one
queued rerun).
### Lesson 3: Deduplicate in-flight async work, not just completed results
Caching the *result* of an async operation (e.g., `event._decodedKind4`) is
not enough if multiple callers start the operation before the first one
finishes. You must also deduplicate the *in-flight promise* itself.
**Pattern**: Use a `Map` keyed by operation identity to store the active
promise:
```javascript
const inFlightDecrypts = new Map();
async function decryptEvent(event, counterparty) {
if (event._decoded) return event._decoded;
const key = event.id + ':' + counterparty;
if (inFlightDecrypts.has(key)) return inFlightDecrypts.get(key);
const promise = doActualDecrypt(event, counterparty)
.finally(() => inFlightDecrypts.delete(key));
inFlightDecrypts.set(key, promise);
return promise;
}
```
In `msg.html`, this reduced 290 decrypt calls (29 renders × 10 messages)
down to 10.
### Lesson 4: Status text should reflect progress, not just phase entry
Showing "Decrypting recent messages… 10" and then going silent for 60 seconds
is worse than showing nothing — it looks frozen. Update the status on every
iteration: "Decrypting recent messages… 3/10". This gives the user confidence
that work is happening.
### Lesson 5: Audit every call site that triggers re-render
Non-awaited render calls from places like `ensureProfileName()` and
`processKind4Event()` can fire dozens of times during background hydration.
Each one triggers a full render cycle. Treat render triggers like network
calls: audit them, guard them, and suppress them during batch operations
(e.g., using a `suppressUiRefresh` flag).
### Lesson 6: "Small" per-item latency explodes in serial loops
A 6-second decrypt call seems tolerable for one message. But 10 messages in a
serial loop = 60 seconds. And if that loop runs 29 times concurrently due to
a render storm, the user waits over a minute. Always consider the
multiplicative effect of per-item costs × loop iterations × concurrent
invocations.
### Lesson 7: Cache-first is necessary but not sufficient
Even after making `hydrateConversationHistory()` cache-first (rendering from
cache before relay hydration), the page still stalled because the *render
path itself* contained expensive blocking work (decrypt). Cache-first solves
the data-fetch bottleneck; you still need to address compute bottlenecks in
the render pipeline.
### Lesson 8: Keep perf tooling in production-safe form
Lightweight timing helpers that log to `console.groupCollapsed` and store
results in a bounded array are cheap enough to ship. They cost nothing when
nobody opens DevTools, and they save hours when debugging the next regression.
---
## Summary Rules
1. **Always import `queryCache`** alongside `ndkFetchEvents` in every page
2. **Render from cache first**`await queryCache()` is safe to block on (fast, local)
3. **Never await `ndkFetchEvents` in the render path** — use `void` for fire-and-forget
4. **Use `subscribe` with `cacheUsage: 'CACHE_FIRST'`** for live updates
5. **Pass `queryCache` to all module configurations** (mute-list, profile-cache, etc.)
6. **Thread `queryCache` through every dependency hop** — page → wrapper → inner module
7. **Apply cache-first to secondary fetches** — embedded notes, interaction data, not just main content
8. **Avoid sequential relay fetches in loops** — batch or use cache-first per item
9. **Always handle relay timeouts gracefully** — the cache result is good enough for initial render
10. **Guard expensive async render functions with single-flight coalescing** — prevent render storms
11. **Deduplicate in-flight async work** — cache the promise, not just the result
12. **Audit all re-render trigger sites** — suppress during batch operations
13. **Show incremental progress in status text** — update per-item, not per-phase
14. **For worker RPC handlers, respond before non-critical relay writes** — background republish/history/cleanup work with warning logs

524
docs/music.md Normal file
View File

@@ -0,0 +1,524 @@
# Music Player — Queue System Documentation
## Overview
The music page ([`www/music.html`](www/music.html:1)) uses a **single unified queue** (`musicQueue[]`) as the source of truth for all playback. The audio engine ([`SimplePlayer`](www/js/greyscale-player.mjs:8)) is a pure playback component — it plays whatever stream URL it receives and fires callbacks when tracks end.
---
## Architecture
```mermaid
flowchart TB
subgraph Page Layer - musicQueue is the single source of truth
MQ[musicQueue array]
QI[queueCurrentIndex]
QP[Queue Panel UI]
Logic[Queue Logic: next / prev / insert / remove / reorder]
end
subgraph SimplePlayer - pure audio engine
Play[playTrack - resolve stream and play]
Toggle[togglePlayPause]
OnEnd[onEnded callback]
end
subgraph Entry Points
SearchClick[Click track in search results]
PlusQ[+Q button on track]
PlusA[+A button on track row]
AlbumQ[+Q on album card]
AlbumCover[Click album cover in detail]
PlaylistPlay[Playlist Play button]
PlaylistQ[Playlist +Q button]
QueueClick[Click queue item]
PlayQueueBtn[Play Queue button]
PrevNext[Prev / Next buttons]
end
SearchClick -->|insert at current+1 and play| Logic
PlusQ -->|append to end| Logic
PlusA -->|append album tracks to end| Logic
AlbumQ -->|fetch album, append to end| Logic
AlbumCover -->|prepend to front, play from 0| Logic
PlaylistPlay -->|replace queue, play from 0| Logic
PlaylistQ -->|append to end| Logic
QueueClick -->|set index, play| Logic
PlayQueueBtn -->|play from current or 0| Logic
PrevNext -->|advance/retreat index, play| Logic
Logic --> MQ
Logic --> QI
MQ --> QP
QI --> QP
Logic -->|track at queueCurrentIndex| Play
OnEnd -->|advance queueCurrentIndex| Logic
```
---
## Data Structures
### Queue State (Page Layer)
```javascript
musicQueue[] // Array of normalized track objects — THE source of truth
queueCurrentIndex // Index of the currently playing track (-1 if nothing playing)
```
Persisted to localStorage under key `music-queue:{pubkey}`:
```json
{
"currentIndex": 2,
"items": [
{ "id": 123, "title": "...", "artist": "...", "duration": 240, "cover": "...", "albumTitle": "..." }
]
}
```
### SimplePlayer (Audio Engine)
```javascript
SimplePlayer.audio // HTML audio element
SimplePlayer.dashPlayer // dash.js MediaPlayer instance
SimplePlayer.resolveStreamFn // Function: track → { streamUrl, isDash }
SimplePlayer.onEnded // Callback: fired when current track finishes
SimplePlayer.onTrackChanged // Callback: fired when a new track starts playing
```
The player has **no queue** and **no index**. It plays one track at a time.
---
## Play Entry Points
### 1. Click a Track in Search Results
**Behavior:** Insert at `queueCurrentIndex + 1` and play immediately.
```
Before: [A, B*, C, D] (* = currently playing)
Click track X from search
After: [A, B, X*, C, D] (X inserted after B, now playing)
```
- The clicked track appears in the queue panel
- The rest of the queue is preserved
- If queue is empty, the track becomes the only item at index 0
### 2. +Q Button on a Track Row
**Behavior:** Append to end of queue. No playback change.
```
Before: [A, B*, C]
+Q track X
After: [A, B*, C, X]
```
### 3. +A Button on a Track Row (Queue Album from Search Results)
**Behavior:** Find all tracks in current results sharing the same `albumTitle`. Append them to end of queue. No playback change.
### 4. +Q Button on an Album Card
**Behavior:** Fetch album from API. Append all album tracks to end of queue. No playback change.
### 5. Click Album Cover Image in Album Detail View
**Behavior:** Prepend album tracks to front of queue. Play from index 0.
```
Before: [A, B*, C]
Click album cover (tracks: X, Y, Z)
After: [X*, Y, Z, A, B, C]
```
### 6. Play Queue Button
**Behavior:** Play from `queueCurrentIndex` (or 0 if index is -1).
### 7. Click a Queue Item
**Behavior:** Set `queueCurrentIndex` to clicked index. Play that track.
### 8. Playlist ▶ Play Button
**Behavior:** Replace entire queue with playlist tracks. Play from index 0.
```
Before: [A, B*, C]
Play playlist (tracks: X, Y, Z)
After: [X*, Y, Z]
```
### 9. Playlist +Q Button
**Behavior:** Append playlist tracks to end of queue. No playback change.
### 10. Prev / Next Buttons
**Behavior:**
- **Next:** `queueCurrentIndex = (queueCurrentIndex + 1) % musicQueue.length`. Play new track.
- **Prev:** `queueCurrentIndex = (queueCurrentIndex - 1 + musicQueue.length) % musicQueue.length`. Play new track.
- If queue is empty, do nothing.
### 11. Track Ends (Auto-Advance)
**Behavior:** Same as Next button — advance `queueCurrentIndex` by 1, wrapping around. Play next track.
### 12. Play / Pause Button
**Behavior:** Toggle `audio.pause()` / `audio.play()`. No queue interaction.
---
## Queue Panel UI
### Visual Layout
```
┌─────────────────────┐
│ QUEUE │
│ [Play Queue] [Clear]│
├─────────────────────┤
│ ♪ Track A │
│ ♪ Track B (active) │ ← queueCurrentIndex, accent border
│─────────────────────│ ← accent divider line (afterCurrent)
│ ♪ Track C │
│ ♪ Track D │
└─────────────────────┘
```
- Active track: `border-color: accent-color`
- Item after active: top border divider (`afterCurrent` class)
- Items are drag-reorderable
- Each item has a ✕ remove button
---
## SimplePlayer API (After Refactor)
| Method | Description |
|--------|-------------|
| `playTrack(track, resolver?)` | Resolve stream URL for track and play it |
| `playStream(streamUrl, isDash)` | Play a raw stream URL |
| `togglePlayPause()` | Toggle audio pause/play |
| `setResolver(fn)` | Set default stream resolver function |
| `onEnded` | Callback: track finished playing |
| `onTrackChanged` | Callback: new track started |
| `onTimeUpdate` | Callback: playback position changed |
**Removed from SimplePlayer:**
- ~~`queue[]`~~ — lives in `musicQueue[]` on page
- ~~`currentIndex`~~ — lives in `queueCurrentIndex` on page
- ~~`setQueue()`~~ — no longer needed
- ~~`playNext()`~~ — page layer handles this
- ~~`playPrev()`~~ — page layer handles this
- ~~`getCurrentTrack()`~~ — page reads `musicQueue[queueCurrentIndex]`
- ~~`playCurrent()`~~ — replaced by `playTrack(track)`
---
## Queue Logic Functions (Page Layer)
| Function | Description |
|----------|-------------|
| `playQueueAt(index)` | Set queueCurrentIndex, resolve stream, play via SimplePlayer |
| `playNext()` | Advance index, play |
| `playPrev()` | Retreat index, play |
| `insertAndPlay(track)` | Insert at queueCurrentIndex + 1, set index, play |
| `addTracksToQueue(tracks, opts)` | Append tracks; optionally replace queue and/or play now |
| `addTrackToQueue(track)` | Append single track to end |
| `prependTracksAndPlay(tracks)` | Prepend to front, play from 0 |
| `queuePlaylist(playlist, opts)` | Queue or replace-and-play a playlist |
| `queueAlbumFromTrack(track)` | Queue tracks sharing same albumTitle from current results |
| `queueAlbumById(albumId)` | Fetch album by ID and append its tracks |
| `removeQueueIndex(index)` | Remove track from queue |
| `applyQueueReorder()` | Apply drag-and-drop reorder |
| `clearQueue()` | Empty the queue |
| `renderQueue()` | Re-render queue panel HTML |
| `saveQueueLocal()` | Persist queue to localStorage |
| `loadQueueLocal()` | Load queue from localStorage |
---
## Shareable URL Routing Plan
### Current State
The music page has **no URL routing**. All view state is ephemeral — refreshing the page loses the current search, drill-down, and results context. The only persistent state is the queue, saved to localStorage.
### Goal
Update the browser URL as the user navigates so that:
- URLs are shareable — sending someone a link opens the same album, artist, or search
- Browser back/forward buttons work naturally
- Page refresh restores the current view
- The URL is human-readable
### URL Scheme
Use **hash-based routing** (`#/path`) to avoid server-side configuration. The music page lives at `music.html`, so all routes are fragments after that.
| View | URL Pattern | Example |
|------|-------------|---------|
| Home / empty | `music.html` or `music.html#/` | `music.html` |
| Search results | `music.html#/search/{query}` | `music.html#/search/radiohead` |
| Album detail | `music.html#/album/{albumId}` | `music.html#/album/12345678` |
| Artist detail | `music.html#/artist/{artistId}` | `music.html#/artist/87654321` |
| Track detail | `music.html#/track/{trackId}` | `music.html#/track/99887766` |
| My playlist | `music.html#/playlist/{identifier}` | `music.html#/playlist/my-chill-mix-1709` |
| Friend playlist | `music.html#/playlist/{pubkey}/{identifier}` | `music.html#/playlist/ab12cd34.../jazz-vibes` |
### Route Flow
```mermaid
flowchart TB
PageLoad[Page loads] --> ParseHash[parseRoute from hash]
ParseHash --> RouteSwitch{Route type?}
RouteSwitch -->|empty or /| HomeView[Show empty search view]
RouteSwitch -->|/search/query| SearchView[Run search, show unified results]
RouteSwitch -->|/album/id| AlbumView[Fetch album, show album detail]
RouteSwitch -->|/artist/id| ArtistView[Fetch artist, show artist detail]
RouteSwitch -->|/track/id| TrackView[Insert track into queue and play]
RouteSwitch -->|/playlist/id| PlaylistView[Load playlist, show tracks]
UserAction[User clicks album/artist/searches] --> UpdateHash[Update hash via navigate]
UpdateHash --> HashChange[hashchange event fires]
HashChange --> ParseHash
BackButton[Browser back] --> HashChange
```
### Implementation Details
#### 1. Route Parser
```javascript
function parseRoute() {
const hash = window.location.hash || '';
const clean = hash.startsWith('#') ? hash.slice(1) : hash;
if (!clean || clean === '/') return { page: 'home', id: '' };
const parts = clean.split('/').filter(Boolean);
return {
page: parts[0] || 'home',
id: decodeURIComponent(parts.slice(1).join('/')),
};
}
```
#### 2. Navigation Helper
```javascript
function navigate(path) {
const finalPath = path.startsWith('/') ? path : '/' + path;
window.location.hash = '#' + finalPath;
}
```
This triggers the `hashchange` event, which calls the route handler.
#### 3. Route Handler
```javascript
async function handleRoute() {
const { page, id } = parseRoute();
if (page === 'search' && id) {
musicEls.input.value = id;
await runMusicSearch(id);
} else if (page === 'album' && id) {
await drillDownToAlbum(id);
} else if (page === 'artist' && id) {
await drillDownToArtist(id);
} else if (page === 'track' && id) {
// Insert track into queue and play
// Requires fetching track metadata first
} else if (page === 'playlist' && id) {
// Load playlist by identifier or pubkey:identifier
} else {
// Home view — show empty search
}
}
```
#### 4. Update Points — Where to Call `navigate()`
| Action | Navigate Call |
|--------|-------------|
| User submits search | `navigate('/search/' + encodeURIComponent(query))` |
| User clicks album card | `navigate('/album/' + albumId)` |
| User clicks artist card | `navigate('/artist/' + artistId)` |
| User clicks track in search results | No URL change — this is a play action, not a navigation |
| User clicks my playlist | `navigate('/playlist/' + identifier)` |
| User clicks friend playlist | `navigate('/playlist/' + pubkey + '/' + identifier)` |
| User clicks back button | `window.history.back()` — triggers hashchange |
#### 5. Initialization
On page load, after `initMusicFeature()` completes:
```javascript
window.addEventListener('hashchange', handleRoute);
await handleRoute(); // Handle initial URL
```
#### 6. Back Button Integration
The current `musicViewStack[]` and `navigateBack()` system should be replaced by browser history. Each `navigate()` call pushes a new hash entry. The browser back button pops it. The `hashchange` listener re-renders the appropriate view.
This means:
- Remove `musicViewStack[]` and `pushCurrentView()`
- Remove the custom `← Back` button (browser back handles it)
- Or keep the `← Back` button but wire it to `window.history.back()`
### What Becomes Shareable
| Entity | Shareable? | Notes |
|--------|-----------|-------|
| Album | ✅ Yes | `#/album/12345` — fetches album from API on load |
| Artist | ✅ Yes | `#/artist/67890` — fetches artist from API on load |
| Search | ✅ Yes | `#/search/radiohead` — re-runs search on load |
| Track | ✅ Yes | `#/track/11111` — fetches track, inserts into queue, plays |
| My playlist | ⚠️ Partial | `#/playlist/my-mix` — only works if playlist is in localStorage |
| Published playlist | ✅ Yes | `#/playlist/pubkey/identifier` — fetches from Nostr relays |
| Queue state | ❌ No | Queue is local-only, not in URL |
### Implementation Steps
1. Add `parseRoute()` and `navigate()` helper functions
2. Add `handleRoute()` async function that dispatches to the correct view
3. Wire `hashchange` listener and initial route handling in `initMusicFeature()`
4. Update `runMusicSearch()` to call `navigate('/search/...')` instead of just rendering
5. Update `drillDownToAlbum()` to call `navigate('/album/...')` instead of direct render
6. Update `drillDownToArtist()` to call `navigate('/artist/...')` instead of direct render
7. Update playlist click handlers to call `navigate('/playlist/...')`
8. Replace `musicViewStack` back navigation with `window.history.back()`
9. Add track route handler that fetches metadata and inserts into queue
10. Add playlist route handler that loads from localStorage or fetches from Nostr
---
## Queue-Centric Playback
### Philosophy
The music page is a **play-from-queue-only** app. Nothing plays directly — every play action inserts into the queue first, then plays from the queue. The queue is the single visual and logical source of truth for what is playing and what comes next.
### Final Behavior
| Action | Behavior |
|--------|----------|
| Click track in search/artist/album results | Insert at **top of queue** (index 0), then play index 0 |
| `#/track/{id}` route | Insert resolved track at **top of queue** (index 0), then play index 0 |
| Click album cover in album detail | Prepend album tracks to queue, then play index 0 |
| Playlist ▶ button | Prepend playlist tracks to queue, then play index 0 |
| +Q on track | Append to end of queue, no playback change |
| +Q on album | Append album tracks to end of queue, no playback change |
| Playlist +Q button | Append playlist tracks to end of queue, no playback change |
All audible playback starts via queue state (`musicQueue[]` + `queueCurrentIndex`) and not from detached direct-play state.
### Layout Change: Player Controls Move to Queue Panel
Currently the player bar (cover art, prev/play/next, progress) lives at the bottom of `#musicMain`. Since the queue is the center of playback, the player controls should live at the bottom of `#musicQueuePanel` instead.
#### Before
```
┌──────────┐ ┌──────────────────────┐ ┌──────────────┐
│ Playlists│ │ Search + Results │ │ QUEUE │
│ │ │ │ │ track A │
│ │ │ │ │ track B * │
│ │ │ │ │ track C │
│ │ ├───────────────────────┤ │ │
│ │ │ ◀ ▶ ▶▶ ━━━━━━━━━━━ │ │ Play Clear │
└──────────┘ └───────────────────────┘ └──────────────┘
```
#### After
```
┌──────────┐ ┌──────────────────────┐ ┌──────────────┐
│ Playlists│ │ Search + Results │ │ QUEUE │
│ │ │ │ │ track A │
│ │ │ │ │ track B * │
│ │ │ │ │ track C │
│ │ │ │ ├──────────────┤
│ │ │ │ │ 🔁 🔀 │
│ │ │ │ │ ◀ ▶ ▶▶ │
│ │ │ │ │ ━━━━━━━━━━━ │
│ │ │ │ │ 0:42 / 3:21 │
└──────────┘ └───────────────────────┘ └──────────────┘
```
The player bar is removed from `#musicMain` and its contents are placed at the bottom of `#musicQueuePanel`, below the queue list. The queue panel uses `flex-direction: column` with the queue list taking `flex: 1; overflow: auto` and the player controls pinned at the bottom with `flex-shrink: 0`.
### Playback Modes
`playbackMode` has four values:
| Mode | Icon | Behavior on Track End |
|------|------|-----------------------|
| `normal` | — | Advance to next track. Stop after last track. |
| `loop-track` | 🔂 | Replay the same track from the beginning. |
| `loop-queue` | 🔁 | Advance to next track. Wrap from last to first. |
| `shuffle` | 🔀 | Pick a random track from the queue (not the current one). |
#### State
```javascript
let playbackMode = 'normal'; // 'normal' | 'loop-track' | 'loop-queue' | 'shuffle'
```
Persisted to localStorage with queue payload (`music-queue:{pubkey}`) as:
```json
{
"currentIndex": 3,
"playbackMode": "shuffle",
"items": [ ... ]
}
```
#### Toggle Behavior
A single mode button cycles on click:
```
normal → loop-queue → loop-track → shuffle → normal
```
The button label/icon updates to reflect the current mode.
#### onEnded Logic
On track end (`musicPlayer.onEnded`):
- `loop-track`: replay current index
- `shuffle`: play random index (prefer different from current when possible)
- `loop-queue`: play next index with wraparound
- `normal`: play next index without wraparound; stop at queue end
#### UI Buttons
Mode button UI:
```html
<button id="musicPlaybackModeBtn" class="btn musicControlBtn" type="button" title="Playback mode"></button>
```
Label mapping:
- `normal``—`
- `loop-queue``🔁`
- `loop-track``🔂`
- `shuffle``🔀`
### Queue Panel Layout (Final)
- Player bar was moved from `#musicMain` to bottom of `#musicQueuePanel`
- Queue list is scrollable and takes remaining vertical space (`flex: 1; min-height: 0; overflow: auto`)
- Player controls stay pinned at bottom of queue panel

3
greyscale/.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "monochrome"]
path = monochrome
url = https://github.com/monochrome-music/monochrome.git

250
greyscale/README.md Normal file
View File

@@ -0,0 +1,250 @@
# Greyscale
Greyscale is a JavaScript API/runtime project whose primary source lives in [`src`](src), while [`greyscale-app`](greyscale-app) is retained as a legacy demo.
## Project Scope
- Primary runtime source: [`src`](src)
- Primary API module: [`src/api.js`](src/api.js)
- Legacy demo (kept for reference): [`greyscale-app`](greyscale-app)
## Current Structure
```text
.
├── src/
│ ├── api.js
│ ├── app.js
│ ├── cache.js
│ ├── downloads.js
│ ├── lyrics.js
│ ├── player.js
│ └── utils.js
├── greyscale-app/ # legacy demo
├── monochrome/ # upstream reference submodule
└── server.js
```
## Run
```bash
node server.js
# open http://127.0.0.1:12345
```
---
## Testing
Run unit tests (offline/mocked only):
```bash
node tests/test.js
```
Run unit + live integration tests (real service calls):
```bash
node tests/test.js --live
```
Notes:
- The `--live` mode calls real uptime workers, real API instances, and `lrclib.net`.
- Some live checks may be skipped when upstream data is unavailable (for example missing IDs from search results).
- The summary at the end reports pass/fail/skip totals for unit and live suites separately.
---
## API Documentation
### `GreyscaleAPI` ([`src/api.js`](src/api.js))
Create an API instance:
```js
import { GreyscaleAPI } from './src/api.js';
const api = new GreyscaleAPI();
await api.initInstances();
```
#### Instance + transport
- `initInstances()`
- Loads healthy API and streaming instance pools from uptime workers.
- Falls back to static instance lists when workers fail.
- `fetchFromPool(poolType, path, options?)`
- Internal transport with failover across instance pool.
- `poolType`: `'api' | 'streaming'`
#### Search endpoints
- `searchTracks(query)`
- Endpoint: `/search/?s={query}`
- Returns paged object:
- `items: Track[]`
- `limit`, `offset`, `totalNumberOfItems`
- `searchAlbums(query)`
- Endpoint: `/search/?al={query}`
- Returns paged object with deduplicated album results.
- `searchArtists(query)`
- Endpoint: `/search/?a={query}`
- Returns paged object of artists.
#### Detail endpoints
- `getAlbum(id)`
- Endpoint: `/album/?id={id}`
- Returns:
- `{ album, tracks }`
- Handles album track pagination (`offset/limit`) for large albums.
- `getArtist(artistId, options?)`
- Endpoints:
- `/artist/?id={id}`
- `/artist/?f={id}&skip_tracks=true`
- Returns normalized artist profile including:
- `albums`
- `eps`
- `tracks` (top tracks)
- `getTrackMetadata(id)`
- Endpoint: `/info/?id={id}`
- Returns a normalized track object.
- `getTrackRecommendations(id)`
- Endpoint: `/recommendations/?id={id}`
- Returns: `Track[]`
#### Streaming + media
- `getTrackStream(id, quality = 'HI_RES_LOSSLESS')`
- Endpoint: `/track/?id={id}&quality={quality}`
- Returns:
- `{ streamUrl, isDash }`
- Handles direct URLs and manifest decoding.
- `downloadTrackBlob(id, quality = 'LOSSLESS', onProgress?)`
- Downloads stream into a `Blob`.
- Returns:
- `{ blob, filename, track }`
- Progress callback shape:
- `{ receivedBytes, totalBytes? }`
#### Artwork helpers
- `getCoverUrl(coverId, size = 320)`
- Builds Tidal image URL for album/track cover ids.
- `getArtistPictureUrl(id, size = 320)`
- Builds Tidal image URL for artist image ids.
#### Cache helpers
- `clearCache()`
- Clears API cache.
- `getCacheStats()`
- Returns cache stats object from `APICache`.
---
### `APICache` ([`src/cache.js`](src/cache.js))
```js
import { APICache } from './src/cache.js';
```
- Constructor:
- `new APICache({ maxSize = 250, ttl = 20min } = {})`
- Methods:
- `get(namespace, key)`
- `set(namespace, key, value, ttl?)`
- `clearExpired()`
- `clear()`
- `getCacheStats()`
Uses namespace-prefixed keys and timestamp-based TTL expiration.
---
### `DownloadManager` ([`src/downloads.js`](src/downloads.js))
```js
import { DownloadManager } from './src/downloads.js';
```
- Constructor:
- `new DownloadManager(api)`
- Methods:
- `setOnChange(callback)`
- `getTasks()`
- `clearFinished()`
- `cancel(taskId)`
- `downloadTrack(track, quality?)`
- `downloadAlbum(album, tracks, quality?)`
Task status lifecycle:
- `queued -> downloading -> done`
- error path: `error`
- cancel path: `cancelled`
---
### `LyricsManager` ([`src/lyrics.js`](src/lyrics.js))
```js
import { LyricsManager } from './src/lyrics.js';
```
- Constructor:
- `new LyricsManager()`
- Methods:
- `fetchLyrics(track)`
- Fetches from LRCLIB and caches by track key.
- `getCurrentLineIndex(currentTime, lines)`
- Binary search for active synced line.
- `renderLyrics(payload, activeIndex?)`
- Produces renderable HTML for synced/plain lyrics.
Internal helper:
- LRC parsing into `{ time, text }[]`.
---
### `SimplePlayer` ([`src/player.js`](src/player.js))
```js
import { SimplePlayer } from './src/player.js';
```
- Constructor:
- `new SimplePlayer({ audio, progressEl, currentTimeEl, durationEl })`
- Core methods:
- `setResolver(resolveStreamFn)`
- `setQueue(tracks, startIndex?)`
- `getCurrentTrack()`
- `playCurrent(resolverOverride?)`
- `playNext(resolverOverride?)`
- `playPrev(resolverOverride?)`
- `togglePlayPause()`
- Events/callbacks:
- `onTrackChanged(track)`
- `onTimeUpdate(current, duration, track)`
Supports direct audio URLs and DASH playback via `dash.js`.
---
## Notes
- [`src/app.js`](src/app.js) is the integration shell (routing + UI wiring) for runtime modules.
- [`greyscale-app`](greyscale-app) is intentionally preserved as demo history.

View File

@@ -0,0 +1,235 @@
# Greyscale App
Minimal vanilla JS library for searching and playing lossless music via Tidal-compatible API instances. Zero dependencies (except dash.js CDN for DASH stream playback). No build tools, no npm, no frameworks.
## Files
| File | Purpose | Dependencies | Reusable? |
|------|---------|-------------|-----------|
| `js/api.js` | Search tracks, resolve stream URLs, get cover art URLs, manage API instances | **None** | ✅ Yes — this is the core library |
| `js/player.js` | Wrap `<audio>` element + dash.js for playback with queue, prev/next | `dashjs` global (CDN) | ✅ Yes — optional playback helper |
| `js/app.js` | Wire search UI → API → player for this PoC demo | `api.js`, `player.js` | ❌ No — PoC-specific glue code |
| `index.html` | Demo page with search box, results list, player bar | All of the above | ❌ No — PoC demo only |
| `style.css` | Dark theme styling for the demo | None | ❌ No — PoC demo only |
## Using in Another Project
### Minimum: Just the API (1 file)
Copy `js/api.js` into your project. It has **zero imports** and works in any browser ES module context.
```html
<script type="module">
import { GreyscaleAPI } from './js/api.js';
const api = new GreyscaleAPI();
// Load API instances (call once on startup)
await api.initInstances();
// Search for tracks
const tracks = await api.searchTracks('Bohemian Rhapsody');
console.log(tracks);
// Each track: { id, title, artist, duration, cover, albumTitle, raw }
// Get a stream URL for a track
const { streamUrl, isDash } = await api.getTrackStream(tracks[0].id, 'HI_RES_LOSSLESS');
// streamUrl: direct FLAC/MP3 URL or blob: URL for DASH manifest
// isDash: true if it's a DASH manifest (needs dash.js to play)
// Get cover art URL
const coverUrl = api.getCoverUrl(tracks[0].cover, 320);
// Returns: https://resources.tidal.com/images/.../320x320.jpg
</script>
```
### With Playback: API + Player (2 files)
Copy `js/api.js` and `js/player.js`. Load dash.js from CDN.
```html
<audio id="audio-player"></audio>
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
<script type="module">
import { GreyscaleAPI } from './js/api.js';
import { SimplePlayer } from './js/player.js';
const api = new GreyscaleAPI();
await api.initInstances();
const player = new SimplePlayer({
audio: document.getElementById('audio-player'),
progressEl: document.getElementById('progress'), // optional range input
currentTimeEl: document.getElementById('current-time'), // optional span
durationEl: document.getElementById('duration'), // optional span
});
// Set up stream resolver
player.setResolver(async (track) => {
return api.getTrackStream(track.id, 'HI_RES_LOSSLESS');
});
// Search and play
const tracks = await api.searchTracks('Bohemian Rhapsody');
player.setQueue(tracks, 0);
await player.playCurrent();
// Controls
player.togglePlayPause();
player.playNext();
player.playPrev();
</script>
```
## API Reference
### `GreyscaleAPI`
#### `new GreyscaleAPI()`
Creates a new API instance with fallback Tidal instances pre-configured.
#### `async initInstances()`
Fetches the latest list of healthy API instances from uptime workers. Call once on startup. Falls back to hardcoded instances if fetch fails.
#### `async searchTracks(query)`
Search for tracks. Returns an array of track objects:
```js
{
id: 12345, // Tidal track ID
title: 'Song Name',
artist: 'Artist Name',
duration: 245, // seconds
cover: 'uuid-string', // pass to getCoverUrl()
albumTitle: 'Album',
raw: { ... } // full Tidal API response object
}
```
#### `async getTrackStream(id, quality)`
Get a playable stream URL for a track ID.
Quality options: `'HI_RES_LOSSLESS'`, `'LOSSLESS'`, `'HIGH'`, `'LOW'`
Returns: `{ streamUrl: string, isDash: boolean }`
- If `isDash` is `true`, the URL is a blob: URL pointing to a DASH manifest — use dash.js to play it
- If `isDash` is `false`, the URL is a direct audio file URL — set it as `audio.src`
#### `getCoverUrl(coverId, size)`
Convert a cover ID to a Tidal CDN image URL.
- `size`: pixel dimension (e.g., `160`, `320`, `640`, `1280`)
### `SimplePlayer`
#### `new SimplePlayer({ audio, progressEl, currentTimeEl, durationEl })`
- `audio`: an `<audio>` DOM element (required)
- `progressEl`: a `<input type="range">` for progress scrubbing (optional)
- `currentTimeEl`: a DOM element to display current time (optional)
- `durationEl`: a DOM element to display total duration (optional)
#### `setResolver(fn)`
Set the async function that resolves a track object to `{ streamUrl, isDash }`.
#### `setQueue(tracks, startIndex)`
Set the playback queue and starting position.
#### `async playCurrent(resolverOverride?)`
Play the current track in the queue.
#### `async playNext()` / `async playPrev()`
Advance or rewind in the queue and play.
#### `async togglePlayPause()`
Toggle audio playback.
#### `onTrackChanged`
Callback property — set to a function that receives the new track object when playback changes.
---
## Running the PoC Demo
```bash
node server.js
# Open http://127.0.0.1:12345
```
Or without the server script:
```bash
cd greyscale-app
python3 -m http.server 8080
# Open http://127.0.0.1:8080
```
---
## Upstream Sync: Keeping Up with Monochrome
The `monochrome/` directory is a **git submodule** pointing to [github.com/monochrome-music/monochrome](https://github.com/monochrome-music/monochrome). It serves as the **reference implementation** — we don't import from it at runtime, but we use it to track upstream API changes.
### For Agents: Automated Upstream Sync Procedure
When tasked with updating this project to match upstream Monochrome changes, follow these steps:
#### Step 1: Pull Latest Upstream
```bash
git submodule update --remote --merge monochrome
```
#### Step 2: Check for API-Relevant Changes
Only these upstream files affect our code:
| Upstream File | What to Check | Our File Affected |
|--------------|---------------|-------------------|
| `monochrome/js/storage.js` | Instance URLs (lines 4-7), fallback instances (lines 60-83) | `js/api.js``UPTIME_URLS` and `FALLBACK_INSTANCES` |
| `monochrome/js/api.js` | Search endpoints (lines 325-415), track/stream endpoint (lines 1075-1109), manifest decoding (lines 266-289), cover URL format | `js/api.js``searchTracks()`, `getTrackStream()`, `decodeManifest()`, `getCoverUrl()` |
| `monochrome/js/qobuz-api.js` | Qobuz search/stream patterns (if adding Qobuz support) | Not yet implemented |
Run a targeted diff:
```bash
git -C monochrome diff HEAD~10..HEAD -- js/storage.js js/api.js js/qobuz-api.js
```
#### Step 3: Classify the Change
- **Instance URL changes** (uptime worker URLs, fallback instance list) → Update `UPTIME_URLS` and `FALLBACK_INSTANCES` in `js/api.js`
- **Search endpoint format changes** (e.g., `/search/?s=` becomes `/v2/search/?q=`) → Update `searchTracks()` in `js/api.js`
- **Track/stream endpoint changes** → Update `getTrackStream()` in `js/api.js`
- **Manifest format changes** → Update `decodeManifest()` in `js/api.js`
- **Cover URL pattern changes** → Update `getCoverUrl()` in `js/api.js`
- **Internal refactors** (caching, retry logic, error handling) → Usually no action needed
- **New features** (new search types, new providers) → Optionally add to `js/api.js`
#### Step 4: Apply Changes
All changes go into **one file**: `js/api.js`. The player module (`js/player.js`) is independent of upstream.
#### Step 5: Validate
1. Run `node --check ./greyscale-app/js/api.js` to verify syntax
2. Start the server: `node server.js`
3. Open `http://127.0.0.1:12345`
4. Search for a track and verify results appear
5. Click a track and verify playback starts
6. Test next/prev buttons
#### Step 6: Commit
```bash
git add monochrome greyscale-app/js/api.js
git commit -m "Sync with upstream monochrome: [describe changes]"
```
### Key Patterns to Watch
These are the specific code patterns in upstream that our `js/api.js` mirrors:
| Pattern | Upstream Location | Our Implementation |
|---------|------------------|-------------------|
| Instance list fetch | `storage.js``apiSettings.loadInstancesFromGitHub()` | `api.js``initInstances()` |
| Search tracks | `api.js``searchTracks()` using `/search/?s=` | `api.js``searchTracks()` |
| Get track stream | `api.js``getTrack()` using `/track/?id=&quality=` | `api.js``getTrackStream()` |
| Manifest decode | `api.js``extractStreamUrlFromManifest()` | `api.js``decodeManifest()` |
| Cover URL | `api.js``getCoverUrl()` using `resources.tidal.com` | `api.js``getCoverUrl()` |
| Instance retry | `api.js``fetchWithRetry()` with random instance selection | `api.js``fetchFromPool()` |

View File

@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Greyscale PoC</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main class="app">
<header class="search-header">
<h1>Greyscale PoC</h1>
<form id="search-form" autocomplete="off">
<input id="search-input" type="text" placeholder="Search songs..." required />
<button id="search-button" type="submit">Search</button>
</form>
<p id="status-text" class="status">Ready.</p>
</header>
<section class="results-section">
<ul id="results-list" class="results-list"></ul>
</section>
</main>
<footer class="player-bar">
<div class="player-meta">
<img id="player-cover" alt="cover" src="" />
<div>
<div id="player-title">No track selected</div>
<div id="player-artist">-</div>
</div>
</div>
<div class="player-controls">
<button id="prev-btn" type="button"></button>
<button id="play-pause-btn" type="button"></button>
<button id="next-btn" type="button">▶▶</button>
</div>
<div class="progress-wrap">
<span id="current-time">0:00</span>
<input id="progress" type="range" min="0" max="100" step="0.1" value="0" />
<span id="duration">0:00</span>
</div>
</footer>
<audio id="audio-player" preload="metadata"></audio>
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
<script type="module" src="./js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,184 @@
const UPTIME_URLS = [
'https://tidal-uptime.jiffy-puffs-1j.workers.dev/',
'https://tidal-uptime.props-76styles.workers.dev/',
];
const FALLBACK_INSTANCES = {
api: [
'https://eu-central.monochrome.tf',
'https://us-west.monochrome.tf',
'https://arran.monochrome.tf',
'https://triton.squid.wtf',
'https://api.monochrome.tf',
],
streaming: ['https://arran.monochrome.tf', 'https://triton.squid.wtf', 'https://us-west.monochrome.tf'],
};
function shuffle(arr) {
const copy = [...arr];
for (let i = copy.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
function normalizeItems(value) {
if (!value || typeof value !== 'object') return [];
if (Array.isArray(value.items)) return value.items;
for (const nested of Object.values(value)) {
if (nested && typeof nested === 'object' && Array.isArray(nested.items)) {
return nested.items;
}
}
return [];
}
function decodeManifest(manifest) {
const decoded = atob(manifest);
if (decoded.includes('<MPD')) {
const blob = new Blob([decoded], { type: 'application/dash+xml' });
return URL.createObjectURL(blob);
}
try {
const parsed = JSON.parse(decoded);
if (parsed?.urls?.[0]) return parsed.urls[0];
} catch {
// fall through
}
const match = decoded.match(/https?:\/\/[\w\-.~:?#[@!$&'()*+,;=%/]+/);
return match ? match[0] : null;
}
export class GreyscaleAPI {
constructor() {
this.instances = { ...FALLBACK_INSTANCES };
this.lastDashBlobUrl = null;
}
async initInstances() {
let loaded = null;
for (const url of shuffle(UPTIME_URLS)) {
try {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) continue;
const data = await res.json();
const api = Array.isArray(data?.api)
? data.api.map((i) => (typeof i === 'string' ? i : i?.url)).filter(Boolean)
: [];
const streaming = Array.isArray(data?.streaming)
? data.streaming.map((i) => (typeof i === 'string' ? i : i?.url)).filter(Boolean)
: [];
if (api.length > 0) {
loaded = {
api,
streaming: streaming.length > 0 ? streaming : api,
};
break;
}
} catch {
// try next URL
}
}
if (loaded) {
this.instances = loaded;
}
return this.instances;
}
async fetchFromPool(poolType, path) {
const pool = shuffle(this.instances[poolType] || []);
let lastError = null;
for (const baseUrl of pool) {
try {
const slashPath = path.startsWith('/') ? path : `/${path}`;
const url = baseUrl.endsWith('/')
? `${baseUrl.slice(0, -1)}${slashPath}`
: `${baseUrl}${slashPath}`;
const res = await fetch(url);
if (!res.ok) {
lastError = new Error(`HTTP ${res.status} from ${baseUrl}`);
continue;
}
return await res.json();
} catch (err) {
lastError = err;
}
}
throw lastError || new Error(`No instances available for ${poolType}`);
}
async searchTracks(query) {
const q = encodeURIComponent(query.trim());
const data = await this.fetchFromPool('api', `/search/?s=${q}`);
const payload = data?.data ?? data;
const items = normalizeItems(payload);
return items.map((entry) => {
const track = entry?.item ?? entry;
return {
id: track?.id,
title: track?.title || 'Unknown title',
artist:
track?.artist?.name ||
track?.artists?.map((a) => a?.name).filter(Boolean).join(', ') ||
'Unknown artist',
duration: track?.duration || 0,
cover: track?.album?.cover || null,
albumTitle: track?.album?.title || '',
raw: track,
};
});
}
async getTrackStream(id, quality = 'HI_RES_LOSSLESS') {
if (!id) throw new Error('Track id is required');
const data = await this.fetchFromPool('streaming', `/track/?id=${id}&quality=${quality}`);
const payload = data?.data ?? data;
const entries = Array.isArray(payload) ? payload : [payload];
const info = entries.find((e) => e && typeof e === 'object' && 'manifest' in e);
const direct = entries.find((e) => e && typeof e === 'object' && typeof e.OriginalTrackUrl === 'string');
if (this.lastDashBlobUrl && this.lastDashBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.lastDashBlobUrl);
this.lastDashBlobUrl = null;
}
if (direct?.OriginalTrackUrl) {
return { streamUrl: direct.OriginalTrackUrl, isDash: false };
}
if (!info?.manifest) {
throw new Error('No stream manifest returned for track');
}
const streamUrl = decodeManifest(info.manifest);
if (!streamUrl) throw new Error('Could not resolve stream URL from manifest');
const isDash = streamUrl.startsWith('blob:');
if (isDash) this.lastDashBlobUrl = streamUrl;
return { streamUrl, isDash };
}
getCoverUrl(coverId, size = 320) {
if (!coverId || typeof coverId !== 'string') return '';
return `https://resources.tidal.com/images/${coverId.replace(/-/g, '/')}/${size}x${size}.jpg`;
}
}

View File

@@ -0,0 +1,204 @@
import { GreyscaleAPI } from './api.js';
import { SimplePlayer } from './player.js';
function formatDuration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '\u0026amp;')
.replace(/</g, '\u0026lt;')
.replace(/>/g, '\u0026gt;')
.replace(/"/g, '\u0026quot;')
.replace(/\u0027/g, '\u0026#39;');
}
const els = {
form: document.getElementById('search-form'),
input: document.getElementById('search-input'),
button: document.getElementById('search-button'),
status: document.getElementById('status-text'),
results: document.getElementById('results-list'),
audio: document.getElementById('audio-player'),
playPauseBtn: document.getElementById('play-pause-btn'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
playerCover: document.getElementById('player-cover'),
playerTitle: document.getElementById('player-title'),
playerArtist: document.getElementById('player-artist'),
currentTime: document.getElementById('current-time'),
duration: document.getElementById('duration'),
progress: document.getElementById('progress'),
};
const api = new GreyscaleAPI();
const player = new SimplePlayer({
audio: els.audio,
progressEl: els.progress,
currentTimeEl: els.currentTime,
durationEl: els.duration,
});
let tracks = [];
function setStatus(text, type = 'neutral') {
els.status.textContent = text;
els.status.dataset.type = type;
}
function setSearchLoading(loading) {
els.button.disabled = loading;
els.input.disabled = loading;
els.button.textContent = loading ? 'Searching...' : 'Search';
}
function setPlayButtonState() {
els.playPauseBtn.textContent = els.audio.paused ? '▶' : '❚❚';
}
function updatePlayerMeta(track) {
if (!track) {
els.playerTitle.textContent = 'No track selected';
els.playerArtist.textContent = '-';
els.playerCover.src = '';
return;
}
els.playerTitle.textContent = track.title;
els.playerArtist.textContent = track.artist;
const cover = api.getCoverUrl(track.cover, 320);
els.playerCover.src = cover || '';
}
function renderResults(items) {
if (!items.length) {
els.results.innerHTML = '<li class="result-empty">No tracks found.</li>';
return;
}
els.results.innerHTML = items
.map(
(track, index) => `
<li class="result-item" data-index="${index}">
<img class="result-cover" src="${escapeHtml(api.getCoverUrl(track.cover, 160))}" alt="" loading="lazy" />
<div class="result-meta">
<div class="result-title">${escapeHtml(track.title)}</div>
<div class="result-subtitle">${escapeHtml(track.artist)}${
track.albumTitle ? `${escapeHtml(track.albumTitle)}` : ''
}</div>
</div>
<div class="result-duration">${formatDuration(track.duration)}</div>
</li>
`
)
.join('');
}
async function resolveTrack(track) {
return api.getTrackStream(track.id, 'HI_RES_LOSSLESS');
}
async function playByIndex(index) {
if (!Number.isInteger(index) || index < 0 || index >= tracks.length) return;
player.setQueue(tracks, index);
try {
setStatus(`Loading: ${tracks[index].title}`, 'neutral');
await player.playCurrent(resolveTrack);
setStatus(`Playing: ${tracks[index].title}`, 'ok');
} catch (error) {
console.error(error);
setStatus(`Playback failed: ${error.message || 'Unknown error'}`, 'error');
}
}
async function runSearch(query) {
const trimmed = query.trim();
if (!trimmed) return;
setSearchLoading(true);
setStatus(`Searching for "${trimmed}"...`, 'neutral');
try {
tracks = await api.searchTracks(trimmed);
renderResults(tracks);
setStatus(`Found ${tracks.length} track${tracks.length === 1 ? '' : 's'}.`, 'ok');
} catch (error) {
console.error(error);
tracks = [];
renderResults([]);
setStatus(`Search failed: ${error.message || 'Unknown error'}`, 'error');
} finally {
setSearchLoading(false);
}
}
async function bootstrap() {
setStatus('Loading API instances...', 'neutral');
try {
await api.initInstances();
setStatus('Ready.', 'ok');
} catch (error) {
console.error(error);
setStatus('Instance loading failed, using fallback list.', 'error');
}
els.form.addEventListener('submit', async (event) => {
event.preventDefault();
await runSearch(els.input.value);
});
els.results.addEventListener('click', async (event) => {
const item = event.target.closest('.result-item');
if (!item) return;
const index = Number(item.dataset.index);
await playByIndex(index);
});
player.onTrackChanged = (track) => {
updatePlayerMeta(track);
};
player.setResolver(resolveTrack);
els.playPauseBtn.addEventListener('click', async () => {
try {
await player.togglePlayPause();
} catch (error) {
console.error(error);
setStatus(`Playback error: ${error.message || 'Unknown error'}`, 'error');
}
});
els.prevBtn.addEventListener('click', async () => {
try {
await player.playPrev();
} catch (error) {
console.error(error);
setStatus(`Previous track failed: ${error.message || 'Unknown error'}`, 'error');
}
});
els.nextBtn.addEventListener('click', async () => {
try {
await player.playNext();
} catch (error) {
console.error(error);
setStatus(`Next track failed: ${error.message || 'Unknown error'}`, 'error');
}
});
els.audio.addEventListener('play', setPlayButtonState);
els.audio.addEventListener('pause', setPlayButtonState);
setPlayButtonState();
}
bootstrap();

View File

@@ -0,0 +1,121 @@
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
export class SimplePlayer {
constructor({ audio, progressEl, currentTimeEl, durationEl }) {
this.audio = audio;
this.progressEl = progressEl;
this.currentTimeEl = currentTimeEl;
this.durationEl = durationEl;
this.queue = [];
this.currentIndex = -1;
this.currentStreamUrl = '';
this.dashPlayer = window.dashjs.MediaPlayer().create();
this.usingDash = false;
this.onTrackChanged = null;
this.resolveStreamFn = null;
this.bindAudioEvents();
}
bindAudioEvents() {
this.audio.addEventListener('timeupdate', () => {
const duration = this.audio.duration || 0;
const current = this.audio.currentTime || 0;
const value = duration > 0 ? (current / duration) * 100 : 0;
this.progressEl.value = String(value);
this.currentTimeEl.textContent = formatTime(current);
});
this.audio.addEventListener('loadedmetadata', () => {
this.durationEl.textContent = formatTime(this.audio.duration || 0);
});
this.audio.addEventListener('ended', async () => {
await this.playNext();
});
this.progressEl.addEventListener('input', () => {
const duration = this.audio.duration || 0;
if (duration <= 0) return;
const target = (Number(this.progressEl.value) / 100) * duration;
this.audio.currentTime = target;
});
}
setResolver(resolveStreamFn) {
this.resolveStreamFn = resolveStreamFn;
}
setQueue(tracks, startIndex = 0) {
this.queue = Array.isArray(tracks) ? tracks : [];
this.currentIndex = startIndex;
}
getCurrentTrack() {
if (this.currentIndex < 0 || this.currentIndex >= this.queue.length) return null;
return this.queue[this.currentIndex];
}
async playStream(streamUrl, isDash = false) {
if (this.currentStreamUrl?.startsWith('blob:') && this.currentStreamUrl !== streamUrl) {
URL.revokeObjectURL(this.currentStreamUrl);
}
if (this.usingDash) {
this.dashPlayer.reset();
this.usingDash = false;
}
this.currentStreamUrl = streamUrl;
if (isDash) {
this.dashPlayer.initialize(this.audio, streamUrl, true);
this.usingDash = true;
return;
}
this.audio.src = streamUrl;
await this.audio.play();
}
async playCurrent(resolveStreamFn = null) {
const resolver = resolveStreamFn || this.resolveStreamFn;
const track = this.getCurrentTrack();
if (!track || typeof resolver !== 'function') return;
const { streamUrl, isDash } = await resolver(track);
await this.playStream(streamUrl, isDash);
if (typeof this.onTrackChanged === 'function') {
this.onTrackChanged(track);
}
}
async playNext(resolveStreamFn = null) {
if (this.queue.length === 0) return;
this.currentIndex = (this.currentIndex + 1) % this.queue.length;
await this.playCurrent(resolveStreamFn);
}
async playPrev(resolveStreamFn = null) {
if (this.queue.length === 0) return;
this.currentIndex = (this.currentIndex - 1 + this.queue.length) % this.queue.length;
await this.playCurrent(resolveStreamFn);
}
async togglePlayPause() {
if (this.audio.paused) {
await this.audio.play();
} else {
this.audio.pause();
}
}
}

View File

@@ -0,0 +1,64 @@
export function formatDuration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
export function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
export function formatYear(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) return '';
return String(date.getFullYear());
}
export function escapeHtml(value) {
return String(value)
.replace(/&/g, '\u0026amp;')
.replace(/</g, '\u0026lt;')
.replace(/>/g, '\u0026gt;')
.replace(/"/g, '\u0026quot;')
.replace(/\u0027/g, '\u0026#39;');
}
export function getTrackArtists(track) {
if (!track || typeof track !== 'object') return 'Unknown artist';
if (track.artist?.name) return track.artist.name;
if (Array.isArray(track.artists) && track.artists.length > 0) {
return track.artists.map((a) => a?.name).filter(Boolean).join(', ') || 'Unknown artist';
}
if (typeof track.artist === 'string' && track.artist.trim()) return track.artist;
return 'Unknown artist';
}
export function getTrackTitle(track) {
return track?.title || 'Unknown title';
}
export function sanitizeForFilename(str) {
return String(str || '')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim();
}
export function buildTrackFilename(track, extension = 'flac') {
const artists = sanitizeForFilename(getTrackArtists(track));
const title = sanitizeForFilename(getTrackTitle(track));
return `${artists} - ${title}.${extension}`;
}
export function debounce(fn, wait = 250) {
let t = null;
return (...args) => {
if (t) clearTimeout(t);
t = setTimeout(() => fn(...args), wait);
};
}

View File

@@ -0,0 +1,248 @@
:root {
color-scheme: dark;
--bg: #111315;
--panel: #181b1f;
--panel-2: #20252b;
--text: #eceff4;
--muted: #9ba3af;
--accent: #7aa2f7;
--danger: #f7768e;
--ok: #9ece6a;
--border: #2c323a;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, sans-serif;
background: var(--bg);
color: var(--text);
}
body {
display: flex;
flex-direction: column;
}
.app {
width: min(980px, 100%);
margin: 0 auto;
padding: 1rem 1rem 7.5rem;
}
.search-header h1 {
margin: 0 0 0.75rem;
font-size: 1.25rem;
font-weight: 650;
}
#search-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
}
#search-input,
#search-button,
.player-controls button,
#progress {
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
}
#search-input {
padding: 0.65rem 0.75rem;
font-size: 0.95rem;
}
#search-button {
padding: 0.65rem 0.9rem;
cursor: pointer;
background: var(--panel-2);
}
#search-button:disabled {
opacity: 0.65;
cursor: wait;
}
.status {
margin: 0.65rem 0 0;
color: var(--muted);
font-size: 0.9rem;
}
.status[data-type='ok'] {
color: var(--ok);
}
.status[data-type='error'] {
color: var(--danger);
}
.results-section {
margin-top: 1rem;
}
.results-list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
background: var(--panel);
}
.result-item,
.result-empty {
display: grid;
grid-template-columns: 44px 1fr auto;
align-items: center;
gap: 0.65rem;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.result-item:last-child,
.result-empty:last-child {
border-bottom: none;
}
.result-item {
cursor: pointer;
}
.result-item:hover {
background: #1d2228;
}
.result-cover {
width: 44px;
height: 44px;
border-radius: 6px;
object-fit: cover;
background: #0f1113;
}
.result-meta {
min-width: 0;
}
.result-title,
.result-subtitle,
.result-duration {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-title {
font-size: 0.95rem;
}
.result-subtitle,
.result-duration,
.result-empty {
color: var(--muted);
font-size: 0.85rem;
}
.player-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #0f1114;
border-top: 1px solid var(--border);
display: grid;
grid-template-columns: minmax(220px, 2fr) auto minmax(180px, 3fr);
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.player-meta {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
}
#player-cover {
width: 52px;
height: 52px;
border-radius: 8px;
object-fit: cover;
background: #1a1d21;
}
#player-title,
#player-artist {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#player-title {
font-size: 0.95rem;
}
#player-artist {
color: var(--muted);
font-size: 0.85rem;
}
.player-controls {
display: flex;
gap: 0.4rem;
}
.player-controls button {
width: 2.25rem;
height: 2.25rem;
cursor: pointer;
}
.progress-wrap {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.5rem;
}
#current-time,
#duration {
color: var(--muted);
font-size: 0.8rem;
min-width: 2.4rem;
text-align: center;
}
#progress {
width: 100%;
accent-color: var(--accent);
}
@media (max-width: 860px) {
.player-bar {
grid-template-columns: 1fr;
gap: 0.6rem;
}
.player-controls {
justify-self: center;
}
.progress-wrap {
width: 100%;
}
}

View File

@@ -0,0 +1,42 @@
# ------------------------------------------------------------
# Base Image
# ------------------------------------------------------------
FROM mcr.microsoft.com/devcontainers/base:debian
# ------------------------------------------------------------
# System Dependencies
# ------------------------------------------------------------
RUN apt update && apt upgrade -y && \
apt-get install -y --no-install-recommends \
git \
git-lfs \
fish \
nodejs \
npm \
curl
# ------------------------------------------------------------
# Install Bun (Non-Root)
# ------------------------------------------------------------
ENV BUN_INSTALL="$HOME/.bun"
ENV PATH="$BUN_INSTALL/bin:$PATH"
RUN curl -fsSL https://bun.sh/install | bash
# ------------------------------------------------------------
# Install OpenCode (Proper PATH Handling)
# ------------------------------------------------------------
RUN curl -fsSL https://opencode.ai/install -o opencode-install && \
chmod +x opencode-install && \
./opencode-install --yes && \
rm opencode-install
# Add OpenCode to PATH permanently
ENV PATH="$HOME/.opencode/bin:$PATH"
# ------------------------------------------------------------
# Ensure fish is Default Shell
# ------------------------------------------------------------
ENV SHELL=/usr/bin/fish
CMD ["fish"]

View File

@@ -0,0 +1,14 @@
{
"name": "Monochrome Dev Container",
"build": {
"context": "..",
"dockerfile": "./Dockerfile"
},
"postCreateCommand": "git config --local core.editor \"code --wait\" && git config --local commit.gpgsign false && npm install && bun install",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
},
"mounts": ["source=${env:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,consistency=cached"]
}

View File

@@ -0,0 +1,32 @@
# Dependencies
node_modules
# Build output
dist
# Git
.git
.github
# IDE
.idea
.vscode
# OS
.DS_Store
# Environment
.env
.env.*
# Documentation
*.md
license
# Docker
Dockerfile*
docker-compose*.yml
.dockerignore
# Other
*.log

View File

@@ -0,0 +1,26 @@
# Monochrome Docker Configuration
# Copy to .env and edit: cp .env.example .env
# --- Monochrome ---
MONOCHROME_PORT=3000
MONOCHROME_DEV_PORT=5173
# --- Auth Gate (server-side authentication) ---
# Set AUTH_ENABLED=true to enable the auth gate entirely (login required)
AUTH_ENABLED=false
AUTH_SECRET=change-me-to-a-random-string
FIREBASE_PROJECT_ID=monochrome-database
# Optional: toggle login providers (defaults to true when unset)
# AUTH_GOOGLE_ENABLED=true
# AUTH_EMAIL_ENABLED=true
# Optional: override the Firebase config for the login page (JSON string)
# FIREBASE_CONFIG={"apiKey":"...","authDomain":"...","projectId":"...","storageBucket":"...","messagingSenderId":"...","appId":"..."}
# Optional: set PocketBase URL (hides the field in settings when set)
# POCKETBASE_URL=https://monodb.samidy.com
# SESSION_MAX_AGE=604800000 # 7 days in ms (default)
# --- PocketBase (only used with --profile pocketbase) ---
POCKETBASE_PORT=8090
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=changeme
TZ=UTC

View File

@@ -0,0 +1 @@
ko_fi: monochromemusic

View File

@@ -0,0 +1,12 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for more information:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# https://containers.dev/guide/dependabot
version: 2
updates:
- package-ecosystem: "devcontainers"
directory: "/"
schedule:
interval: weekly

View File

@@ -0,0 +1,14 @@
### Description
### Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Style/UI update
- [ ] Docs only
### Checklist
- [ ] **I have read the [Contributing Guidelines](https://github.com/monochrome-music/monochrome/blob/main/CONTRIBUTING.md).**
- [ ] **I understand every line of code I am submitting.**
- [ ] I have tested these changes locally, and they work as expected.
---
*By submitting this PR, I agree to follow the guidelines. I understand that the final decision to merge rests with the maintainers and that not all contributions can be accepted.*

View File

@@ -0,0 +1,125 @@
name: Desktop Build
on:
push:
branches: [main, neutralino]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
platform: windows
binary_source: Monochrome-win_x64.exe
binary_dest: Monochrome.exe
archive_name: monochrome-windows.zip
- os: ubuntu-latest
platform: linux
binary_source: Monochrome-linux_x64
binary_dest: Monochrome
archive_name: monochrome-linux.zip
- os: macos-latest
platform: macos
binary_source: Monochrome-mac_universal
binary_dest: Monochrome
archive_name: monochrome-mac.zip
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Download Neutralino binaries
run: bun x neu update
- name: Auto-Bump Version
run: |
$json = Get-Content neutralino.config.json -Raw | ConvertFrom-Json
$v = $json.version.Split('.')
if ($v.Count -lt 2) { $v += "0" }
$newVersion = "{0}.{1}.{2}" -f $v[0], $v[1], ${{ github.run_number }}
$json.version = $newVersion
$json | ConvertTo-Json -Depth 5 | Set-Content neutralino.config.json
shell: pwsh
- name: Build application
run: bun run build
- name: Prepare Release Folder
run: |
mkdir release
cp dist/Monochrome/resources.neu release/
cp neutralino.config.json release/
cp -r dist/Monochrome/extensions release/
cp dist/Monochrome/${{ matrix.binary_source }} release/${{ matrix.binary_dest }}
shell: bash
- name: Set Permissions (Linux/macOS)
if: matrix.platform != 'windows'
run: chmod +x release/${{ matrix.binary_dest }}
- name: Zip for R2 (Windows)
if: matrix.platform == 'windows'
run: Compress-Archive -Path release/* -DestinationPath ${{ matrix.archive_name }} -Force
shell: pwsh
- name: Zip for R2 (Linux/macOS)
if: matrix.platform != 'windows'
run: |
cd release
zip -r ../${{ matrix.archive_name }} .
shell: bash
- name: Isolate Zip File
run: |
mkdir out_delivery
mv ${{ matrix.archive_name }} out_delivery/
shell: bash
- name: Generate Update Manifest
run: |
$config = Get-Content neutralino.config.json | ConvertFrom-Json
$version = $config.version
$platform = "${{ matrix.platform }}"
$archive = "${{ matrix.archive_name }}"
$json = @{
version = $version
url = "https://downloads.samidy.com/out_delivery/$archive"
notes = "Update $version is available."
}
$json | ConvertTo-Json | Set-Content "out_delivery/update-$platform.json"
shell: pwsh
- name: Upload to R2
uses: ryand56/r2-upload-action@latest
with:
r2-account-id: ${{ secrets.R2_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }}
r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }}
r2-bucket: ${{ secrets.R2_BUCKET }}
source-dir: 'out_delivery'
destination-dir: 'out_delivery'
- name: Upload Artifact (GH Internal)
uses: actions/upload-artifact@v4
with:
name: Monochrome-${{ matrix.platform }}
path: release/
retention-days: 30

View File

@@ -0,0 +1,60 @@
name: Lint Codebase
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: write
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Cache dependencies
uses: actions/cache@v3
with:
path: |
./bun_modules
./node_modules
./bun.lock
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run JS Lint
run: bun run lint:js -- --fix
continue-on-error: true
- name: Run CSS Lint
run: bun run lint:css -- --fix
continue-on-error: true
- name: Format with Prettier
run: bun run format
continue-on-error: true
- name: Commit and Push lint fixes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: 'style: auto-fix linting issues'
commit_user_name: 'github-actions[bot]'
commit_user_email: 'github-actions[bot]@users.noreply.github.com'
only_if_changed: true
- name: Run HTML Lint
run: bun run lint:html

View File

@@ -0,0 +1,43 @@
name: Update Lock File
on:
workflow_dispatch:
permissions:
contents: write
jobs:
update-lock:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Configure Git
uses: DanTheMan827/config-git-user-action@v1
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Cache dependencies
uses: actions/cache@v3
with:
path: |
./bun_modules
./node_modules
./bun.lock
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
- name: Install dependencies
run: bun install
- name: Commit changes
run: |
git add -A . || true
git commit "update lockfile" || true
git push || true

BIN
greyscale/monochrome/.gitignore vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,3 @@
dist/
node_modules/
legacy/

View File

@@ -0,0 +1,12 @@
{
"tag-pair": true,
"tagname-lowercase": true,
"attr-lowercase": true,
"attr-value-double-quotes": true,
"doctype-first": true,
"id-unique": true,
"src-not-empty": true,
"alt-require": true,
"head-script-disabled": false,
"spec-char-escape": true
}

View File

@@ -0,0 +1,6 @@
dist/
node_modules/
legacy/
package-lock.json
*.min.js
.github/

View File

@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "es5",
"printWidth": 120,
"endOfLine": "auto"
}

View File

@@ -0,0 +1,12 @@
{
"extends": "stylelint-config-standard",
"rules": {
"no-empty-source": null,
"selector-class-pattern": null,
"media-feature-range-notation": null,
"declaration-block-no-redundant-longhand-properties": null,
"color-function-notation": null,
"alpha-value-notation": null
},
"ignoreFiles": ["dist/**/*.css", "node_modules/**/*.css", "legacy/**/*.css", "www/**/*.css"]
}

23
greyscale/monochrome/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "build",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": [],
"label": "npm: build",
"detail": "vite build"
},
{
"type": "npm",
"script": "dev",
"problemMatcher": [],
"label": "npm: dev",
"detail": "vite"
}
]
}

View File

@@ -0,0 +1,57 @@
# Global Auth Gate
This document explains the optional server-side login gate and what it implies for your site.
## Overview
- When enabled, all HTML routes require login.
- Login uses Firebase Auth (Google or email) and exchanges a Firebase ID token for a server session.
- The session is stored in a signed cookie and checked on every request.
## Where it runs
- The gate runs only in `vite preview` (production-like server).
- The Vite dev server (`vite dev`) does not enable the gate.
- Static hosting cannot enforce the gate, because there is no server to verify tokens or set cookies.
## Flow
1. User requests `/` or any HTML route.
2. Server checks the `mono_session` cookie.
3. If missing, redirect to `/login`.
4. Login page signs in with Firebase and POSTs to `/api/auth/login`.
5. Server verifies the ID token and sets a session cookie.
6. User is redirected back to `/`.
## Configuration
- `AUTH_ENABLED=true` enables the gate (default is false).
- `AUTH_SECRET` is required when the gate is enabled. It signs the session cookie.
- `AUTH_GOOGLE_ENABLED` toggles Google sign-in on `/login` (default true).
- `AUTH_EMAIL_ENABLED` toggles email/password sign-in on `/login` (default true).
- `FIREBASE_PROJECT_ID` sets the Firebase project used to verify tokens.
- `FIREBASE_CONFIG` (JSON) injects config into the login page.
- `POCKETBASE_URL` hides the custom DB setting field.
- `SESSION_MAX_AGE` sets cookie lifetime in ms (default 7 days).
## Implications for the site
- Requires a server runtime. Pure static hosting will not force login.
- Unauthenticated requests to non-HTML assets return 401.
- `/login` and `/login.html` remain accessible to start the flow.
- Logging out clears the session and redirects to `/login`.
- Authenticated visits to `/login` redirect back to `/`.
## Enable (Docker)
1. `cp .env.example .env`
2. Set `AUTH_ENABLED=true` and `AUTH_SECRET=...`
3. Optionally set `FIREBASE_CONFIG` and `FIREBASE_PROJECT_ID`
4. `docker compose up -d`
5. Visit `http://localhost:3000`
## Enable (local preview)
1. `npm run build`
2. Set env vars in your shell or `.env`
3. `npm run preview`

View File

@@ -0,0 +1,342 @@
# Contributing to Monochrome
Thank you for your interest in contributing to Monochrome! This guide will help you get started with development, understand our codebase, and follow our contribution workflow.
---
## Table of Contents
- [Development Setup](#development-setup)
- [Code Quality](#code-quality)
- [Project Structure](#project-structure)
- [Before You Contribute](#before-you-contribute)
- [Contributing Workflow](#contributing-workflow)
- [Commit Message Guidelines](#commit-message-guidelines)
- [Deployment](#deployment)
- [Questions?](#questions)
---
## Development Setup
### Prerequisites
- [Node.js](https://nodejs.org/) (Version 20+ or 22+ recommended)
- [Bun](https://bun.sh/) (preferred) or [npm](https://www.npmjs.com/)
### Quick Start
1. **Fork and clone the repository:**
```bash
git clone https://github.com/YOUR_USERNAME/monochrome.git
cd monochrome
```
2. **Install dependencies:**
```bash
bun install
# or
npm install
```
3. **Start the development server:**
```bash
bun run dev
# or
npm run dev
```
4. **Open your browser:**
Navigate to `http://localhost:5173/`
---
## Code Quality
We maintain high code quality standards. All code must pass our linting checks before being merged.
### Our Tool Stack
| Tool | Purpose | Files |
| ---------------------------------- | ------------------ | -------- |
| [ESLint](https://eslint.org/) | JavaScript linting | `*.js` |
| [Stylelint](https://stylelint.io/) | CSS linting | `*.css` |
| [HTMLHint](https://htmlhint.com/) | HTML validation | `*.html` |
| [Prettier](https://prettier.io/) | Code formatting | All |
### Available Commands
```bash
# Check everything (runs all linters)
bun run lint
# Auto-format all code
bun run format
# Fix JavaScript issues automatically
bun run lint:js -- --fix
# Fix CSS issues automatically
bun run lint:css -- --fix
# Check HTML
bun run lint:html
# Check specific file types
bun run lint:js
bun run lint:css
```
> ⚠️ **Important:** A GitHub Action automatically runs `bun run lint` on every push and pull request. Please ensure all checks pass before committing.
---
## Project Structure
```
monochrome/
├── 📁 js/ # Application source code
│ ├── components/ # UI components
│ ├── utils/ # Utility functions
│ ├── api/ # API integration
│ └── ...
├── 📁 public/ # Static assets
│ ├── assets/ # Images, icons, fonts
│ ├── manifest.json # PWA manifest
│ └── instances.json # API instances configuration (deprecated)
├── 📄 index.html # Application entry point
├── 📄 vite.config.js # Build and PWA configuration
├── 📄 package.json # Dependencies and scripts
└── 📄 README.md # Project documentation
```
### Key Directories
- **`/js`** - All JavaScript source code
- Keep modules focused and single-purpose
- Use ES6+ features
- Add JSDoc comments for complex functions
- **`/public`** - Static assets copied directly to build
- Images should be optimized before adding
- Keep file sizes reasonable
- Use appropriate formats (WebP where possible)
---
## Before You Contribute
To ensure a smooth contribution process and avoid wasted effort, please adhere to the following guidelines before starting any major work.
### Consult on Major Features
If you're looking into contributing a big feature, please speak with us before starting work. You might be implementing something we are already working on, or a feature that could create more issues long-term. You can reach us via a [GitHub Issue](https://github.com/monochrome-music/monochrome/issues) or on our **[Discord](https://monochrome.tf/discord)**.
### Open Draft PRs Early
Whether you've spoken with us or not, we highly recommend opening **Draft Pull Requests** early. This allows us to catch potential issues before you spend too much time on them. Large PRs that appear suddenly are often difficult to review, and we may close them if they conflict with internal work we haven't pushed yet.
### AI as a Tool
**AS A TOOL**, AI is a great way to help you navigate our (admittedly messy) codebase or refactor logic. We actually encourage using it to speed up your workflow, but we have a zero-tolerance policy for Vibecoding.
#### Permissible (and encouraged):
- Using AI as a tutor to help you understand a specific module or issue.
- Using AI to help clean up your code or write clearer PR descriptions.
- Making sure you understand **every line** of code you submit.
- Mentioning in your PR if you used AI to help with a specific section.
#### Prohibited (AI Slop):
- **Vibecoding** the entire PR (letting AI write the code without human oversight).
- Submitting code you don't actually understand or haven't tested.
- Ignoring edge cases because the AI didn't suggest them.
> :warning:: If we can verify that a Pull Request is just unvetted AI/Vibecoded Work, **it will be automatically closed without review.** If you can't explain your code, it doesn't belong in Monochrome.
### No Hard Feelings
If we end up closing your Pull Request, please don't feel bad about it! We **really appreciate** you taking the time to help out with Monochrome.
There are a lot of reasons why we might close a PR, and most of them have nothing to do with you. It might be because:
- Were already working on the same thing behind the scenes.
- The feature doesn't quite fit where the project is headed right now.
- Were still undecided on how a certain part of the app should work.
- It doesn't quite follow the guidelines we've set here.
In short: we don't hate you, and we aren't trying to be mean. We know how much work goes into a PR, and we're grateful you chose to spend your time on our project. Even if a PR gets closed, we'd still love to have you around the community!
---
## Contributing Workflow
### 1. Create a Branch
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/description-of-fix
```
### 2. Make Your Changes
- Follow existing code style
- Write clear, self-documenting code
- Add comments for complex logic
- Update documentation if needed
### 3. Test Your Changes
```bash
# Run all linters
bun run lint
# Test the build
bun run build
```
### 4. Commit Your Changes
Follow our [commit message guidelines](#commit-message-guidelines).
```bash
git add .
git commit -m "feat(player): add keyboard shortcut for loop toggle"
```
### 5. Push and Create a Pull Request
```bash
git push origin feature/your-feature-name
```
Then open a pull request on GitHub with:
- Clear title describing the change
- Detailed description of what changed and why
- Reference any related issues
---
## Commit Message Guidelines
We use [Conventional Commits](https://www.conventionalcommits.org/) for clear, structured commit messages.
### Format
```
<type>(<scope>): <description>
[optional body]
[optional footer]
```
### Types
| Type | Description |
| ---------- | ------------------------------------------------- |
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation changes |
| `style` | Code style changes (formatting, semicolons, etc.) |
| `refactor` | Code refactoring without changing behavior |
| `perf` | Performance improvements |
| `test` | Adding or updating tests |
| `chore` | Maintenance tasks (dependencies, build, etc.) |
### Scopes
Common scopes in our project:
- `player` - Audio player functionality
- `ui` - User interface components
- `api` - API integration
- `library` - Library management
- `playlists` - Playlist functionality
- `lyrics` - Lyrics display
- `downloads` - Download functionality
- `auth` - Authentication
- `pwa` - Progressive Web App features
- `settings` - Settings/preferences
- `theme` - Theming system
### Examples
```bash
# Feature addition
feat(playlists): add shuffle playlist button
# Bug fix
fix(metadata): resolve corrupted Hi-res metadata issue
# Refactoring
refactor(downloads): simplify cancel download logic
# Documentation
docs(README): improve installation instructions
# Maintenance
chore(deps): bump lyrics package to fix vulnerability
# Style changes
style(player): fix indentation in audio controls
```
### Tips
- Use the present tense ("add feature" not "added feature")
- Use imperative mood ("move cursor to..." not "moves cursor to...")
- Don't capitalize the first letter
- No period at the end
- Keep the first line under 72 characters
📋 **Cheat Sheet:** [Conventional Commits Cheat Sheet](https://gist.github.com/Zekfad/f51cb06ac76e2457f11c80ed705c95a3)
---
## Deployment
Deployment is fully automated via **Cloudflare Pages**.
### How It Works
1. Push changes to the `main` branch
2. Cloudflare automatically builds and deploys
3. Changes are live within minutes
### Configuration Notes
The project uses a **relative base path** (`./`) in `vite.config.js`. This allows the same build artifact to work on both:
- **Cloudflare Pages** (served from root)
- **GitHub Pages** (served from `/monochrome/`)
Hash routing is used to ensure compatibility across all hosting platforms.
### Manual Deployment
If you need to deploy manually:
```bash
# Build for production
bun run build
# The `dist/` folder contains the deployable files
```
---
## Code of Conduct
- Be respectful and inclusive
- Welcome newcomers and help them learn
- Focus on constructive feedback
- Respect different viewpoints and experiences
Thank you for contributing to Monochrome!

View File

@@ -0,0 +1,284 @@
# Monochrome Design System
A comprehensive design language for consistent UI/UX across Monochrome.
## Design Tokens
### Typography Scale
| Token | Value | Usage |
| ------------- | --------------- | ---------------------------- |
| `--text-xs` | 0.75rem (12px) | Captions, badges, timestamps |
| `--text-sm` | 0.875rem (14px) | Secondary text, labels |
| `--text-base` | 1rem (16px) | Body text (default) |
| `--text-md` | 1.125rem (18px) | Lead paragraphs |
| `--text-lg` | 1.25rem (20px) | Small headings |
| `--text-xl` | 1.5rem (24px) | H4, card titles |
| `--text-2xl` | 1.875rem (30px) | H3 |
| `--text-3xl` | 2.25rem (36px) | H2 |
| `--text-4xl` | 3rem (48px) | H1 |
| `--text-5xl` | 3.75rem (60px) | Display text |
### Font Weights
| Token | Value |
| ----------------- | ----- |
| `--font-normal` | 400 |
| `--font-medium` | 500 |
| `--font-semibold` | 600 |
| `--font-bold` | 700 |
### Spacing Scale
| Token | Value | Pixels |
| ------------ | ------- | ------ |
| `--space-1` | 0.25rem | 4px |
| `--space-2` | 0.5rem | 8px |
| `--space-3` | 0.75rem | 12px |
| `--space-4` | 1rem | 16px |
| `--space-5` | 1.25rem | 20px |
| `--space-6` | 1.5rem | 24px |
| `--space-8` | 2rem | 32px |
| `--space-10` | 2.5rem | 40px |
| `--space-12` | 3rem | 48px |
| `--space-16` | 4rem | 64px |
### Border Radius Scale
| Token | Value | Usage |
| --------------- | ------ | ----------------------- |
| `--radius-none` | 0 | Sharp corners |
| `--radius-xs` | 2px | Small badges, tags |
| `--radius-sm` | 4px | Inputs, small buttons |
| `--radius-md` | 8px | Cards, panels (default) |
| `--radius-lg` | 12px | Large cards, modals |
| `--radius-xl` | 16px | Hero elements |
| `--radius-2xl` | 24px | Extra large elements |
| `--radius-full` | 9999px | Circles, pills |
### Transition Timing
| Token | Value |
| -------------------- | ----- |
| `--duration-instant` | 0ms |
| `--duration-fast` | 150ms |
| `--duration-normal` | 300ms |
| `--duration-slow` | 500ms |
### Easing Functions
| Token | Value | Usage |
| ----------------- | --------------------------------------- | --------------------- |
| `--ease-linear` | linear | Continuous animations |
| `--ease-in` | cubic-bezier(0.4, 0, 1, 1) | Entering elements |
| `--ease-out` | cubic-bezier(0, 0, 0.2, 1) | Exiting elements |
| `--ease-in-out` | cubic-bezier(0.4, 0, 0.2, 1) | Standard transitions |
| `--ease-out-back` | cubic-bezier(0.34, 1.56, 0.64, 1) | Bouncy effects |
| `--ease-elastic` | cubic-bezier(0.68, -0.55, 0.265, 1.55) | Playful animations |
| `--ease-spring` | cubic-bezier(0.175, 0.885, 0.32, 1.275) | Snappy interactions |
### Shadows
| Token | Value |
| ---------------- | ------------------------------------------------------------------- |
| `--shadow-none` | none |
| `--shadow-xs` | 0 1px 2px 0 rgb(0 0 0 / 0.05) |
| `--shadow-sm` | 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1) |
| `--shadow-md` | 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) |
| `--shadow-lg` | 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1) |
| `--shadow-xl` | 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1) |
| `--shadow-2xl` | 0 25px 50px -12px rgb(0 0 0 / 0.25) |
| `--shadow-inner` | inset 0 2px 4px 0 rgb(0 0 0 / 0.05) |
| `--shadow-glow` | 0 0 20px rgb(var(--highlight-rgb) / 0.5) |
### Z-Index Scale
| Token | Value | Usage |
| -------------- | ----- | --------------- |
| `--z-hide` | -1 | Hidden elements |
| `--z-base` | 0 | Default |
| `--z-docked` | 10 | Docked elements |
| `--z-dropdown` | 1000 | Dropdowns |
| `--z-sticky` | 1100 | Sticky headers |
| `--z-banner` | 1200 | Banners |
| `--z-overlay` | 1300 | Overlays |
| `--z-modal` | 1400 | Modals |
| `--z-popover` | 1500 | Popovers |
| `--z-tooltip` | 1600 | Tooltips |
| `--z-toast` | 1700 | Toasts |
## Component Tokens
### Buttons
| Token | Value |
| ------------------ | ----------------------------- |
| `--btn-height-sm` | 32px |
| `--btn-height-md` | 40px |
| `--btn-height-lg` | 48px |
| `--btn-padding-sm` | var(--space-2) var(--space-3) |
| `--btn-padding-md` | var(--space-3) var(--space-4) |
| `--btn-padding-lg` | var(--space-4) var(--space-6) |
### Inputs
| Token | Value |
| ----------------- | ----------------------------- |
| `--input-height` | 40px |
| `--input-padding` | var(--space-3) var(--space-4) |
### Cards
| Token | Value |
| ---------------- | ---------------- |
| `--card-padding` | var(--space-4) |
| `--card-gap` | var(--space-4) |
| `--card-radius` | var(--radius-lg) |
### Modals
| Token | Value |
| ---------------------- | ---------------- |
| `--modal-padding` | var(--space-6) |
| `--modal-radius` | var(--radius-xl) |
| `--modal-max-width-sm` | 400px |
| `--modal-max-width-md` | 500px |
| `--modal-max-width-lg` | 600px |
| `--modal-max-width-xl` | 800px |
## Utility Classes
### Typography
```css
.text-xs, .text-sm, .text-base, .text-md, .text-lg, .text-xl, .text-2xl, .text-3xl, .text-4xl
.font-normal, .font-medium, .font-semibold, .font-bold
.leading-none, .leading-tight, .leading-snug, .leading-normal, .leading-relaxed
```
### Spacing
```css
.m-0, .m-1, .m-2, .m-3, .m-4, .m-6, .m-8
.mt-0, .mt-1, .mt-2, .mt-3, .mt-4, .mt-6
.mb-0, .mb-1, .mb-2, .mb-3, .mb-4, .mb-6
.ml-0, .ml-2, .ml-4
.mr-0, .mr-2, .mr-4
.mx-0, .mx-2, .mx-4
.my-0, .my-2, .my-4
.p-0, .p-1, .p-2, .p-3, .p-4, .p-6
.px-0, .px-2, .px-3, .px-4
.py-0, .py-1, .py-2, .py-3
.gap-0, .gap-1, .gap-2, .gap-3, .gap-4, .gap-6
```
### Border Radius
```css
.rounded-none, .rounded-xs, .rounded-sm, .rounded-md, .rounded-lg, .rounded-xl, .rounded-full
```
### Shadows
```css
.shadow-none, .shadow-xs, .shadow-sm, .shadow-md, .shadow-lg, .shadow-xl
```
### Display & Flex
```css
.block, .inline-block, .inline, .flex, .inline-flex, .grid, .hidden
.flex-row, .flex-col, .flex-wrap, .flex-nowrap
.items-start, .items-center, .items-end
.justify-start, .justify-center, .justify-end, .justify-between
.flex-1, .flex-auto, .flex-none
```
### Text
```css
.text-left, .text-center, .text-right
.truncate
.line-clamp-2, .line-clamp-3
.text-muted, .text-highlight
```
### Other
```css
.cursor-pointer, .cursor-default
.transition-fast, .transition-normal, .transition-slow
```
## Best Practices
### DO:
- Use design tokens for all values
- Use utility classes for common patterns
- Keep component styles in CSS, not inline JS
- Use semantic HTML elements
- Maintain consistent spacing using the spacing scale
### DON'T:
- Use hardcoded pixel values
- Use inline styles in JavaScript
- Mix different border-radius values arbitrarily
- Skip the spacing scale with custom values
- Use arbitrary font sizes outside the type scale
## Migration Guide
### From hardcoded values:
```css
/* Before */
.element {
padding: 16px;
font-size: 14px;
border-radius: 4px;
margin-bottom: 24px;
}
/* After */
.element {
padding: var(--space-4);
font-size: var(--text-sm);
border-radius: var(--radius-sm);
margin-bottom: var(--space-6);
}
```
### From inline styles:
```javascript
// Before
element.style.cssText = 'display: flex; gap: 8px; padding: 16px;';
// After
element.classList.add('flex', 'gap-2', 'p-4');
```
## Themes
The design system supports multiple themes. Each theme defines color variables while maintaining consistent spacing, typography, and other design tokens.
Available themes:
- `monochrome` (default)
- `dark`
- `ocean`
- `purple`
- `forest`
- `mocha`
- `machiatto`
- `frappe`
- `latte`
- `white`
## Notes
- The `--highlight-rgb` variable must be in comma-separated RGB format (e.g., `245, 245, 245`) for use with `rgb()` function
- All spacing values are in rem units for accessibility
- The design system is mobile-first and responsive

View File

@@ -0,0 +1,187 @@
# Docker Deployment Guide
## Quick Start
### Monochrome Only
```bash
docker compose up -d
```
Visit `http://localhost:3000`
### With PocketBase
```bash
cp .env.example .env
# Edit .env -- set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD
docker compose --profile pocketbase up -d
```
- Monochrome: `http://localhost:3000`
- PocketBase admin: `http://localhost:8090/_/`
Configure PocketBase collections per [self-hosted-database.md](self-hosted-database.md).
### Development
```bash
docker compose --profile dev up -d
```
Visit `http://localhost:5173` (hot-reload enabled)
---
## How It Works
### Profiles
Docker Compose [profiles](https://docs.docker.com/compose/how-tos/profiles/) control which services start. A service with no profile always runs. A service with a profile only runs when that profile is activated.
| Command | What starts |
| --------------------------------------------------------- | ------------------------------------ |
| `docker compose up -d` | Monochrome |
| `docker compose --profile pocketbase up -d` | Monochrome + PocketBase |
| `docker compose --profile dev up -d` | Monochrome + Dev server |
| `docker compose --profile dev --profile pocketbase up -d` | Monochrome + Dev server + PocketBase |
In `docker-compose.yml`, it looks like this:
```yaml
services:
monochrome: # no profile -- always starts
pocketbase:
profiles: ['pocketbase'] # opt-in
monochrome-dev:
profiles: ['dev'] # opt-in
```
### Override File
Docker Compose automatically merges `docker-compose.override.yml` into `docker-compose.yml` if it exists in the same directory. No flags needed.
This is useful for forks that need to add custom services or configuration (Traefik labels, extra containers, custom networks) without modifying the base `docker-compose.yml`.
The override file does not exist in the upstream repo, don't search it!
**Example** -- adding Traefik labels to PocketBase in your fork:
```yaml
# docker-compose.override.yml
services:
pocketbase:
labels:
- traefik.enable=true
- traefik.http.routers.pocketbase.rule=Host(`pocketbase.example.com`)
- traefik.http.routers.pocketbase.entrypoints=websecure
- traefik.http.routers.pocketbase.tls.certresolver=letsencrypt
- traefik.http.services.pocketbase.loadbalancer.server.port=8090
networks:
- proxy-network
networks:
proxy-network:
external: true
```
**Example** -- adding a custom service in your fork:
```yaml
# docker-compose.override.yml
services:
my-custom-api:
image: my-api:latest
restart: unless-stopped
ports:
- '4000:4000'
networks:
- monochrome-network
```
Override files can extend existing services (add labels, env vars, networks) and define entirely new services. See the [Docker docs](https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/) for the full merge behavior.
---
## Portainer Deployment
Portainer can deploy directly from your GitHub fork with auto-updates on push.
### Setup
1. In Portainer, go to **Stacks > Add Stack > Repository**
2. Enter your fork URL and branch
3. Compose path: `docker-compose.yml`
4. If your fork has a `docker-compose.override.yml`, Portainer loads it automatically
5. Under **Environment variables**, add:
- `COMPOSE_PROFILES=pocketbase` (to enable PocketBase -- omit if not needed)
- `PB_ADMIN_EMAIL=your@email.com`
- `PB_ADMIN_PASSWORD=your_secure_password`
- Any other variables from `.env.example`
6. Enable **GitOps updates** to auto-redeploy on push
> **Tip:** `COMPOSE_PROFILES` is a built-in Docker Compose variable. Setting it to `pocketbase` is equivalent to passing `--profile pocketbase` on the command line.
> **Warning:** The `dev` profile is for **local development only**. It uses volume mounts to enable hot-reload, which requires the source code to be present on the host machine. Do **not** include `dev` in `COMPOSE_PROFILES` on Portainer deployments from GitHub — it will fail because there's no local source code to mount.
### Fork Workflow
To add custom services (Traefik, monitoring, etc.) to your fork:
1. Create `docker-compose.override.yml` in your fork
2. Remove the `docker-compose.override.yml` line from `.gitignore`
3. Commit both changes to your fork
4. Portainer will auto-load the override file alongside the base compose
When pulling updates from upstream (`git pull upstream main`), there are no conflicts -- the upstream repo does not have an override file.
---
## Common Operations
```bash
# View logs
docker compose logs -f
docker compose logs -f pocketbase
# Rebuild after code changes
docker compose up -d --build
# Stop everything (include all profiles you started)
docker compose --profile pocketbase down
# Stop and remove volumes (data loss!)
docker compose --profile pocketbase down -v
# Backup PocketBase data
docker compose exec pocketbase tar czf - /pb_data > backup.tar.gz
# Restore PocketBase data
docker compose exec pocketbase tar xzf - -C / < backup.tar.gz
```
---
## Architecture
### Production (Dockerfile)
Node.js Alpine image (multi-arch: amd64 + arm64). Installs dependencies, runs `vite build`, then serves the built files with `vite preview` on port 4173.
### Development (Dockerfile.dev)
Node.js Alpine image with source code mounted as a volume for hot-reload.
### Files
| File | Purpose | In upstream repo |
| ----------------------------- | ----------------------------- | :--------------: |
| `docker-compose.yml` | All services with profiles | Yes |
| `docker-compose.override.yml` | Fork-specific customizations | No |
| `.env.example` | Environment variable template | Yes |
| `.env` | Your local configuration | No |
| `Dockerfile` | Production build | Yes |
| `Dockerfile.dev` | Development build | Yes |
| `.dockerignore` | Build context exclusions | Yes |

View File

@@ -0,0 +1,32 @@
# Node Alpine -- multi-arch (amd64 + arm64)
FROM node:lts-alpine
WORKDIR /app
# Install system dependencies required for Bun and Neutralino
RUN apk add --no-cache wget curl bash
RUN apk add --no-cache python3 make g++ && ln -sf python3 /usr/bin/python
# Install Bun
RUN curl -fsSL https://bun.sh/install | bash
# Add Bun to PATH so it can be used in subsequent steps
ENV PATH="/root/.bun/bin:${PATH}"
# Copy package files first for caching
COPY package.json package-lock.json ./
# Install dependencies (Node)
RUN bun install
# Copy the rest of the project
COPY . .
# Build the project (Bun is now available for "bun x neu build")
RUN bun run build
# Expose Vite preview port
EXPOSE 4173
# Run the built project
CMD ["bun", "run", "preview", "--", "--host", "0.0.0.0"]

View File

@@ -0,0 +1,16 @@
# Development Dockerfile for hot-reloading
# Node Alpine -- multi-arch (amd64 + arm64)
FROM node:lts-alpine
WORKDIR /app
# Copy package files first for caching
COPY package.json ./
# Install dependencies
RUN npm install
# Expose Vite dev server port
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View File

@@ -0,0 +1,100 @@
# Monochrome Instances
This document lists public instances of Monochrome that you can use. Instances are community-hosted versions of Monochrome that provide access to the application.
---
## Official Instance
The official Monochrome instance maintained by the core team:
| URL | Status | Notes |
| ------------------------------------------------------ | -------- | ---------------- |
| [monochrome.tf](https://monochrome.tf) | Official | Primary instance |
| [monochrome.samidy.com](https://monochrome.samidy.com) | Official | Secondary mirror |
---
## Community Instances
### UI-Only Instances
These instances provide the tidal-ui web interface, not monochrome:
| Provider | URL | Status |
| ------------------- | ---------------------------------------------- | --------- |
| **bini (tidal-ui)** | [music.binimum.org](https://music.binimum.org) | Community |
| **squid.wtf** | [tidal.squid.wtf](https://tidal.squid.wtf) | Community |
| **QQDL** | [tidal.qqdl.site](https://tidal.qqdl.site/) | Community |
---
## API Instances
Monochrome uses the Hi-Fi API under the hood. Live, up-to-date status trackers (which return JSON) can be found below:
- https://tidal-uptime.jiffy-puffs-1j.workers.dev/
- https://tidal-uptime.props-76styles.workers.dev/
These are available API endpoints that can be used with Monochrome or other Hi-Fi based applications:
### Official & Community APIs
| Provider | URL | Notes |
| ----------------- | ----------------------------------- | ---------------------------------------------------------- |
| **Monochrome** | `https://monochrome-api.samidy.com` | Official API - [See Note](https://rentry.co/monochromeapi) |
| | `https://api.monochrome.tf` | Official API |
| | `https://arran.monochrome.tf` | Official API |
| **squid.wtf** | `https://triton.squid.wtf` | Community hosted |
| **Lucida (QQDL)** | `https://wolf.qqdl.site` | Community hosted |
| | `https://maus.qqdl.site` | Community hosted |
| | `https://vogel.qqdl.site` | Community hosted |
| | `https://katze.qqdl.site` | Community hosted |
| | `https://hund.qqdl.site` | Community hosted |
| **Spotisaver** | `https://hifi-one.spotisaver.net` | Community hosted |
| | `https://hifi-two.spotisaver.net` | Community hosted |
| **Kinoplus** | `https://tidal.kinoplus.online` | Community hosted |
| **Binimum** | `https://tidal-api.binimum.org` | Community hosted |
---
## Instance Health
To check the current status of instances:
1. Visit the instance URL in your browser
2. Check if the page loads correctly
3. Try playing a track to verify API connectivity
> **Note:** Community instances may have varying uptime and performance. If one doesn't work, try another.
---
## Adding Your Instance
Want to add your instance to this list?
1. Ensure your instance is stable and publicly accessible
2. Open a pull request with your instance details
3. Include:
- Instance URL
- Provider name
- Type (UI/API/Both)
- Brief description
---
## Disclaimer
- Community instances are not affiliated with the official Monochrome project
- Use at your own risk
- Instance availability and performance may vary
- The official project does not guarantee uptime for community instances
---
## Related Resources
- [Self-Hosting Guide](self-hosted-database.md) - Host your own instance
- [Contributing Guide](CONTRIBUTE.md) - Contribute to the project
- [Main Repository](https://github.com/SamidyFR/monochrome) - Source code

View File

@@ -0,0 +1,258 @@
<p align="center">
<a href="https://monochrome.tf">
<img src="https://github.com/monochrome-music/monochrome/blob/main/public/assets/512.png?raw=true" alt="Monochrome Logo" width="150px">
</a>
</p>
<h1 align="center">Monochrome</h1>
<p align="center">
<strong>An open-source, privacy-respecting, ad-free music app.</strong>
</p>
<p align="center">
<a href="https://monochrome.tf">Website</a>
<a href="https://ko-fi.com/monochromemusic">Donate</a>
<a href="#features">Features</a>
<a href="#installation">Installation</a>
<a href="#usage">Usage</a>
<a href="#self-hosting">Self-Hosting</a>
<a href="CONTRIBUTING.md">Contributing</a>
</p>
<p align="center">
<a href="https://github.com/monochrome-music/monochrome/stargazers">
<img src="https://img.shields.io/github/stars/monochrome-music/monochrome?style=for-the-badge&color=ffffff&labelColor=000000" alt="GitHub stars">
</a>
<a href="https://github.com/monochrome-music/monochrome/forks">
<img src="https://img.shields.io/github/forks/monochrome-music/monochrome?style=for-the-badge&color=ffffff&labelColor=000000" alt="GitHub forks">
</a>
<a href="https://github.com/monochrome-music/monochrome/issues">
<img src="https://img.shields.io/github/issues/monochrome-music/monochrome?style=for-the-badge&color=ffffff&labelColor=000000" alt="GitHub issues">
</a>
</p>
---
## What is Monochrome?
**Monochrome** is an open-source, privacy-respecting, ad-free [TIDAL](https://tidal.com) web UI, built on top of [Hi-Fi](https://github.com/binimum/hifi-api). It provides a beautiful, minimalist interface for streaming high-quality music without the clutter of traditional streaming platforms.
<p align="center">
<a href="https://monochrome.tf/#album/90502209">
<img width="2559" height="1439" alt="image" src="https://github.com/user-attachments/assets/7973ea9f-c4aa-4c12-b476-f388f614db38" alt="Monochrome UI" width="800">
</a>
</p>
---
## Features
### Audio Quality
- High-quality Hi-Res/lossless audio streaming
- Support for local music files
- Intelligent API caching for improved performance
### Interface
- Dark, minimalist interface optimized for focus
- Customizable themes
- Community Theme Store
- Accurate and unique audio visualizer
- Offline-capable Progressive Web App (PWA)
- Media Session API integration for system controls
### Library & Organization
- Recently Played tracking for easy history access
- Comprehensive Personal Library for favorites
- Queue management with shuffle and repeat modes
- Playlist import from other platforms
- Public playlists for social sharing
- Smart recommendations for new songs, albums & artists
### Lyrics & Metadata
- Lyrics support with karaoke mode
- Genius integration for lyrics
- Track downloads with automatic metadata embedding
### Integrations
- Account system for cross-device syncing
- Customizable & Public Profiles
- Last.fm and ListenBrainz integration for scrobbling
- Unreleased music from [ArtistGrid](https://artistgrid.cx)
- Dynamic Discord Embeds
- Multiple API instance support with failover
### Power User Features
- Keyboard shortcuts & Command Palette (CTRL+K) for power users
---
## Quick Start
### Live Instance
Our Recommended way to use monochrome is through our official instance:
**[monochrome.tf](https://monochrome.tf)**
For alternative instances, check [INSTANCES.md](INSTANCES.md).
---
## Self-Hosting
NOTE: We only allow authorized domains to use our firebase authentication system, so unless you switch to your own firebase project, accounts wont work.
### Option 1: Docker (Recommended)
```bash
git clone https://github.com/monochrome-music/monochrome.git
cd monochrome
docker compose up -d
```
Visit `http://localhost:3000`
### Tailscale Access
Visit `http://<tailscale_server_hostname_or_ip>:3000`
By default, the app uses Vite preview, which restricts access to localhost.
To allow access over Tailscale:
1. Open `vite.config.js`
2. Uncomment and configure the `preview` section:
```js
preview: {
host: true,
allowedHosts: ['<your_tailscale_hostname>'], // e.g. pi5.tailf5f622.ts.net
},
```
3. Restart with a fresh container (if already running):
```bash
docker compose down
docker compose up -d
```
For PocketBase, development mode, and advanced setups, see [DOCKER.md](DOCKER.md).
### Option 2: Manual Installation
#### Prerequisites
- [Node.js](https://nodejs.org/) (Version 20+ or 22+ recommended)
- [Bun](https://bun.sh/) or [npm](https://www.npmjs.com/)
#### Local Development
1. **Clone the repository:**
```bash
git clone https://github.com/monochrome-music/monochrome.git
cd monochrome
```
2. **Install dependencies:**
```bash
bun install
# or
npm install
```
3. **Start the development server:**
```bash
bun run dev
# or
npm run dev
```
4. **Open your browser:**
Navigate to `http://localhost:5173/`
#### Building for Production
```bash
bun run build
# or
npm run build
```
---
## Usage
### Basic Usage
1. Visit the [Website](https://monochrome.tf) or your local development server
2. Search for your favorite artists, albums, or tracks
3. Click play to start streaming
4. Use the media controls to manage playback, queue, and volume
### Keyboard Shortcuts
| Shortcut | Action |
| -------- | -------------- |
| `Space` | Play/Pause |
| `` | Next track |
| `` | Previous track |
| `` | Volume up |
| `` | Volume down |
| `M` | Mute/Unmute |
| `L` | Toggle lyrics |
| `F` | Fullscreen |
| `/` | Focus search |
### Account Features
To sync your library, history, and playlists across devices:
1. Click the "Accounts" Section
2. Sign in with Google or Email
3. Your data will automatically sync across all devices
---
## Contributing
We welcome contributions from the community! Please see our [Contributing Guide](CONTRIBUTING.md) for:
- Setting up your development environment
- Code style and linting
- Project structure
- Before You Contribute
- Commit message conventions
- Deployment information
---
<p align="center">
<a href="https://fmhy.net/audio#streaming-sites">
<img src="https://raw.githubusercontent.com/monochrome-music/monochrome/refs/heads/main/public/assets/asseenonfmhy880x310.png" alt="As seen on FMHY" height="50">
</a>
</p>
<p align="center">
Made with ❤️ by the Monochrome team
</p>
## Star History
<a href="https://www.star-history.com/#monochrome-music/monochrome&type=date&logscale&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=monochrome-music/monochrome&type=date&theme=dark&logscale&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=monochrome-music/monochrome&type=date&logscale&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=monochrome-music/monochrome&type=date&logscale&legend=top-left" />
</picture>
</a>

View File

@@ -0,0 +1,77 @@
# Monochrome Theme Creation Guide
Welcome to the Monochrome Theme Guide! This document explains how to create, style, and upload custom themes for Monochrome.
## Getting Started
Themes in Monochrome are essentially CSS snippets that override the default CSS variables (custom properties). You can create a theme by defining these variables inside a `:root` block.
### Basic Structure
```css
:root {
/* Base Colors */
--background: #0a0a0a;
--foreground: #ededed;
/* UI Elements */
--card: #1a1a1a;
--card-foreground: #ededed;
--border: #2a2a2a;
/* Accents */
--primary: #3b82f6;
--primary-foreground: #ffffff;
--secondary: #2a2a2a;
--secondary-foreground: #ededed;
/* Text */
--muted: #2a2a2a;
--muted-foreground: #a0a0a0;
/* Special */
--highlight: #3b82f6;
--ring: #3b82f6;
--radius: 8px;
--font-family: 'Inter', sans-serif;
}
```
## CSS Variables Reference
| Variable | Description |
| :----------------------- | :-------------------------------------------------------- |
| `--background` | The main background color for your theme. |
| `--foreground` | The main text color. |
| `--card` | Background color for cards, modals, and panels. |
| `--card-foreground` | Text color inside cards. |
| `--border` | Color for borders and separators. |
| `--primary` | Main accent color (buttons, active states). |
| `--primary-foreground` | Text color on top of primary elements. |
| `--secondary` | Secondary background (hover states, secondary buttons). |
| `--secondary-foreground` | Text color on secondary elements. |
| `--muted` | Muted background color (placeholders, skeletons). |
| `--muted-foreground` | Muted text color (subtitles, metadata). |
| `--highlight` | Color used for text highlighting and focus rings. |
| `--radius` | Border radius for cards and buttons (e.g., `8px`, `0px`). |
| `--font-family` | Font stack for the theme. |
## Using the Theme Editor
1. **Open the Theme Store**: Go to Settings > Appearance > Open Theme Store.
2. **Go to Upload Tab**: Click on the "Upload" tab.
3. **Use the Toolbar**:
- **Colors**: Use the color pickers to quickly set the main colors.
- **Styles**: Use the dropdowns to set font and border radius.
- **Template**: Click "Template" to insert a starter CSS block.
- **Preview**: Click "Preview" to see your changes in real-time on a sample card.
4. **Manual Editing**: You can manually edit the CSS in the text area for fine-grained control.
## Uploading Your Theme
1. **Name & Description**: Give your theme a unique name and a brief description.
2. **Author Website**: Optionally provide a link to your website.
- _Note:_ If you have a Monochrome profile, your name will automatically link to it.
3. **Submit**: Click "Upload Theme".
Once uploaded, your theme will be available for everyone to browse and apply!

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
services:
# Production frontend -- always runs
monochrome:
build:
context: .
dockerfile: Dockerfile
container_name: monochrome
ports:
- '${MONOCHROME_PORT:-3000}:4173'
environment:
AUTH_ENABLED: ${AUTH_ENABLED:-false}
AUTH_SECRET: ${AUTH_SECRET:-}
FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-monochrome-database}
FIREBASE_CONFIG: ${FIREBASE_CONFIG:-}
POCKETBASE_URL: ${POCKETBASE_URL:-}
SESSION_MAX_AGE: ${SESSION_MAX_AGE:-604800000}
restart: unless-stopped
networks:
- monochrome-network
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4173/health']
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
# PocketBase backend -- only starts with: docker compose --profile pocketbase up -d
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: monochrome-pocketbase
profiles:
- pocketbase
restart: unless-stopped
environment:
PB_ADMIN_EMAIL: ${PB_ADMIN_EMAIL:-admin@example.com}
PB_ADMIN_PASSWORD: ${PB_ADMIN_PASSWORD:-changeme}
TZ: ${TZ:-UTC}
ports:
- '${POCKETBASE_PORT:-8090}:8090'
volumes:
- pb_data:/pb_data
- pb_public:/pb_public
- pb_hooks:/pb_hooks
command: serve --http=0.0.0.0:8090
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:8090/api/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- monochrome-network
# Development server -- only starts with: docker compose --profile dev up -d
monochrome-dev:
build:
context: .
dockerfile: Dockerfile.dev
container_name: monochrome-dev
profiles:
- dev
ports:
- '${MONOCHROME_DEV_PORT:-5173}:5173'
volumes:
- .:/app
- /app/node_modules
command: npm run dev -- --host 0.0.0.0
networks:
- monochrome-network
networks:
monochrome-network:
driver: bridge
volumes:
pb_data:
pb_public:
pb_hooks:

View File

@@ -0,0 +1,34 @@
import js from '@eslint/js';
import globals from 'globals';
import prettierConfig from 'eslint-config-prettier';
export default [
{
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/legacy/**',
'**/bin/**',
'**/www/**',
'**/public/lib/**',
'**/public/neutralino.js',
],
},
js.configs.recommended,
prettierConfig,
{
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
Neutralino: 'readonly',
},
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-console': ['warn', { allow: ['log', 'warn', 'error'] }],
},
},
];

View File

@@ -0,0 +1,102 @@
# bridge.ps1 - Diagnostic Version
$Log = Join-Path $PSScriptRoot "bridge.log"
function Log($m) { Add-Content $Log "$(Get-Date -f 'HH:mm:ss') - $m" }
Log "--- START (DIAGNOSTIC) ---"
# 1. PID
$p = Get-Process Monochrome -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $p) { $p = Get-Process neutralino-win_x64 -ErrorAction SilentlyContinue | Select-Object -First 1 }
$pid_to_send = if ($p) { $p.Id } else { [System.Diagnostics.Process]::GetCurrentProcess().Id }
# 2. Discord Connection
function Get-Pipe {
for ($i = 0; $i -le 9; $i++) {
try {
$pn = "discord-ipc-$i"
$p = New-Object System.IO.Pipes.NamedPipeClientStream(".", $pn, [System.IO.Pipes.PipeDirection]::InOut)
$p.Connect(100)
return $p
} catch { }
}
return $null
}
$pipe = Get-Pipe; if (-not $pipe) { Log "Discord Fail"; exit }
function Send-Packet($op, $json) {
if ($op -eq 1) { Log "Sending Activity: $json" }
$j = [System.Text.Encoding]::UTF8.GetBytes($json)
[byte[]]$pkt = [BitConverter]::GetBytes([int]$op) + [BitConverter]::GetBytes([int]$j.Length) + $j
$pipe.Write($pkt, 0, $pkt.Length); $pipe.Flush()
}
# 3. Handshake
Send-Packet 0 (@{ v = 1; client_id = "1462186088184549661" } | ConvertTo-Json -Compress)
$h = New-Object byte[] 8; if ($pipe.Read($h, 0, 8) -eq 8) {
$l = [BitConverter]::ToInt32($h, 4); $b = New-Object byte[] $l; $pipe.Read($b, 0, $l) | Out-Null
Log "Handshake OK"
}
function Set-Activity($d, $s, $img, $start, $end, $large_text, $small_img, $small_txt) {
$activity = @{
details = [string]$d
state = [string]$s
type = 2
assets = @{
large_image = if ($img -and $img.StartsWith("http")) { [string]$img } else { "monochrome" }
large_text = if ($large_text) { [string]$large_text } else { "Monochrome" }
}
}
if ($small_img) {
$activity.assets.small_image = [string]$small_img
$activity.assets.small_text = [string]$small_txt
}
if ($start -or $end) {
$activity.timestamps = @{}
if ($start) { $activity.timestamps.start = [long]$start }
if ($end) { $activity.timestamps.end = [long]$end }
}
# CRITICAL: -Depth 10 ensures 'assets' is not stringified as a class name
$payload = @{
cmd = "SET_ACTIVITY"
args = @{ pid = [int]$pid_to_send; activity = $activity }
nonce = [Guid]::NewGuid().ToString()
} | ConvertTo-Json -Compress -Depth 10
Send-Packet 1 $payload
}
Start-Sleep -Seconds 1
Set-Activity "Idling" "Monochrome" $null $null $null $null $null $null
# 4. Config & WS
$line = [Console]::In.ReadLine()
if (-not $line) { exit }
$config = $line | ConvertFrom-Json
$ws = New-Object System.Net.WebSockets.ClientWebSocket
try {
$uri = [Uri]"ws://127.0.0.1:$($config.nlPort)?extensionId=$($config.nlExtensionId)&connectToken=$($config.nlConnectToken)"
$ws.ConnectAsync($uri, [System.Threading.CancellationToken]::None).Wait()
Log "WS Connected"
} catch { exit }
# 5. Loop
$buf = New-Object byte[] 65536
while ($ws.State -eq "Open") {
$task = $ws.ReceiveAsync((New-Object ArraySegment[byte] -ArgumentList @(,$buf)), [System.Threading.CancellationToken]::None)
while (-not $task.Wait(1000)) { if (-not (Get-Process -Id $pid_to_send -ErrorAction SilentlyContinue)) { exit } }
if ($task.Result.Count -gt 0) {
try {
$raw = [System.Text.Encoding]::UTF8.GetString($buf, 0, $task.Result.Count)
$msg = $raw | ConvertFrom-Json
if ($msg.event -eq "discord:update") {
Set-Activity $msg.data.details $msg.data.state $msg.data.largeImageKey $msg.data.startTimestamp $msg.data.endTimestamp $msg.data.largeImageText $msg.data.smallImageKey $msg.data.smallImageText
}
elseif ($msg.event -eq "discord:clear") { Set-Activity "Idling" "Monochrome" $null $null $null $null $null $null }
} catch {}
}
}

View File

@@ -0,0 +1,165 @@
# bridge.py - Production Discord RPC Bridge (Linux/macOS)
import sys, json, socket, struct, os, uuid, base64, time
CLIENT_ID = "1462186088184549661"
LAST_STATUS = ""
def get_discord_path():
for i in range(10):
path = os.path.join(os.environ.get('XDG_RUNTIME_DIR', '/tmp'), f'discord-ipc-{i}')
if os.path.exists(path): return path
return None
def send_packet(s, op, data):
payload = json.dumps(data).encode('utf-8')
header = struct.pack('<II', op, len(payload))
s.sendall(header + payload)
def recv_packet(s):
try:
header = s.recv(8)
if len(header) < 8: return None
op, length = struct.unpack('<II', header)
payload = s.recv(length)
return json.loads(payload.decode('utf-8'))
except Exception:
# Ignore errors and return None
return None
def set_activity(ds, pid, details, state, img=None, start=None, end=None, large_text=None, small_img=None, small_txt=None):
global LAST_STATUS
current = f"{details}-{state}-{img}-{start}-{end}-{large_text}-{small_img}-{small_txt}"
if current == LAST_STATUS: return
LAST_STATUS = current
activity = {
"details": str(details or "Idling"),
"state": str(state or "Monochrome"),
"type": 2, # Listening
"assets": {
"large_image": img if img and img.startswith('http') else "monochrome",
"large_text": str(large_text or "Monochrome")
}
}
if small_img:
activity["assets"]["small_image"] = str(small_img)
activity["assets"]["small_text"] = str(small_txt or "")
if start or end:
activity["timestamps"] = {}
if start: activity["timestamps"]["start"] = int(start)
if end: activity["timestamps"]["end"] = int(end)
send_packet(ds, 1, {
"cmd": "SET_ACTIVITY",
"args": {"pid": pid, "activity": activity},
"nonce": str(uuid.uuid4())
})
def main():
# 1. Read config
try:
line = sys.stdin.readline()
if not line: return
config = json.loads(line)
except Exception:
# Ignore errors and exit
return
ppid = os.getppid()
# 2. Connect to Discord
ipc_path = get_discord_path()
if not ipc_path: return
try:
ds = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
ds.connect(ipc_path)
except Exception:
# Ignore connection errors and exit
return
# 3. Handshake
send_packet(ds, 0, {"v": 1, "client_id": CLIENT_ID})
recv_packet(ds) # Mandatory read
time.sleep(0.5)
set_activity(ds, ppid, "Idling", "Monochrome")
# 4. Minimal WebSocket Client
ws = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
ws.settimeout(1.0)
try:
ws.connect(('127.0.0.1', int(config['nlPort'])))
except Exception:
# Ignore connection errors and exit
return
key = base64.b64encode(os.urandom(16)).decode()
handshake = (
f"GET /?extensionId={config['nlExtensionId']}&connectToken={config['nlConnectToken']} HTTP/1.1\r\n"
f"Host: 127.0.0.1:{config['nlPort']}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n"
)
ws.sendall(handshake.encode())
# Skip HTTP response header
resp = b""
while b"\r\n\r\n" not in resp:
try:
chunk = ws.recv(1024)
if not chunk: break
resp += chunk
except socket.timeout: continue
# 5. Loop
while True:
# Watchdog
try:
os.kill(ppid, 0)
except OSError: break
try:
head = ws.recv(2)
if not head: break
length = head[1] & 127
if length == 126: length = struct.unpack(">H", ws.recv(2))[0]
elif length == 127: length = struct.unpack(">Q", ws.recv(8))[0]
data = b""
while len(data) < length:
data += ws.recv(length - len(data))
msg = json.loads(data.decode('utf-8'))
if msg['event'] == 'discord:update':
d = msg['data']
set_activity(ds, ppid, d.get('details'), d.get('state'), d.get('largeImageKey'), d.get('startTimestamp'), d.get('endTimestamp'), d.get('largeImageText'), d.get('smallImageKey'), d.get('smallImageText'))
elif msg['event'] == 'discord:clear':
set_activity(ds, ppid, "Idling", "Monochrome")
elif msg['event'] == 'windowClose':
break
except socket.timeout:
# Timeout is expected, continue polling
continue
except Exception:
# Ignore other errors and continue
continue
# Cleanup
try:
send_packet(ds, 1, {
"cmd": "SET_ACTIVITY",
"args": {"pid": ppid, "activity": None},
"nonce": str(uuid.uuid4())
})
time.sleep(0.1)
ds.close()
except Exception:
# Ignore cleanup errors
pass
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,160 @@
// functions/album/[id].js
class ServerAPI {
constructor() {
this.INSTANCES_URLS = [
'https://tidal-uptime.jiffy-puffs-1j.workers.dev/',
'https://tidal-uptime.props-76styles.workers.dev/',
];
this.apiInstances = null;
}
async getInstances() {
if (this.apiInstances) return this.apiInstances;
let data = null;
const urls = [...this.INSTANCES_URLS].sort(() => Math.random() - 0.5);
for (const url of urls) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
data = await response.json();
break;
} catch (error) {
console.warn(`Failed to fetch from ${url}:`, error);
}
}
if (data) {
this.apiInstances = (data.api || [])
.map((item) => item.url || item)
.filter((url) => !url.includes('spotisaver.net'));
return this.apiInstances;
}
console.error('Failed to load instances from all uptime APIs');
return [
'https://eu-central.monochrome.tf',
'https://us-west.monochrome.tf',
'https://arran.monochrome.tf',
'https://triton.squid.wtf',
'https://api.monochrome.tf',
'https://monochrome-api.samidy.com',
'https://maus.qqdl.site',
'https://vogel.qqdl.site',
'https://katze.qqdl.site',
'https://hund.qqdl.site',
'https://tidal.kinoplus.online',
'https://wolf.qqdl.site',
];
}
async fetchWithRetry(relativePath) {
const instances = await this.getInstances();
if (instances.length === 0) {
throw new Error('No API instances configured.');
}
let lastError = null;
for (const baseUrl of instances) {
const url = baseUrl.endsWith('/') ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`;
try {
const response = await fetch(url);
if (response.ok) {
return response;
}
lastError = new Error(`Request failed with status ${response.status}`);
} catch (error) {
lastError = error;
}
}
throw lastError || new Error(`All API instances failed for: ${relativePath}`);
}
async getAlbumMetadata(id) {
try {
const response = await this.fetchWithRetry(`/album/${id}`);
return await response.json();
} catch {
const response = await this.fetchWithRetry(`/album?id=${id}`);
return await response.json();
}
}
getCoverUrl(id, size = '1280') {
if (!id) return '';
const formattedId = id.replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
}
export async function onRequest(context) {
const { request, params, env } = context;
const userAgent = request.headers.get('User-Agent') || '';
const isBot = /discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot/i.test(
userAgent
);
const albumId = params.id;
if (isBot && albumId) {
try {
const api = new ServerAPI();
const data = await api.getAlbumMetadata(albumId);
const album = data.data || data.album || data;
const tracks = album.items || data.tracks || [];
if (album && (album.title || album.name)) {
const title = album.title || album.name;
const artist = album.artist?.name || 'Unknown Artist';
const year = album.releaseDate ? new Date(album.releaseDate).getFullYear() : '';
const trackCount = album.numberOfTracks || tracks.length;
const description = `Album by ${artist}${year}${trackCount} Tracks\nListen on Monochrome`;
const imageUrl = album.cover
? api.getCoverUrl(album.cover, '1280')
: 'https://monochrome.samidy.com/assets/appicon.png';
const pageUrl = new URL(request.url).href;
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${title}</title>
<meta name="description" content="${description}">
<meta name="theme-color" content="#000000">
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:type" content="music.album">
<meta property="og:url" content="${pageUrl}">
<meta property="music:musician" content="${artist}">
<meta property="music:release_date" content="${album.releaseDate}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${title}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
</head>
<body>
<h1>${title}</h1>
<p>${description}</p>
<img src="${imageUrl}" alt="Album Cover">
</body>
</html>
`;
return new Response(metaHtml, { headers: { 'content-type': 'text/html;charset=UTF-8' } });
}
} catch (error) {
console.error(`Error for album ${albumId}:`, error);
}
}
const url = new URL(request.url);
url.pathname = '/';
return env.ASSETS.fetch(new Request(url, request));
}

View File

@@ -0,0 +1,2 @@
// functions/album/t/[id].js - Re-export handler from parent for /album/t/:id routes
export { onRequest } from '../[id].js';

View File

@@ -0,0 +1,152 @@
// functions/artist/[id].js
class ServerAPI {
constructor() {
this.INSTANCES_URLS = [
'https://tidal-uptime.jiffy-puffs-1j.workers.dev/',
'https://tidal-uptime.props-76styles.workers.dev/',
];
this.apiInstances = null;
}
async getInstances() {
if (this.apiInstances) return this.apiInstances;
let data = null;
const urls = [...this.INSTANCES_URLS].sort(() => Math.random() - 0.5);
for (const url of urls) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
data = await response.json();
break;
} catch (error) {
console.warn(`Failed to fetch from ${url}:`, error);
}
}
if (data) {
this.apiInstances = (data.api || [])
.map((item) => item.url || item)
.filter((url) => !url.includes('spotisaver.net'));
return this.apiInstances;
}
console.error('Failed to load instances from all uptime APIs');
return [
'https://eu-central.monochrome.tf',
'https://us-west.monochrome.tf',
'https://arran.monochrome.tf',
'https://triton.squid.wtf',
'https://api.monochrome.tf',
'https://monochrome-api.samidy.com',
'https://maus.qqdl.site',
'https://vogel.qqdl.site',
'https://katze.qqdl.site',
'https://hund.qqdl.site',
'https://tidal.kinoplus.online',
'https://wolf.qqdl.site',
];
}
async fetchWithRetry(relativePath) {
const instances = await this.getInstances();
if (instances.length === 0) {
throw new Error('No API instances configured.');
}
let lastError = null;
for (const baseUrl of instances) {
const url = baseUrl.endsWith('/') ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`;
try {
const response = await fetch(url);
if (response.ok) {
return response;
}
lastError = new Error(`Request failed with status ${response.status}`);
} catch (error) {
lastError = error;
}
}
throw lastError || new Error(`All API instances failed for: ${relativePath}`);
}
async getArtistMetadata(id) {
try {
const response = await this.fetchWithRetry(`/artist/${id}`);
return await response.json();
} catch {
const response = await this.fetchWithRetry(`/artist?id=${id}`);
return await response.json();
}
}
getArtistPictureUrl(id, size = '750') {
if (!id) return '';
const formattedId = id.replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
}
export async function onRequest(context) {
const { request, params, env } = context;
const userAgent = request.headers.get('User-Agent') || '';
const isBot = /discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot/i.test(
userAgent
);
const artistId = params.id;
if (isBot && artistId) {
try {
const api = new ServerAPI();
const data = await api.getArtistMetadata(artistId);
const artist = data.artist || data.data || data;
if (artist && (artist.name || artist.title)) {
const name = artist.name || artist.title;
const description = `Listen to ${name} on Monochrome`;
const imageUrl = artist.picture
? api.getArtistPictureUrl(artist.picture, '750')
: 'https://monochrome.samidy.com/assets/appicon.png';
const pageUrl = new URL(request.url).href;
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${name}</title>
<meta name="description" content="${description}">
<meta name="theme-color" content="#000000">
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="${name}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:type" content="profile">
<meta property="og:url" content="${pageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${name}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
</head>
<body>
<h1>${name}</h1>
<img src="${imageUrl}" alt="Artist Picture">
</body>
</html>
`;
return new Response(metaHtml, { headers: { 'content-type': 'text/html;charset=UTF-8' } });
}
} catch (error) {
console.error(`Error for artist ${artistId}:`, error);
}
}
const url = new URL(request.url);
url.pathname = '/';
return env.ASSETS.fetch(new Request(url, request));
}

View File

@@ -0,0 +1,2 @@
// functions/artist/t/[id].js - Re-export handler from parent for /artist/t/:id routes
export { onRequest } from '../[id].js';

View File

@@ -0,0 +1,157 @@
// functions/playlist/[id].js
class ServerAPI {
constructor() {
this.INSTANCES_URLS = [
'https://tidal-uptime.jiffy-puffs-1j.workers.dev/',
'https://tidal-uptime.props-76styles.workers.dev/',
];
this.apiInstances = null;
}
async getInstances() {
if (this.apiInstances) return this.apiInstances;
let data = null;
const urls = [...this.INSTANCES_URLS].sort(() => Math.random() - 0.5);
for (const url of urls) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
data = await response.json();
break;
} catch (error) {
console.warn(`Failed to fetch from ${url}:`, error);
}
}
if (data) {
this.apiInstances = (data.api || [])
.map((item) => item.url || item)
.filter((url) => !url.includes('spotisaver.net'));
return this.apiInstances;
}
console.error('Failed to load instances from all uptime APIs');
return [
'https://eu-central.monochrome.tf',
'https://us-west.monochrome.tf',
'https://arran.monochrome.tf',
'https://triton.squid.wtf',
'https://api.monochrome.tf',
'https://monochrome-api.samidy.com',
'https://maus.qqdl.site',
'https://vogel.qqdl.site',
'https://katze.qqdl.site',
'https://hund.qqdl.site',
'https://tidal.kinoplus.online',
'https://wolf.qqdl.site',
];
}
async fetchWithRetry(relativePath) {
const instances = await this.getInstances();
if (instances.length === 0) {
throw new Error('No API instances configured.');
}
let lastError = null;
for (const baseUrl of instances) {
const url = baseUrl.endsWith('/') ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`;
try {
const response = await fetch(url);
if (response.ok) {
return response;
}
lastError = new Error(`Request failed with status ${response.status}`);
} catch (error) {
lastError = error;
}
}
throw lastError || new Error(`All API instances failed for: ${relativePath}`);
}
async getPlaylistMetadata(id) {
try {
const response = await this.fetchWithRetry(`/playlist/${id}`);
return await response.json();
} catch {
// Fallback to query param style
const response = await this.fetchWithRetry(`/playlist?id=${id}`);
return await response.json();
}
}
getCoverUrl(id, size = '1080') {
if (!id) return '';
const formattedId = id.replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
}
export async function onRequest(context) {
const { request, params, env } = context;
const userAgent = request.headers.get('User-Agent') || '';
const isBot = /discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot/i.test(
userAgent
);
const playlistId = params.id;
if (isBot && playlistId) {
try {
const api = new ServerAPI();
const data = await api.getPlaylistMetadata(playlistId);
const playlist = data.playlist || data.data || data;
if (playlist && (playlist.title || playlist.name)) {
const title = playlist.title || playlist.name;
const trackCount = playlist.numberOfTracks;
const description = `Playlist • ${trackCount} Tracks\nListen on Monochrome`;
const imageId = playlist.squareImage || playlist.image;
const imageUrl = imageId
? api.getCoverUrl(imageId, '1080')
: 'https://monochrome.samidy.com/assets/appicon.png';
const pageUrl = new URL(request.url).href;
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${title}</title>
<meta name="description" content="${description}">
<meta name="theme-color" content="#000000">
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:type" content="music.playlist">
<meta property="og:url" content="${pageUrl}">
<meta property="music:song_count" content="${trackCount}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${title}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
</head>
<body>
<h1>${title}</h1>
<p>${description}</p>
<img src="${imageUrl}" alt="Playlist Cover">
</body>
</html>
`;
return new Response(metaHtml, { headers: { 'content-type': 'text/html;charset=UTF-8' } });
}
} catch (error) {
console.error(`Error for playlist ${playlistId}:`, error);
}
}
const url = new URL(request.url);
url.pathname = '/';
return env.ASSETS.fetch(new Request(url, request));
}

View File

@@ -0,0 +1,2 @@
// functions/playlist/t/[id].js - Re-export handler from parent for /playlist/t/:id routes
export { onRequest } from '../[id].js';

View File

@@ -0,0 +1,196 @@
// functions/track/[id].js
function getTrackTitle(track, { fallback = 'Unknown Title' } = {}) {
if (!track?.title) return fallback;
return track?.version ? `${track.title} (${track.version})` : track.title;
}
function getTrackArtists(track = {}, { fallback = 'Unknown Artist' } = {}) {
if (track?.artists?.length) {
return track.artists.map((artist) => artist?.name).join(', ');
}
return fallback;
}
class ServerAPI {
constructor() {
this.INSTANCES_URLS = [
'https://tidal-uptime.jiffy-puffs-1j.workers.dev/',
'https://tidal-uptime.props-76styles.workers.dev/',
];
this.apiInstances = null;
}
async getInstances() {
if (this.apiInstances) return this.apiInstances;
let data = null;
const urls = [...this.INSTANCES_URLS].sort(() => Math.random() - 0.5);
for (const url of urls) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
data = await response.json();
break;
} catch (error) {
console.warn(`Failed to fetch from ${url}:`, error);
}
}
if (data) {
this.apiInstances = (data.api || [])
.map((item) => item.url || item)
.filter((url) => !url.includes('spotisaver.net'));
return this.apiInstances;
}
console.error('Failed to load instances from all uptime APIs');
return [
'https://eu-central.monochrome.tf',
'https://us-west.monochrome.tf',
'https://arran.monochrome.tf',
'https://triton.squid.wtf',
'https://api.monochrome.tf',
'https://monochrome-api.samidy.com',
'https://maus.qqdl.site',
'https://vogel.qqdl.site',
'https://katze.qqdl.site',
'https://hund.qqdl.site',
'https://tidal.kinoplus.online',
'https://wolf.qqdl.site',
];
}
async fetchWithRetry(relativePath) {
const instances = await this.getInstances();
if (instances.length === 0) {
throw new Error('No API instances configured.');
}
let lastError = null;
for (const baseUrl of instances) {
const url = baseUrl.endsWith('/') ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`;
try {
const response = await fetch(url);
if (response.ok) {
return response;
}
lastError = new Error(`Request failed with status ${response.status}`);
} catch (error) {
lastError = error;
}
}
throw lastError || new Error(`All API instances failed for: ${relativePath}`);
}
async getTrackMetadata(id) {
const response = await this.fetchWithRetry(`/info/?id=${id}`);
const json = await response.json();
const data = json.data || json;
const items = Array.isArray(data) ? data : [data];
const found = items.find((i) => i.id == id || (i.item && i.item.id == id));
if (found) {
return found.item || found;
}
throw new Error('Track metadata not found');
}
getCoverUrl(id, size = '1280') {
if (!id) return '';
const formattedId = id.replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
async getStreamUrl(id) {
const response = await this.fetchWithRetry(`/stream?id=${id}&quality=LOW`);
const data = await response.json();
return data.url || data.streamUrl;
}
}
export async function onRequest(context) {
const { request, params, env } = context;
const userAgent = request.headers.get('User-Agent') || '';
const isBot = /discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot/i.test(
userAgent
);
const trackId = params.id;
if (isBot && trackId) {
try {
const api = new ServerAPI();
const track = await api.getTrackMetadata(trackId);
if (track) {
const title = getTrackTitle(track);
const artist = getTrackArtists(track);
const description = `${artist} - ${track.album.title}`;
const imageUrl = api.getCoverUrl(track.album.cover, '1280');
const trackUrl = new URL(request.url).href;
let audioUrl = track.previewUrl || track.previewURL;
if (!audioUrl) {
try {
audioUrl = await api.getStreamUrl(trackId);
} catch (e) {
console.error('Failed to fetch stream fallback:', e);
}
}
// this prob wont work im js winging it
const audioMeta = audioUrl
? `
<meta property="og:audio" content="${audioUrl}">
<meta property="og:audio:type" content="audio/mp4">
<meta property="og:video" content="${audioUrl}">
<meta property="og:video:type" content="audio/mp4">
`
: '';
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${title} by ${artist}</title>
<meta name="description" content="${description}">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:type" content="music.song">
<meta property="og:url" content="${trackUrl}">
<meta property="music:duration" content="${track.duration}">
<meta property="music:album" content="${track.album.title}">
<meta property="music:musician" content="${artist}">
${audioMeta}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${title}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
<meta name="theme-color" content="#000000">
</head>
<body>
<h1>${title}</h1>
<p>by ${artist}</p>
</body>
</html>
`;
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
}
} catch (error) {
console.error(`Error generating meta tags for track ${trackId}:`, error);
}
}
const url = new URL(request.url);
url.pathname = '/';
return env.ASSETS.fetch(new Request(url, request));
}

View File

@@ -0,0 +1,2 @@
// functions/track/t/[id].js - Re-export handler from parent for /track/t/:id routes
export { onRequest } from '../[id].js';

View File

@@ -0,0 +1,195 @@
const API_URL = 'https://catbox.moe/user/api.php';
const R2_PUBLIC_URL = 'https://cucks.qzz.io';
const R2_ENDPOINT = 'https://faae2f5c0a232c7f3733ef597c55bd69.r2.cloudflarestorage.com';
const R2_BUCKET = 'monochrome-image-uploads';
async function hmac(key, data) {
const cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
return new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, data));
}
async function sha256(data) {
return new Uint8Array(await crypto.subtle.digest('SHA-256', data));
}
function buf2hex(buffer) {
return Array.from(buffer)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
async function signature(secretKey, dateStamp, region, service, stringToSign) {
const kDate = await hmac(new TextEncoder().encode('AWS4' + secretKey), new TextEncoder().encode(dateStamp));
const kRegion = await hmac(kDate, new TextEncoder().encode(region));
const kService = await hmac(kRegion, new TextEncoder().encode(service));
const kSigning = await hmac(kService, new TextEncoder().encode('aws4_request'));
const sig = await hmac(kSigning, new TextEncoder().encode(stringToSign));
return buf2hex(sig);
}
async function createSignature(method, path, headers, payloadHash, accessKeyId, secretAccessKey, amzDate, dateStamp) {
const region = 'auto';
const service = 's3';
const signedHeaders = Object.keys(headers).sort().join(';');
const canonicalHeaders =
Object.entries(headers)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([k, v]) => `${k.toLowerCase()}:${v}`)
.join('\n') + '\n';
const canonicalRequest = `${method}\n${path}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`;
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${buf2hex(await sha256(new TextEncoder().encode(canonicalRequest)))}`;
const sig = await signature(secretAccessKey, dateStamp, region, service, stringToSign);
return `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${sig}`;
}
export async function onRequest(context) {
const { request, env } = context;
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders() });
}
if (request.method !== 'POST') {
return jsonError('Method not allowed', 405);
}
const useR2 = env.R2_ENABLED === 'true';
try {
const contentType = request.headers.get('content-type') || '';
let file;
let fileName;
let fileType;
if (contentType.includes('application/json')) {
const body = await request.json();
if (!body.fileUrl) return jsonError('No fileUrl provided', 400);
const res = await fetch(body.fileUrl);
if (!res.ok) throw new Error('Failed to fetch remote file');
file = await res.arrayBuffer();
fileName = body.fileName || body.fileUrl.split('/').pop();
fileType = res.headers.get('content-type') || 'application/octet-stream';
} else {
const form = await request.formData();
const uploaded = form.get('file');
if (!uploaded) return jsonError('No file provided', 400);
if (uploaded.size > 10 * 1024 * 1024) {
return jsonError('File exceeds 10MB', 400);
}
file = await uploaded.arrayBuffer();
fileName = uploaded.name;
fileType = uploaded.type || 'application/octet-stream';
}
let url;
if (useR2) {
try {
const key = `${Date.now()}-${fileName}`;
const now = new Date();
const amzDate =
now
.toISOString()
.replace(/[:-]|\.\d{3}/g, '')
.slice(0, 15) + 'Z';
const dateStamp = now
.toISOString()
.replace(/[:-]|\.\d{3}/g, '')
.slice(0, 8);
const payloadHash = buf2hex(await sha256(file));
const host = new URL(R2_ENDPOINT).host;
const headers = {
'Content-Type': fileType,
'Content-Length': file.byteLength,
'x-amz-date': amzDate,
'x-amz-content-sha256': payloadHash,
Host: host,
};
const auth = await createSignature(
'PUT',
`/${R2_BUCKET}/${key}`,
headers,
payloadHash,
env.R2_ACCESS_KEY_ID,
env.R2_SECRET_ACCESS_KEY,
amzDate,
dateStamp
);
headers['Authorization'] = auth;
const res = await fetch(`${R2_ENDPOINT}/${R2_BUCKET}/${key}`, {
method: 'PUT',
headers,
body: new Uint8Array(file),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`R2 error: ${res.status} - ${err}`);
}
url = `${R2_PUBLIC_URL}/${key}`;
} catch (r2Err) {
console.error('R2 upload error:', r2Err);
return jsonError(`R2 upload failed: ${r2Err.message}`, 500);
}
} else {
const formData = new FormData();
formData.append('reqtype', 'fileupload');
formData.append('fileToUpload', new Blob([file], { type: fileType }), fileName);
const response = await fetch(API_URL, {
method: 'POST',
body: formData,
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(`Upload failed: ${responseText}`);
}
url = responseText.trim();
}
return jsonResponse({
success: true,
url: url,
});
} catch (err) {
return jsonError(err.message, 500);
}
}
function jsonResponse(obj, status = 200) {
return new Response(JSON.stringify(obj), {
status,
headers: {
'Content-Type': 'application/json',
...corsHeaders(),
},
});
}
function jsonError(message, status) {
return jsonResponse({ success: false, error: message }, status);
}
function corsHeaders() {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': '*',
};
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,181 @@
// js/accounts/auth.js
import { auth } from './config.js';
export class AuthManager {
constructor() {
this.user = null;
this.authListeners = [];
this.init();
}
async init() {
const params = new URLSearchParams(window.location.search);
const userId = params.get('userId');
const secret = params.get('secret');
const isOAuthRedirect = params.get('oauth') === '1';
if (userId && secret && userId !== 'null' && secret !== 'null') {
try {
await auth.createSession(userId, secret);
window.history.replaceState({}, '', window.location.pathname);
} catch (error) {
console.warn('OAuth session handoff failed:', error.message);
window.history.replaceState({}, '', window.location.pathname);
}
} else if (isOAuthRedirect) {
await new Promise((resolve) => setTimeout(resolve, 500));
window.history.replaceState({}, '', window.location.pathname);
}
try {
this.user = await auth.get();
this.updateUI(this.user);
this.authListeners.forEach((listener) => listener(this.user));
} catch (error) {
this.user = null;
this.updateUI(null);
}
}
onAuthStateChanged(callback) {
this.authListeners.push(callback);
// If we already have a user state, trigger immediately
if (this.user !== null) {
callback(this.user);
}
}
async signInWithGoogle() {
try {
auth.createOAuth2Session(
'google',
window.location.origin + '/index.html?oauth=1',
window.location.origin + '/login.html'
);
} catch (error) {
console.error('Login failed:', error);
alert(`Login failed: ${error.message}`);
}
}
async signInWithEmail(email, password) {
try {
await auth.createEmailPasswordSession(email, password);
this.user = await auth.get();
this.updateUI(this.user);
this.authListeners.forEach((listener) => listener(this.user));
return this.user;
} catch (error) {
console.error('Email Login failed:', error);
alert(`Login failed: ${error.message}`);
throw error;
}
}
async signUpWithEmail(email, password) {
try {
await auth.create('unique()', email, password);
await auth.createEmailPasswordSession(email, password);
this.user = await auth.get();
this.updateUI(this.user);
this.authListeners.forEach((listener) => listener(this.user));
return this.user;
} catch (error) {
console.error('Sign Up failed:', error);
alert(`Sign Up failed: ${error.message}`);
throw error;
}
}
async sendPasswordReset(email) {
try {
await auth.createRecovery(email, window.location.origin + '/reset-password.html');
alert(`Password reset email sent to ${email}`);
} catch (error) {
console.error('Password reset failed:', error);
alert(`Failed to send reset email: ${error.message}`);
throw error;
}
}
async signOut() {
try {
await auth.deleteSession('current');
this.user = null;
this.updateUI(null);
this.authListeners.forEach((listener) => listener(null));
if (window.__AUTH_GATE__) {
window.location.href = '/login';
} else {
window.location.reload();
}
} catch (error) {
console.error('Logout failed:', error);
throw error;
}
}
updateUI(user) {
const connectBtn = document.getElementById('firebase-connect-btn');
const clearDataBtn = document.getElementById('firebase-clear-cloud-btn');
const statusText = document.getElementById('firebase-status');
const emailContainer = document.getElementById('email-auth-container');
const emailToggleBtn = document.getElementById('toggle-email-auth-btn');
if (!connectBtn) return;
if (window.__AUTH_GATE__) {
connectBtn.textContent = 'Sign Out';
connectBtn.classList.add('danger');
connectBtn.onclick = () => this.signOut();
if (clearDataBtn) clearDataBtn.style.display = 'none';
if (emailContainer) emailContainer.style.display = 'none';
if (emailToggleBtn) emailToggleBtn.style.display = 'none';
if (statusText) statusText.textContent = user ? `Signed in as ${user.email}` : 'Signed in';
const accountPage = document.getElementById('page-account');
if (accountPage) {
const title = accountPage.querySelector('.section-title');
if (title) title.textContent = 'Account';
accountPage.querySelectorAll('.account-content > p, .account-content > div').forEach((el) => {
if (el.id !== 'firebase-status' && el.id !== 'auth-buttons-container') {
el.style.display = 'none';
}
});
}
const customDbBtn = document.getElementById('custom-db-btn');
if (customDbBtn) {
const pbFromEnv = !!window.__POCKETBASE_URL__;
if (pbFromEnv) {
const settingItem = customDbBtn.closest('.setting-item');
if (settingItem) settingItem.style.display = 'none';
}
}
return;
}
if (user) {
connectBtn.textContent = 'Sign Out';
connectBtn.classList.add('danger');
connectBtn.onclick = () => this.signOut();
if (clearDataBtn) clearDataBtn.style.display = 'block';
if (emailContainer) emailContainer.style.display = 'none';
if (emailToggleBtn) emailToggleBtn.style.display = 'none';
if (statusText) statusText.textContent = `Signed in as ${user.email}`;
} else {
connectBtn.textContent = 'Connect with Google';
connectBtn.classList.remove('danger');
connectBtn.onclick = () => this.signInWithGoogle();
if (clearDataBtn) clearDataBtn.style.display = 'none';
if (emailToggleBtn) emailToggleBtn.style.display = 'inline-block';
if (statusText) statusText.textContent = 'Sync your library across devices';
}
}
}
export const authManager = new AuthManager();

View File

@@ -0,0 +1,20 @@
import { Client, Account } from 'appwrite';
const getEndpoint = () => {
const hostname = window.location.hostname;
if (hostname.endsWith('monochrome.tf') || hostname === 'monochrome.tf') {
return 'https://auth.monochrome.tf/v1';
}
return 'https://auth.samidy.com/v1';
};
const client = new Client().setEndpoint(getEndpoint()).setProject('auth-for-monochrome');
const account = new Account(client);
export { client, account as auth };
export const saveFirebaseConfig = () => {
console.log('ill fix this tomorrow');
};
export const clearFirebaseConfig = () => {
console.log('ill fix this tomorrow');
};

View File

@@ -0,0 +1,648 @@
//js/accounts/pocketbase.js
import PocketBase from 'pocketbase';
import { db } from '../db.js';
import { authManager } from './auth.js';
const PUBLIC_COLLECTION = 'public_playlists';
const DEFAULT_POCKETBASE_URL = 'https://data.samidy.xyz';
const POCKETBASE_URL = localStorage.getItem('monochrome-pocketbase-url') || DEFAULT_POCKETBASE_URL;
console.log('[PocketBase] Using URL:', POCKETBASE_URL);
const pb = new PocketBase(POCKETBASE_URL);
pb.autoCancellation(false);
const syncManager = {
pb: pb,
_userRecordCache: null,
_isSyncing: false,
async _getUserRecord(uid) {
if (!uid) return null;
if (this._userRecordCache && this._userRecordCache.firebase_id === uid) {
return this._userRecordCache;
}
try {
const record = await this.pb.collection('DB_users').getFirstListItem(`firebase_id="${uid}"`, { f_id: uid });
this._userRecordCache = record;
return record;
} catch (error) {
if (error.status === 404) {
try {
const newRecord = await this.pb.collection('DB_users').create(
{
firebase_id: uid,
library: {},
history: [],
user_playlists: {},
user_folders: {},
},
{ f_id: uid }
);
this._userRecordCache = newRecord;
return newRecord;
} catch (createError) {
console.error('[PocketBase] Failed to create user:', createError);
return null;
}
}
console.error('[PocketBase] Failed to get user:', error);
return null;
}
},
async getUserData() {
const user = authManager.user;
if (!user) return null;
const record = await this._getUserRecord(user.$id);
if (!record) return null;
const library = this.safeParseInternal(record.library, 'library', {});
const history = this.safeParseInternal(record.history, 'history', []);
const userPlaylists = this.safeParseInternal(record.user_playlists, 'user_playlists', {});
const userFolders = this.safeParseInternal(record.user_folders, 'user_folders', {});
const favoriteAlbums = this.safeParseInternal(record.favorite_albums, 'favorite_albums', []);
const profile = {
username: record.username,
display_name: record.display_name,
avatar_url: record.avatar_url,
banner: record.banner,
status: record.status,
about: record.about,
website: record.website,
privacy: this.safeParseInternal(record.privacy, 'privacy', { playlists: 'public', lastfm: 'public' }),
lastfm_username: record.lastfm_username,
favorite_albums: favoriteAlbums,
};
return { library, history, userPlaylists, userFolders, profile };
},
async _updateUserJSON(uid, field, data) {
const record = await this._getUserRecord(uid);
if (!record) {
console.error('Cannot update: no user record found');
return;
}
try {
const stringifiedData = typeof data === 'string' ? data : JSON.stringify(data);
const updated = await this.pb
.collection('DB_users')
.update(record.id, { [field]: stringifiedData }, { f_id: uid });
this._userRecordCache = updated;
} catch (error) {
console.error(`Failed to sync ${field} to PocketBase:`, error);
}
},
safeParseInternal(str, fieldName, fallback) {
if (!str) return fallback;
if (typeof str !== 'string') return str;
try {
return JSON.parse(str);
} catch {
try {
// Recovery attempt: replace illegal internal quotes in name/title fields
const recovered = str.replace(/(:\s*")(.+?)("(?=\s*[,}\n\r]))/g, (match, p1, p2, p3) => {
const escapedContent = p2.replace(/(?<!\\)"/g, '\\"');
return p1 + escapedContent + p3;
});
return JSON.parse(recovered);
} catch {
try {
// Python-style fallback (Single quotes, True/False, None)
// This handles data that was incorrectly serialized as Python repr string
if (str.includes("'") || str.includes('True') || str.includes('False')) {
const jsFriendly = str
.replace(/\bTrue\b/g, 'true')
.replace(/\bFalse\b/g, 'false')
.replace(/\bNone\b/g, 'null');
// Basic safety check: ensure it looks like a structure and doesn't contain obvious code vectors
if (
(jsFriendly.trim().startsWith('[') || jsFriendly.trim().startsWith('{')) &&
!jsFriendly.match(/function|=>|window|document|alert|eval/)
) {
return new Function('return ' + jsFriendly)();
}
}
} catch (error) {
console.log(error); // Ignore fallback error
}
return fallback;
}
}
},
async syncLibraryItem(type, item, added) {
const user = authManager.user;
if (!user) return;
const record = await this._getUserRecord(user.$id);
if (!record) return;
let library = this.safeParseInternal(record.library, 'library', {});
const pluralType = type === 'mix' ? 'mixes' : `${type}s`;
const key = type === 'playlist' ? item.uuid : item.id;
if (!library[pluralType]) {
library[pluralType] = {};
}
if (added) {
library[pluralType][key] = this._minifyItem(type, item);
} else {
delete library[pluralType][key];
}
await this._updateUserJSON(user.$id, 'library', library);
},
_minifyItem(type, item) {
if (!item) return item;
const base = {
id: item.id,
addedAt: item.addedAt || Date.now(),
};
if (type === 'track') {
return {
...base,
title: item.title || null,
duration: item.duration || null,
explicit: item.explicit || false,
artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0] : null) || null,
artists: item.artists?.map((a) => ({ id: a.id, name: a.name || null })) || [],
album: item.album
? {
id: item.album.id,
title: item.album.title || null,
cover: item.album.cover || null,
releaseDate: item.album.releaseDate || null,
vibrantColor: item.album.vibrantColor || null,
artist: item.album.artist || null,
numberOfTracks: item.album.numberOfTracks || null,
}
: null,
copyright: item.copyright || null,
isrc: item.isrc || null,
trackNumber: item.trackNumber || null,
streamStartDate: item.streamStartDate || null,
version: item.version || null,
mixes: item.mixes || null,
};
}
if (type === 'album') {
return {
...base,
title: item.title || null,
cover: item.cover || null,
releaseDate: item.releaseDate || null,
explicit: item.explicit || false,
artist: item.artist
? { name: item.artist.name || null, id: item.artist.id }
: item.artists?.[0]
? { name: item.artists[0].name || null, id: item.artists[0].id }
: null,
type: item.type || null,
numberOfTracks: item.numberOfTracks || null,
};
}
if (type === 'artist') {
return {
...base,
name: item.name || null,
picture: item.picture || item.image || null,
};
}
if (type === 'playlist') {
return {
uuid: item.uuid || item.id,
addedAt: item.addedAt || Date.now(),
title: item.title || item.name || null,
image: item.image || item.squareImage || item.cover || null,
numberOfTracks: item.numberOfTracks || (item.tracks ? item.tracks.length : 0),
user: item.user ? { name: item.user.name || null } : null,
};
}
if (type === 'mix') {
return {
id: item.id,
addedAt: item.addedAt || Date.now(),
title: item.title,
subTitle: item.subTitle,
mixType: item.mixType,
cover: item.cover,
};
}
return item;
},
async syncHistoryItem(historyEntry) {
const user = authManager.user;
if (!user) return;
const record = await this._getUserRecord(user.$id);
if (!record) return;
let history = this.safeParseInternal(record.history, 'history', []);
const newHistory = [historyEntry, ...history].slice(0, 100);
await this._updateUserJSON(user.$id, 'history', newHistory);
},
async syncUserPlaylist(playlist, action) {
const user = authManager.user;
if (!user) return;
const record = await this._getUserRecord(user.$id);
if (!record) return;
let userPlaylists = this.safeParseInternal(record.user_playlists, 'user_playlists', {});
if (action === 'delete') {
delete userPlaylists[playlist.id];
await this.unpublishPlaylist(playlist.id);
} else {
userPlaylists[playlist.id] = {
id: playlist.id,
name: playlist.name,
cover: playlist.cover || null,
tracks: playlist.tracks ? playlist.tracks.map((t) => this._minifyItem('track', t)) : [],
createdAt: playlist.createdAt || Date.now(),
updatedAt: playlist.updatedAt || Date.now(),
numberOfTracks: playlist.tracks ? playlist.tracks.length : 0,
images: playlist.images || [],
isPublic: playlist.isPublic || false,
};
if (playlist.isPublic) {
await this.publishPlaylist(playlist);
}
}
await this._updateUserJSON(user.$id, 'user_playlists', userPlaylists);
},
async syncUserFolder(folder, action) {
const user = authManager.user;
if (!user) return;
const record = await this._getUserRecord(user.$id);
if (!record) return;
let userFolders = this.safeParseInternal(record.user_folders, 'user_folders', {});
if (action === 'delete') {
delete userFolders[folder.id];
} else {
userFolders[folder.id] = {
id: folder.id,
name: folder.name,
cover: folder.cover || null,
playlists: folder.playlists || [],
createdAt: folder.createdAt || Date.now(),
updatedAt: folder.updatedAt || Date.now(),
};
}
await this._updateUserJSON(user.$id, 'user_folders', userFolders);
},
async getPublicPlaylist(uuid) {
try {
const record = await this.pb
.collection(PUBLIC_COLLECTION)
.getFirstListItem(`uuid="${uuid}"`, { p_id: uuid });
let rawCover = record.image || record.cover || record.playlist_cover || '';
let extraData = this.safeParseInternal(record.data, 'data', {});
if (!rawCover && extraData && typeof extraData === 'object') {
rawCover = extraData.cover || extraData.image || '';
}
let finalCover = rawCover;
if (rawCover && !rawCover.startsWith('http') && !rawCover.startsWith('data:')) {
finalCover = this.pb.files.getUrl(record, rawCover);
}
let images = [];
let tracks = this.safeParseInternal(record.tracks, 'tracks', []);
if (!finalCover && tracks && tracks.length > 0) {
const uniqueCovers = [];
const seenCovers = new Set();
for (const track of tracks) {
const c = track.album?.cover;
if (c && !seenCovers.has(c)) {
seenCovers.add(c);
uniqueCovers.push(c);
if (uniqueCovers.length >= 4) break;
}
}
images = uniqueCovers;
}
let finalTitle = record.title || record.name || record.playlist_name;
if (!finalTitle && extraData && typeof extraData === 'object') {
finalTitle = extraData.title || extraData.name;
}
if (!finalTitle) finalTitle = 'Untitled Playlist';
let finalDescription = record.description || '';
if (!finalDescription && extraData && typeof extraData === 'object') {
finalDescription = extraData.description || '';
}
return {
...record,
id: record.uuid,
name: finalTitle,
title: finalTitle,
description: finalDescription,
cover: finalCover,
image: finalCover,
tracks: tracks,
images: images,
numberOfTracks: tracks.length,
type: 'user-playlist',
isPublic: true,
user: { name: 'Community Playlist' },
};
} catch (error) {
if (error.status === 404) return null;
console.error('Failed to fetch public playlist:', error);
throw error;
}
},
async publishPlaylist(playlist) {
if (!playlist || !playlist.id) return;
const uid = authManager.user?.$id;
if (!uid) return;
const data = {
uuid: playlist.id,
uid: uid,
firebase_id: uid,
title: playlist.name,
name: playlist.name,
playlist_name: playlist.name,
image: playlist.cover,
cover: playlist.cover,
playlist_cover: playlist.cover,
description: playlist.description || '',
tracks: JSON.stringify(playlist.tracks || []),
isPublic: true,
data: {
title: playlist.name,
cover: playlist.cover,
description: playlist.description || '',
},
};
try {
const existing = await this.pb.collection(PUBLIC_COLLECTION).getList(1, 1, {
filter: `uuid="${playlist.id}"`,
p_id: playlist.id,
});
if (existing.items.length > 0) {
await this.pb.collection(PUBLIC_COLLECTION).update(existing.items[0].id, data, { f_id: uid });
} else {
await this.pb.collection(PUBLIC_COLLECTION).create(data, { f_id: uid });
}
} catch (error) {
console.error('Failed to publish playlist:', error);
}
},
async unpublishPlaylist(uuid) {
const uid = authManager.user?.$id;
if (!uid) return;
try {
const existing = await this.pb.collection(PUBLIC_COLLECTION).getList(1, 1, {
filter: `uuid="${uuid}"`,
p_id: uuid,
});
if (existing.items && existing.items.length > 0) {
await this.pb.collection(PUBLIC_COLLECTION).delete(existing.items[0].id, { p_id: uuid, f_id: uid });
}
} catch (error) {
console.error('Failed to unpublish playlist:', error);
}
},
async getProfile(username) {
try {
const record = await this.pb.collection('DB_users').getFirstListItem(`username="${username}"`, {
fields: 'username,display_name,avatar_url,banner,status,about,website,lastfm_username,privacy,user_playlists,favorite_albums',
});
return {
...record,
privacy: this.safeParseInternal(record.privacy, 'privacy', { playlists: 'public', lastfm: 'public' }),
user_playlists: this.safeParseInternal(record.user_playlists, 'user_playlists', {}),
favorite_albums: this.safeParseInternal(record.favorite_albums, 'favorite_albums', []),
};
} catch {
return null;
}
},
async updateProfile(data) {
const user = authManager.user;
if (!user) return;
const record = await this._getUserRecord(user.$id);
if (!record) return;
const updateData = { ...data };
await this.pb.collection('DB_users').update(record.id, updateData, { f_id: user.$id });
if (this._userRecordCache) {
this._userRecordCache = { ...this._userRecordCache, ...updateData };
}
},
async isUsernameTaken(username) {
try {
const list = await this.pb.collection('DB_users').getList(1, 1, { filter: `username="${username}"` });
return list.totalItems > 0;
} catch {
return false;
}
},
async clearCloudData() {
const user = authManager.user;
if (!user) return;
try {
const record = await this._getUserRecord(user.$id);
if (record) {
await this.pb.collection('DB_users').delete(record.id, { f_id: user.$id });
this._userRecordCache = null;
alert('Cloud data cleared successfully.');
}
} catch (error) {
console.error('Failed to clear cloud data!', error);
alert('Failed to clear cloud data! :( Check console for details.');
}
},
async onAuthStateChanged(user) {
if (user) {
if (this._isSyncing) return;
this._isSyncing = true;
try {
const cloudData = await this.getUserData();
if (cloudData) {
let database = db;
if (typeof database === 'function') {
database = await database();
} else {
database = await database;
}
const getAll = async (store) => {
if (database && typeof database.getAll === 'function') return database.getAll(store);
if (database && database.db && typeof database.db.getAll === 'function')
return database.db.getAll(store);
return [];
};
const localData = {
tracks: (await getAll('favorites_tracks')) || [],
albums: (await getAll('favorites_albums')) || [],
artists: (await getAll('favorites_artists')) || [],
playlists: (await getAll('favorites_playlists')) || [],
mixes: (await getAll('favorites_mixes')) || [],
history: (await getAll('history_tracks')) || [],
userPlaylists: (await getAll('user_playlists')) || [],
userFolders: (await getAll('user_folders')) || [],
};
let { library, history, userPlaylists, userFolders } = cloudData;
let needsUpdate = false;
if (!library) library = {};
if (!library.tracks) library.tracks = {};
if (!library.albums) library.albums = {};
if (!library.artists) library.artists = {};
if (!library.playlists) library.playlists = {};
if (!library.mixes) library.mixes = {};
if (!userPlaylists) userPlaylists = {};
if (!userFolders) userFolders = {};
if (!history) history = [];
const mergeItem = (collection, item, type) => {
const id = type === 'playlist' ? item.uuid || item.id : item.id;
if (!collection[id]) {
collection[id] = this._minifyItem(type, item);
needsUpdate = true;
}
};
localData.tracks.forEach((item) => mergeItem(library.tracks, item, 'track'));
localData.albums.forEach((item) => mergeItem(library.albums, item, 'album'));
localData.artists.forEach((item) => mergeItem(library.artists, item, 'artist'));
localData.playlists.forEach((item) => mergeItem(library.playlists, item, 'playlist'));
localData.mixes.forEach((item) => mergeItem(library.mixes, item, 'mix'));
localData.userPlaylists.forEach((playlist) => {
if (!userPlaylists[playlist.id]) {
userPlaylists[playlist.id] = {
id: playlist.id,
name: playlist.name,
cover: playlist.cover || null,
tracks: playlist.tracks ? playlist.tracks.map((t) => this._minifyItem('track', t)) : [],
createdAt: playlist.createdAt || Date.now(),
updatedAt: playlist.updatedAt || Date.now(),
numberOfTracks: playlist.tracks ? playlist.tracks.length : 0,
images: playlist.images || [],
isPublic: playlist.isPublic || false,
};
needsUpdate = true;
}
});
localData.userFolders.forEach((folder) => {
if (!userFolders[folder.id]) {
userFolders[folder.id] = {
id: folder.id,
name: folder.name,
cover: folder.cover || null,
playlists: folder.playlists || [],
createdAt: folder.createdAt || Date.now(),
updatedAt: folder.updatedAt || Date.now(),
};
needsUpdate = true;
}
});
if (history.length === 0 && localData.history.length > 0) {
history = localData.history;
needsUpdate = true;
}
if (needsUpdate) {
await this._updateUserJSON(user.$id, 'library', library);
await this._updateUserJSON(user.$id, 'user_playlists', userPlaylists);
await this._updateUserJSON(user.$id, 'user_folders', userFolders);
await this._updateUserJSON(user.$id, 'history', history);
}
const convertedData = {
favorites_tracks: Object.values(library.tracks).filter((t) => t && typeof t === 'object'),
favorites_albums: Object.values(library.albums).filter((a) => a && typeof a === 'object'),
favorites_artists: Object.values(library.artists).filter((a) => a && typeof a === 'object'),
favorites_playlists: Object.values(library.playlists).filter((p) => p && typeof p === 'object'),
favorites_mixes: Object.values(library.mixes).filter((m) => m && typeof m === 'object'),
history_tracks: history,
user_playlists: Object.values(userPlaylists).filter((p) => p && typeof p === 'object'),
user_folders: Object.values(userFolders).filter((f) => f && typeof f === 'object'),
};
await database.importData(convertedData);
await new Promise((resolve) => setTimeout(resolve, 300));
window.dispatchEvent(new CustomEvent('library-changed'));
window.dispatchEvent(new CustomEvent('history-changed'));
window.dispatchEvent(new HashChangeEvent('hashchange'));
console.log('[PocketBase] ✓ Sync completed');
}
} catch (error) {
console.error('[PocketBase] Sync error:', error);
} finally {
this._isSyncing = false;
}
} else {
this._userRecordCache = null;
this._isSyncing = false;
}
},
};
if (pb) {
authManager.onAuthStateChanged(syncManager.onAuthStateChanged.bind(syncManager));
}
export { pb, syncManager };

View File

@@ -0,0 +1,755 @@
// js/analytics.js - Plausible Analytics custom event tracking
import { analyticsSettings } from './storage.js';
/**
* Check if analytics is enabled
* @returns {boolean}
*/
function isAnalyticsEnabled() {
return analyticsSettings.isEnabled();
}
/**
* Track a custom event with Plausible
* @param {string} eventName - The name of the event
* @param {object} [props] - Optional event properties
*/
export function trackEvent(eventName, props = {}) {
if (!isAnalyticsEnabled()) return;
if (window.plausible) {
try {
window.plausible(eventName, { props });
} catch {
// Silently fail if analytics is blocked
}
}
}
/**
* Track page views with custom properties
* @param {string} path - The page path
*/
export function trackPageView(path) {
trackEvent('pageview', { path });
}
// Playback Events
export function trackPlayTrack(track) {
trackEvent('Play Track', {
track_id: track?.id || 'unknown',
track_title: track?.title || 'Unknown',
artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
album_id: track?.album?.id || 'unknown',
album: track?.album?.title || 'Unknown',
duration: track?.duration || 0,
quality: track?.audioQuality || track?.quality || 'Unknown',
is_local: track?.isLocal || false,
is_explicit: track?.explicit || false,
track_number: track?.trackNumber || 0,
year: track?.album?.releaseYear || track?.album?.releaseDate || 'unknown',
});
}
export function trackPauseTrack(track) {
trackEvent('Pause Track', {
track_id: track?.id || 'unknown',
track_title: track?.title || 'Unknown',
artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
album_id: track?.album?.id || 'unknown',
album: track?.album?.title || 'Unknown',
});
}
export function trackSkipTrack(track, direction) {
trackEvent('Skip Track', {
track_id: track?.id || 'unknown',
track_title: track?.title || 'Unknown',
artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
album_id: track?.album?.id || 'unknown',
album: track?.album?.title || 'Unknown',
direction: direction,
});
}
export function trackToggleShuffle(enabled) {
trackEvent('Toggle Shuffle', { enabled });
}
export function trackToggleRepeat(mode) {
trackEvent('Toggle Repeat', { mode });
}
export function trackTrackComplete(track, completionPercent) {
trackEvent('Track Complete', {
track_id: track?.id || 'unknown',
track_title: track?.title || 'Unknown',
artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
album_id: track?.album?.id || 'unknown',
album: track?.album?.title || 'Unknown',
duration: track?.duration || 0,
completion_percent: completionPercent || 100,
});
}
export function trackSetVolume(level) {
// Only track volume changes at coarse intervals to avoid spam
const roundedLevel = Math.round(level * 10) / 10;
trackEvent('Set Volume', { level: roundedLevel });
}
export function trackToggleMute(muted) {
trackEvent('Toggle Mute', { muted });
}
// Track listening progress milestones (10%, 50%, 90%, 100%)
export function trackListeningProgress(track, percent) {
trackEvent('Listening Progress', {
track_id: track?.id || 'unknown',
track_title: track?.title || 'Unknown',
artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
percent: percent,
});
}
// Search Events
export function trackSearch(query, resultsCount) {
trackEvent('Search', {
query_length: query?.length || 0,
has_results: resultsCount > 0,
results_count: resultsCount,
});
}
export function trackSearchTabChange(tab) {
trackEvent('Search Tab Change', { tab });
}
// Navigation Events
export function trackNavigate(path, pageType) {
trackEvent('Navigate', {
path,
page_type: pageType,
});
}
export function trackSidebarNavigation(item) {
trackEvent('Sidebar Navigation', { item });
}
// Library Events
export function trackLikeTrack(track) {
trackEvent('Like Track', {
track_id: track?.id || 'unknown',
track_title: track?.title || 'Unknown',
artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
album_id: track?.album?.id || 'unknown',
album: track?.album?.title || 'Unknown',
});
}
export function trackUnlikeTrack(track) {
trackEvent('Unlike Track', {
track_id: track?.id || 'unknown',
track_title: track?.title || 'Unknown',
artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
album_id: track?.album?.id || 'unknown',
});
}
export function trackLikeAlbum(album) {
trackEvent('Like Album', {
album_title: album?.title || 'Unknown',
artist: album?.artist?.name || 'Unknown',
});
}
export function trackUnlikeAlbum(album) {
trackEvent('Unlike Album', {
album_title: album?.title || 'Unknown',
});
}
export function trackLikeArtist(artist) {
trackEvent('Like Artist', {
artist_name: artist?.name || 'Unknown',
});
}
export function trackUnlikeArtist(artist) {
trackEvent('Unlike Artist', {
artist_name: artist?.name || 'Unknown',
});
}
export function trackLikePlaylist(playlist) {
trackEvent('Like Playlist', {
playlist_name: playlist?.title || playlist?.name || 'Unknown',
});
}
export function trackUnlikePlaylist(playlist) {
trackEvent('Unlike Playlist', {
playlist_name: playlist?.title || playlist?.name || 'Unknown',
});
}
// Playlist Management Events
export function trackCreatePlaylist(playlist, source) {
trackEvent('Create Playlist', {
playlist_name: playlist?.name || 'Unknown',
track_count: playlist?.tracks?.length || 0,
is_public: playlist?.isPublic || false,
source: source || 'manual',
});
}
export function trackEditPlaylist(playlist) {
trackEvent('Edit Playlist', {
playlist_name: playlist?.name || 'Unknown',
});
}
export function trackDeletePlaylist(playlistName) {
trackEvent('Delete Playlist', { playlist_name: playlistName });
}
export function trackAddToPlaylist(track, playlist) {
trackEvent('Add to Playlist', {
track_title: track?.title || 'Unknown',
playlist_name: playlist?.name || 'Unknown',
});
}
export function trackRemoveFromPlaylist(track, playlist) {
trackEvent('Remove from Playlist', {
track_title: track?.title || 'Unknown',
playlist_name: playlist?.name || 'Unknown',
});
}
export function trackCreateFolder(folder) {
trackEvent('Create Folder', {
folder_name: folder?.name || 'Unknown',
});
}
export function trackDeleteFolder(folderName) {
trackEvent('Delete Folder', { folder_name: folderName });
}
// Playback Actions
export function trackPlayAlbum(album, shuffle) {
trackEvent('Play Album', {
album_id: album?.id || 'unknown',
album_title: album?.title || 'Unknown',
artist_id: album?.artist?.id || 'unknown',
artist: album?.artist?.name || 'Unknown',
shuffle: shuffle || false,
track_count: album?.numberOfTracks || album?.tracks?.length || 0,
year: album?.releaseYear || album?.releaseDate || 'unknown',
});
}
export function trackPlayPlaylist(playlist, shuffle) {
trackEvent('Play Playlist', {
playlist_id: playlist?.id || 'unknown',
playlist_name: playlist?.title || playlist?.name || 'Unknown',
shuffle: shuffle || false,
track_count: playlist?.tracks?.length || 0,
is_public: playlist?.isPublic || false,
});
}
export function trackPlayArtistRadio(artist) {
trackEvent('Play Artist Radio', {
artist_id: artist?.id || 'unknown',
artist_name: artist?.name || 'Unknown',
});
}
export function trackShuffleLikedTracks(count) {
trackEvent('Shuffle Liked Tracks', { track_count: count });
}
// Download Events
export function trackDownloadTrack(track, quality) {
trackEvent('Download Track', {
track_id: track?.id || 'unknown',
track_title: track?.title || 'Unknown',
artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
album_id: track?.album?.id || 'unknown',
quality: quality || 'Unknown',
});
}
export function trackDownloadAlbum(album, quality) {
trackEvent('Download Album', {
album_id: album?.id || 'unknown',
album_title: album?.title || 'Unknown',
artist_id: album?.artist?.id || 'unknown',
artist: album?.artist?.name || 'Unknown',
track_count: album?.numberOfTracks || album?.tracks?.length || 0,
quality: quality || 'Unknown',
});
}
export function trackDownloadPlaylist(playlist, quality) {
trackEvent('Download Playlist', {
playlist_id: playlist?.id || 'unknown',
playlist_name: playlist?.title || playlist?.name || 'Unknown',
track_count: playlist?.tracks?.length || 0,
quality: quality || 'Unknown',
});
}
export function trackDownloadLikedTracks(count, quality) {
trackEvent('Download Liked Tracks', {
track_count: count,
quality: quality || 'Unknown',
});
}
export function trackDownloadDiscography(artist, selection) {
trackEvent('Download Discography', {
artist_id: artist?.id || 'unknown',
artist_name: artist?.name || 'Unknown',
include_albums: selection?.includeAlbums || false,
include_eps: selection?.includeEPs || false,
include_singles: selection?.includeSingles || false,
});
}
// Queue Management
export function trackAddToQueue(track, position) {
trackEvent('Add to Queue', {
track_id: track?.id || 'unknown',
track_title: track?.title || 'Unknown',
artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
album_id: track?.album?.id || 'unknown',
position: position || 'end',
});
}
export function trackPlayNext(track) {
trackEvent('Play Next', {
track_id: track?.id || 'unknown',
track_title: track?.title || 'Unknown',
artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
album_id: track?.album?.id || 'unknown',
});
}
export function trackClearQueue() {
trackEvent('Clear Queue');
}
export function trackShuffleQueue() {
trackEvent('Shuffle Queue');
}
// Context Menu Actions
export function trackContextMenuAction(action, itemType, item) {
trackEvent('Context Menu Action', {
action,
item_type: itemType,
item_name: item?.title || item?.name || 'Unknown',
});
}
export function trackBlockTrack(track) {
trackEvent('Block Track', {
track_id: track?.id || 'unknown',
track_title: track?.title || 'Unknown',
artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
album_id: track?.album?.id || 'unknown',
});
}
export function trackUnblockTrack(track) {
trackEvent('Unblock Track', {
track_id: track?.id || 'unknown',
track_title: track?.title || 'Unknown',
});
}
export function trackBlockAlbum(album) {
trackEvent('Block Album', {
album_id: album?.id || 'unknown',
album_title: album?.title || 'Unknown',
artist_id: album?.artist?.id || 'unknown',
});
}
export function trackUnblockAlbum(album) {
trackEvent('Unblock Album', {
album_id: album?.id || 'unknown',
album_title: album?.title || 'Unknown',
});
}
export function trackBlockArtist(artist) {
trackEvent('Block Artist', {
artist_id: artist?.id || 'unknown',
artist_name: artist?.name || 'Unknown',
});
}
export function trackUnblockArtist(artist) {
trackEvent('Unblock Artist', {
artist_id: artist?.id || 'unknown',
artist_name: artist?.name || 'Unknown',
});
}
export function trackCopyLink(type, id) {
trackEvent('Copy Link', { type, id });
}
export function trackOpenInNewTab(type, id) {
trackEvent('Open in New Tab', { type, id });
}
// Lyrics Events
export function trackOpenLyrics(track) {
trackEvent('Open Lyrics', {
track_title: track?.title || 'Unknown',
artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
});
}
export function trackCloseLyrics(track) {
trackEvent('Close Lyrics', {
track_title: track?.title || 'Unknown',
});
}
// Fullscreen/Cover View Events
export function trackOpenFullscreenCover(track) {
trackEvent('Open Fullscreen Cover', {
track_title: track?.title || 'Unknown',
});
}
export function trackCloseFullscreenCover() {
trackEvent('Close Fullscreen Cover');
}
export function trackToggleVisualizer(enabled) {
trackEvent('Toggle Visualizer', { enabled });
}
export function trackToggleLyricsFullscreen(enabled) {
trackEvent('Toggle Lyrics Fullscreen', { enabled });
}
// Settings Events
export function trackChangeSetting(setting, value) {
trackEvent('Change Setting', {
setting,
value: typeof value === 'object' ? JSON.stringify(value) : String(value),
});
}
export function trackChangeTheme(theme) {
trackEvent('Change Theme', { theme });
}
export function trackChangeQuality(type, quality) {
trackEvent('Change Quality', { type, quality });
}
export function trackChangeVolume(volume) {
trackEvent('Change Volume', { volume: Math.round(volume * 100) });
}
export function trackToggleScrobbler(service, enabled) {
trackEvent('Toggle Scrobbler', { service, enabled });
}
export function trackConnectScrobbler(service) {
trackEvent('Connect Scrobbler', { service });
}
export function trackDisconnectScrobbler(service) {
trackEvent('Disconnect Scrobbler', { service });
}
// Local Files Events
export function trackSelectLocalFolder(fileCount) {
trackEvent('Select Local Folder', { file_count: fileCount });
}
export function trackPlayLocalFile(track) {
trackEvent('Play Local File', {
track_title: track?.title || 'Unknown',
});
}
export function trackChangeLocalFolder() {
trackEvent('Change Local Folder');
}
// Import/Export Events
export function trackImportCSV(playlistName, trackCount, missingCount) {
trackEvent('Import CSV', {
playlist_name: playlistName,
track_count: trackCount,
missing_count: missingCount,
});
}
export function trackImportJSPF(playlistName, trackCount, missingCount, source) {
trackEvent('Import JSPF', {
playlist_name: playlistName,
track_count: trackCount,
missing_count: missingCount,
source: source || 'unknown',
});
}
export function trackImportXSPF(playlistName, trackCount, missingCount) {
trackEvent('Import XSPF', {
playlist_name: playlistName,
track_count: trackCount,
missing_count: missingCount,
});
}
export function trackImportXML(playlistName, trackCount, missingCount) {
trackEvent('Import XML', {
playlist_name: playlistName,
track_count: trackCount,
missing_count: missingCount,
});
}
export function trackImportM3U(playlistName, trackCount, missingCount) {
trackEvent('Import M3U', {
playlist_name: playlistName,
track_count: trackCount,
missing_count: missingCount,
});
}
// Sleep Timer Events
export function trackSetSleepTimer(minutes) {
trackEvent('Set Sleep Timer', { minutes });
}
export function trackCancelSleepTimer() {
trackEvent('Cancel Sleep Timer');
}
// History Events
export function trackClearHistory() {
trackEvent('Clear History');
}
export function trackClearRecent() {
trackEvent('Clear Recent');
}
// Casting Events
export function trackStartCasting(deviceType) {
trackEvent('Start Casting', { device_type: deviceType });
}
export function trackStopCasting() {
trackEvent('Stop Casting');
}
// Keyboard Shortcuts
export function trackKeyboardShortcut(key) {
trackEvent('Keyboard Shortcut', { key });
}
// Pinning Events
export function trackPinItem(type, item) {
trackEvent('Pin Item', {
type,
item_name: item?.title || item?.name || 'Unknown',
});
}
export function trackUnpinItem(type, item) {
trackEvent('Unpin Item', {
type,
item_name: item?.title || item?.name || 'Unknown',
});
}
// Side Panel Events
export function trackOpenSidePanel(panelType) {
trackEvent('Open Side Panel', { panel_type: panelType });
}
export function trackCloseSidePanel() {
trackEvent('Close Side Panel');
}
// Queue Panel Events
export function trackOpenQueue() {
trackEvent('Open Queue');
}
export function trackCloseQueue() {
trackEvent('Close Queue');
}
// Mix Events
export function trackStartMix(sourceType, source) {
trackEvent('Start Mix', {
source_type: sourceType,
source_name: source?.title || source?.name || 'Unknown',
});
}
export function trackPlayMix(mixId) {
trackEvent('Play Mix', { mix_id: mixId });
}
// Search History Events
export function trackClearSearchHistory() {
trackEvent('Clear Search History');
}
export function trackClickSearchHistory(query) {
trackEvent('Click Search History', { query_length: query?.length || 0 });
}
// PWA/Update Events
export function trackPwaInstall() {
trackEvent('PWA Install');
}
export function trackPwaUpdate() {
trackEvent('PWA Update');
}
export function trackDismissUpdate() {
trackEvent('Dismiss Update');
}
// Sort Events
export function trackChangeSort(sortType) {
trackEvent('Change Sort', { sort_type: sortType });
}
// Modal Events
export function trackOpenModal(modalName) {
trackEvent('Open Modal', { modal_name: modalName });
}
export function trackCloseModal(modalName) {
trackEvent('Close Modal', { modal_name: modalName });
}
// Sharing Events
export function trackSharePlaylist(playlist, isPublic) {
trackEvent('Share Playlist', {
playlist_name: playlist?.name || 'Unknown',
is_public: isPublic,
});
}
// Audio Effects Events
export function trackChangePlaybackSpeed(speed) {
trackEvent('Change Playback Speed', { speed });
}
export function trackToggleReplayGain(mode) {
trackEvent('Toggle ReplayGain', { mode });
}
export function trackChangeEqualizer(preset) {
trackEvent('Change Equalizer', { preset });
}
// Waveform Events
export function trackToggleWaveform(enabled) {
trackEvent('Toggle Waveform', { enabled });
}
// Error Events
export function trackPlaybackError(errorType, track) {
trackEvent('Playback Error', {
error_type: errorType,
track_title: track?.title || 'Unknown',
});
}
export function trackSearchError(query) {
trackEvent('Search Error', { query_length: query?.length || 0 });
}
export function trackApiError(endpoint) {
trackEvent('API Error', { endpoint });
}
// Feature Discovery Events
export function trackViewFeature(feature) {
trackEvent('View Feature', { feature });
}
export function trackUseFeature(feature) {
trackEvent('Use Feature', { feature });
}
// Session Events
export function trackSessionStart() {
trackEvent('Session Start', {
user_agent: navigator.userAgent,
screen_width: window.screen.width,
screen_height: window.screen.height,
language: navigator.language,
});
}
export function trackSessionEnd(duration) {
trackEvent('Session End', { duration });
}
// Initialize analytics on page load
export function initAnalytics() {
if (!isAnalyticsEnabled()) return;
// Track initial page view
trackPageView(window.location.pathname);
// Track session start
trackSessionStart();
// Track navigation changes
let lastPath = window.location.pathname;
setInterval(() => {
const currentPath = window.location.pathname;
if (currentPath !== lastPath) {
trackPageView(currentPath);
lastPath = currentPath;
}
}, 500);
// Track online/offline status
window.addEventListener('online', () => trackEvent('Go Online'));
window.addEventListener('offline', () => trackEvent('Go Offline'));
// Track visibility changes (app focus/blur)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
trackEvent('App Background');
} else {
trackEvent('App Foreground');
}
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,785 @@
// js/audio-context.js
// Shared Audio Context Manager - handles EQ and provides context for visualizer
// Supports 3-32 parametric EQ bands
import { equalizerSettings, monoAudioSettings } from './storage.js';
// Generate frequency array for given number of bands using logarithmic spacing
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
const frequencies = [];
const safeMin = Math.max(10, minFreq);
const safeMax = Math.min(96000, maxFreq);
for (let i = 0; i < bandCount; i++) {
// Logarithmic interpolation
const t = i / (bandCount - 1);
const freq = safeMin * Math.pow(safeMax / safeMin, t);
frequencies.push(Math.round(freq));
}
return frequencies;
}
// Generate frequency labels for display
function generateFrequencyLabels(frequencies) {
return frequencies.map((freq) => {
if (freq < 1000) {
return freq.toString();
} else if (freq < 10000) {
return (freq / 1000).toFixed(freq % 1000 === 0 ? 0 : 1) + 'K';
} else {
return (freq / 1000).toFixed(0) + 'K';
}
});
}
// EQ Presets (16-band default)
const EQ_PRESETS_16 = {
flat: { name: 'Flat', gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] },
bass_boost: { name: 'Bass Boost', gains: [6, 5, 4.5, 4, 3, 2, 1, 0.5, 0, 0, 0, 0, 0, 0, 0, 0] },
bass_reducer: { name: 'Bass Reducer', gains: [-6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] },
treble_boost: { name: 'Treble Boost', gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 5.5, 6] },
treble_reducer: { name: 'Treble Reducer', gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -2, -3, -4, -5, -5.5, -6] },
vocal_boost: { name: 'Vocal Boost', gains: [-2, -1, 0, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, 0, -1, -2] },
loudness: { name: 'Loudness', gains: [5, 4, 3, 1, 0, -1, -1, 0, 0, 1, 2, 3, 4, 4.5, 4, 3] },
rock: { name: 'Rock', gains: [4, 3.5, 3, 2, -1, -2, -1, 1, 2, 3, 3.5, 4, 4, 3, 2, 1] },
pop: { name: 'Pop', gains: [-1, 0, 1, 2, 3, 3, 2, 1, 0, 1, 2, 2, 2, 2, 1, 0] },
classical: { name: 'Classical', gains: [3, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 3, 2] },
jazz: { name: 'Jazz', gains: [3, 2, 1, 1, -1, -1, 0, 1, 2, 2, 2, 2, 2, 2, 2, 2] },
electronic: { name: 'Electronic', gains: [4, 3.5, 3, 1, 0, -1, 0, 1, 2, 3, 3, 2, 2, 3, 4, 3.5] },
hip_hop: { name: 'Hip-Hop', gains: [5, 4.5, 4, 3, 1, 0, 0, 1, 1, 1, 1, 1, 1, 2, 2, 2] },
r_and_b: { name: 'R&B', gains: [3, 5, 4, 2, 1, 0, 1, 1, 1, 1, 2, 2, 2, 1, 1, 1] },
acoustic: { name: 'Acoustic', gains: [3, 2, 1, 1, 2, 2, 1, 0, 0, 1, 1, 2, 3, 3, 2, 1] },
podcast: { name: 'Podcast / Speech', gains: [-3, -2, -1, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, -1, -2, -3] },
};
// Interpolate 16-band preset to target band count
function interpolatePreset(preset16, targetBands) {
if (targetBands === 16) return [...preset16];
const result = [];
for (let i = 0; i < targetBands; i++) {
const sourceIndex = (i / (targetBands - 1)) * (preset16.length - 1);
const indexLow = Math.floor(sourceIndex);
const indexHigh = Math.min(Math.ceil(sourceIndex), preset16.length - 1);
const fraction = sourceIndex - indexLow;
const lowValue = preset16[indexLow] || 0;
const highValue = preset16[indexHigh] || 0;
const interpolated = lowValue + (highValue - lowValue) * fraction;
result.push(Math.round(interpolated * 10) / 10);
}
return result;
}
// Get presets for given band count
function getPresetsForBandCount(bandCount) {
const presets = {};
for (const [key, preset] of Object.entries(EQ_PRESETS_16)) {
presets[key] = {
name: preset.name,
gains: interpolatePreset(preset.gains, bandCount),
};
}
return presets;
}
// Default export for backwards compatibility (16 bands)
const EQ_PRESETS = EQ_PRESETS_16;
class AudioContextManager {
constructor() {
this.audioContext = null;
this.source = null;
this.analyser = null;
this.filters = [];
this.outputNode = null;
this.volumeNode = null;
this.isInitialized = false;
this.isEQEnabled = false;
this.isMonoAudioEnabled = false;
this.monoMergerNode = null;
this.audio = null;
this.currentVolume = 1.0;
// Band configuration
this.bandCount = equalizerSettings.getBandCount();
this.freqRange = equalizerSettings.getFreqRange();
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
this.currentGains = new Array(this.bandCount).fill(0);
// Callbacks for audio graph changes (for visualizers like Butterchurn)
this._graphChangeCallbacks = [];
// Load saved settings
this._loadSettings();
}
/**
* Update band count and reinitialize EQ
*/
setBandCount(count) {
const newCount = Math.max(
equalizerSettings.MIN_BANDS,
Math.min(equalizerSettings.MAX_BANDS, parseInt(count, 10) || 16)
);
if (newCount === this.bandCount) return;
// Save new band count
equalizerSettings.setBandCount(newCount);
// Update configuration
this.bandCount = newCount;
this.frequencies = generateFrequencies(newCount, this.freqRange.min, this.freqRange.max);
// Interpolate current gains to new band count
const newGains = equalizerSettings._interpolateGains(this.currentGains, newCount);
this.currentGains = newGains;
equalizerSettings.setGains(newGains);
// Reinitialize EQ if already initialized
if (this.isInitialized && this.audioContext) {
this._destroyEQ();
this._createEQ();
// Reconnect the audio graph without interrupting playback
this._connectGraph();
}
// Dispatch event for UI update
window.dispatchEvent(
new CustomEvent('equalizer-band-count-changed', {
detail: { bandCount: newCount, frequencies: this.frequencies },
})
);
}
/**
* Update frequency range and reinitialize EQ
*/
setFreqRange(minFreq, maxFreq) {
const newMin = Math.max(10, Math.min(96000, parseInt(minFreq, 10) || 20));
const newMax = Math.max(10, Math.min(96000, parseInt(maxFreq, 10) || 20000));
if (newMin >= newMax) {
console.warn('[AudioContext] Invalid frequency range: min must be less than max');
return false;
}
if (newMin === this.freqRange.min && newMax === this.freqRange.max) return true;
// Save new frequency range
equalizerSettings.setFreqRange(newMin, newMax);
// Update configuration
this.freqRange = { min: newMin, max: newMax };
this.frequencies = generateFrequencies(this.bandCount, newMin, newMax);
// Reinitialize EQ if already initialized
if (this.isInitialized && this.audioContext) {
this._destroyEQ();
this._createEQ();
// Reconnect the audio graph without interrupting playback
this._connectGraph();
}
// Dispatch event for UI update
window.dispatchEvent(
new CustomEvent('equalizer-freq-range-changed', {
detail: { min: newMin, max: newMax, frequencies: this.frequencies },
})
);
return true;
}
/**
* Destroy EQ filters
*/
_destroyEQ() {
if (this.filters) {
this.filters.forEach((filter) => {
try {
filter.disconnect();
} catch {
/* ignore */
}
});
}
this.filters = [];
// Destroy preamp node
if (this.preampNode) {
try {
this.preampNode.disconnect();
} catch {
/* ignore */
}
this.preampNode = null;
}
}
/**
* Create EQ filters
*/
_createEQ() {
if (!this.audioContext) return;
// Create preamp node
if (!this.preampNode) {
this.preampNode = this.audioContext.createGain();
}
// Set preamp gain
const preampValue = this.preamp || 0;
const gainValue = Math.pow(10, preampValue / 20);
this.preampNode.gain.value = gainValue;
// Create biquad filters for each frequency band
this.filters = this.frequencies.map((freq, index) => {
const filter = this.audioContext.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
filter.Q.value = this._calculateQ(index);
filter.gain.value = this.currentGains[index] || 0;
return filter;
});
// Create volume node if not exists
if (!this.volumeNode) {
this.volumeNode = this.audioContext.createGain();
}
}
/**
* Calculate Q factor for each band
*/
_calculateQ(_index) {
// Scale Q based on band count for consistent sound
const baseQ = 2.5;
const scalingFactor = Math.sqrt(16 / this.bandCount);
return baseQ * scalingFactor;
}
/**
* Register a callback to be called when audio graph is reconnected
* @param {Function} callback - Function to call when graph changes
* @returns {Function} - Unregister function
*/
onGraphChange(callback) {
this._graphChangeCallbacks.push(callback);
return () => {
const index = this._graphChangeCallbacks.indexOf(callback);
if (index > -1) {
this._graphChangeCallbacks.splice(index, 1);
}
};
}
/**
* Notify all registered callbacks that graph has changed
*/
_notifyGraphChange() {
this._graphChangeCallbacks.forEach((callback) => {
try {
callback(this.source);
} catch (e) {
console.warn('[AudioContext] Graph change callback failed:', e);
}
});
}
/**
* Initialize the audio context and connect to the audio element
* This should be called when audio starts playing
*/
init(audioElement) {
if (this.isInitialized) return;
if (!audioElement) return;
this.audio = audioElement;
// Detect iOS - skip Web Audio initialization on iOS to avoid lock screen audio issues
// iOS suspends AudioContext when screen locks, and MediaSession controls don't count
// as user gestures to resume it, causing audio to play silently.
// Use window.__IS_IOS__ (set before UA spoof in index.html) so detection works on real iOS.
const isIOS = typeof window !== 'undefined' && window.__IS_IOS__ === true;
if (isIOS) {
console.log('[AudioContext] Skipping Web Audio initialization on iOS for lock screen compatibility');
// Don't set isInitialized - let it remain false so isReady() returns false
// This prevents other code from trying to use the non-existent audio context
return;
}
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
// "playback" latency hint maximizes buffer size to prevent audio glitches (stuttering),
// which is critical for high-fidelity music listening.
// We also attempt to request 192kHz sample rate for high-res audio support.
const highResOptions = { sampleRate: 192000, latencyHint: 'playback' };
try {
this.audioContext = new AudioContext(highResOptions);
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
} catch (e) {
console.warn('[AudioContext] 192kHz/playback init failed, falling back to system defaults:', e);
// Fallback: Try just playback latency preference without forcing sample rate
try {
this.audioContext = new AudioContext({ latencyHint: 'playback' });
console.log(`[AudioContext] Created with system default rate: ${this.audioContext.sampleRate}Hz`);
} catch (e2) {
console.warn('[AudioContext] Playback latency hint failed, using defaults:', e2);
this.audioContext = new AudioContext();
}
}
// Create the media element source
this.source = this.audioContext.createMediaElementSource(audioElement);
// Create analyser for visualizer
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.7;
// Create biquad filters for EQ with dynamic band count
this._createEQ();
// Create output gain node
this.outputNode = this.audioContext.createGain();
this.outputNode.gain.value = 1;
// Create volume node
this.volumeNode = this.audioContext.createGain();
this.volumeNode.gain.value = this.currentVolume;
// Create mono audio merger node
this.monoMergerNode = this.audioContext.createChannelMerger(2);
// Connect the audio graph based on EQ and mono state
this._connectGraph();
this.isInitialized = true;
console.log(`[AudioContext] Initialized with ${this.bandCount}-band EQ`);
} catch (e) {
console.warn('[AudioContext] Init failed:', e);
}
}
/**
* Connect the audio graph based on EQ and mono audio state
*/
_connectGraph() {
if (!this.source || !this.audioContext) return;
try {
// Disconnect everything first
this.source.disconnect();
this.outputNode.disconnect();
if (this.volumeNode) {
this.volumeNode.disconnect();
}
this.analyser.disconnect();
if (this.monoMergerNode) {
try {
this.monoMergerNode.disconnect();
} catch {
// Ignore if not connected
}
}
let lastNode = this.source;
// Apply mono audio if enabled
if (this.isMonoAudioEnabled && this.monoMergerNode) {
// Create a gain node to mix channels before the merger
const monoGain = this.audioContext.createGain();
monoGain.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
// Connect source to mono gain
this.source.connect(monoGain);
// Connect mono gain to both inputs of the merger
monoGain.connect(this.monoMergerNode, 0, 0);
monoGain.connect(this.monoMergerNode, 0, 1);
lastNode = this.monoMergerNode;
console.log('[AudioContext] Mono audio enabled');
}
if (this.isEQEnabled && this.filters.length > 0) {
// EQ enabled: lastNode -> preamp -> EQ filters -> output -> analyser -> volume -> destination
// Connect filter chain
for (let i = 0; i < this.filters.length - 1; i++) {
this.filters[i].connect(this.filters[i + 1]);
}
// Connect preamp to first filter
if (this.preampNode) {
lastNode.connect(this.preampNode);
this.preampNode.connect(this.filters[0]);
} else {
lastNode.connect(this.filters[0]);
}
this.filters[this.filters.length - 1].connect(this.outputNode);
this.outputNode.connect(this.analyser);
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
console.log('[AudioContext] EQ connected');
} else {
// EQ disabled: lastNode -> analyser -> volume -> destination
lastNode.connect(this.analyser);
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
}
// Notify visualizers that graph has been reconnected
this._notifyGraphChange();
} catch (e) {
console.warn('[AudioContext] Failed to connect graph:', e);
// Fallback: direct connection
try {
this.source.connect(this.audioContext.destination);
} catch {
/* ignore */
}
}
}
/**
* Resume audio context (required after user interaction)
* @returns {Promise<boolean>} - Returns true if context is running
*/
async resume() {
if (!this.audioContext) return false;
console.log('[AudioContext] Current state:', this.audioContext.state);
if (this.audioContext.state === 'suspended') {
try {
await this.audioContext.resume();
console.log('[AudioContext] Resumed successfully, state:', this.audioContext.state);
} catch (e) {
console.warn('[AudioContext] Failed to resume:', e);
}
}
// Ensure graph is connected after resuming (iOS may disconnect when suspended)
if (this.isInitialized && this.audioContext.state === 'running') {
this._connectGraph();
}
return this.audioContext.state === 'running';
}
/**
* Get the analyser node for the visualizer
*/
getAnalyser() {
return this.analyser;
}
/**
* Get the audio context
*/
getAudioContext() {
return this.audioContext;
}
/**
* Get the source node for visualizers
*/
getSourceNode() {
return this.source;
}
/**
* Check if initialized and active
*/
isReady() {
return this.isInitialized && this.audioContext !== null;
}
/**
* Set the volume level (0.0 to 1.0)
* @param {number} value - Volume level
*/
setVolume(value) {
this.currentVolume = Math.max(0, Math.min(1, value));
if (this.volumeNode && this.audioContext) {
const now = this.audioContext.currentTime;
this.volumeNode.gain.setTargetAtTime(this.currentVolume, now, 0.01);
}
}
/**
* Toggle EQ on/off
*/
toggleEQ(enabled) {
this.isEQEnabled = enabled;
equalizerSettings.setEnabled(enabled);
if (this.isInitialized) {
this._connectGraph();
}
return this.isEQEnabled;
}
/**
* Check if EQ is active
*/
isEQActive() {
return this.isInitialized && this.isEQEnabled;
}
/**
* Toggle mono audio on/off
*/
toggleMonoAudio(enabled) {
this.isMonoAudioEnabled = enabled;
monoAudioSettings.setEnabled(enabled);
if (this.isInitialized) {
this._connectGraph();
}
return this.isMonoAudioEnabled;
}
/**
* Check if mono audio is active
*/
isMonoAudioActive() {
return this.isInitialized && this.isMonoAudioEnabled;
}
/**
* Get current gain range
*/
getRange() {
return equalizerSettings.getRange();
}
/**
* Clamp gain to valid range
*/
_clampGain(gainDb) {
const range = this.getRange();
return Math.max(range.min, Math.min(range.max, gainDb));
}
/**
* Set gain for a specific band
*/
setBandGain(bandIndex, gainDb) {
if (bandIndex < 0 || bandIndex >= this.bandCount) return;
const clampedGain = this._clampGain(gainDb);
this.currentGains[bandIndex] = clampedGain;
if (this.filters[bandIndex] && this.audioContext) {
const now = this.audioContext.currentTime;
this.filters[bandIndex].gain.setTargetAtTime(clampedGain, now, 0.01);
}
equalizerSettings.setGains(this.currentGains);
}
/**
* Set all band gains at once
*/
setAllGains(gains) {
if (!Array.isArray(gains)) return;
// Ensure gains array matches current band count
let adjustedGains = gains;
if (gains.length !== this.bandCount) {
adjustedGains = equalizerSettings._interpolateGains(gains, this.bandCount);
}
const now = this.audioContext?.currentTime || 0;
adjustedGains.forEach((gain, index) => {
const clampedGain = this._clampGain(gain);
this.currentGains[index] = clampedGain;
if (this.filters[index]) {
this.filters[index].gain.setTargetAtTime(clampedGain, now, 0.01);
}
});
equalizerSettings.setGains(this.currentGains);
}
/**
* Apply a preset
*/
applyPreset(presetKey) {
const presets = getPresetsForBandCount(this.bandCount);
const preset = presets[presetKey];
if (!preset) return;
this.setAllGains(preset.gains);
equalizerSettings.setPreset(presetKey);
}
/**
* Reset all bands to flat
*/
reset() {
this.setAllGains(new Array(this.bandCount).fill(0));
equalizerSettings.setPreset('flat');
}
/**
* Get current gains
*/
getGains() {
return [...this.currentGains];
}
/**
* Get current band count
*/
getBandCount() {
return this.bandCount;
}
/**
* Load settings from storage
*/
_loadSettings() {
this.isEQEnabled = equalizerSettings.isEnabled();
this.bandCount = equalizerSettings.getBandCount();
this.freqRange = equalizerSettings.getFreqRange();
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
this.currentGains = equalizerSettings.getGains(this.bandCount);
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
this.preamp = equalizerSettings.getPreamp();
}
/**
* Set preamp value in dB
* @param {number} db - Preamp value in dB (-20 to +20)
*/
setPreamp(db) {
const clampedDb = Math.max(-20, Math.min(20, parseFloat(db) || 0));
this.preamp = clampedDb;
equalizerSettings.setPreamp(clampedDb);
// Update preamp node if it exists
if (this.preampNode && this.audioContext) {
const gainValue = Math.pow(10, clampedDb / 20);
const now = this.audioContext.currentTime;
this.preampNode.gain.setTargetAtTime(gainValue, now, 0.01);
}
}
/**
* Get current preamp value
* @returns {number} Current preamp value in dB
*/
getPreamp() {
return this.preamp || 0;
}
/**
* Export equalizer settings to text format
* @returns {string} Exported settings in text format
*/
exportEQToText() {
const lines = [];
const preampValue = this.getPreamp();
lines.push(`Preamp: ${preampValue.toFixed(1)} dB`);
this.frequencies.forEach((freq, index) => {
const gain = this.currentGains[index] || 0;
const filterNum = index + 1;
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`);
});
return lines.join('\n');
}
/**
* Import equalizer settings from text format
* @param {string} text - Text format settings
* @returns {boolean} True if import was successful
*/
importEQFromText(text) {
try {
const lines = text
.split('\n')
.map((line) => line.trim())
.filter((line) => line);
const filters = [];
let preamp = 0;
for (const line of lines) {
// Parse preamp
const preampMatch = line.match(/^Preamp:\s*([+-]?\d+\.?\d*)\s*dB$/i);
if (preampMatch) {
preamp = parseFloat(preampMatch[1]);
continue;
}
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
const filterMatch = line.match(
/^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB\s+Q\s+(\d+\.?\d*)/i
);
if (filterMatch) {
const type = filterMatch[1].toUpperCase();
const freq = parseInt(filterMatch[2], 10);
const gain = parseFloat(filterMatch[3]);
const q = parseFloat(filterMatch[4]);
filters.push({ type, freq, gain, q });
}
}
if (filters.length === 0) {
console.warn('[AudioContext] No valid filters found in import text');
return false;
}
// Apply preamp
this.setPreamp(preamp);
// If different number of bands, adjust
if (filters.length !== this.bandCount) {
const newCount = Math.max(
equalizerSettings.MIN_BANDS,
Math.min(equalizerSettings.MAX_BANDS, filters.length)
);
this.setBandCount(newCount);
}
// Extract gains from filters
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
this.setAllGains(gains);
// Store filter frequencies if different
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
}
return true;
} catch (e) {
console.warn('[AudioContext] Failed to import EQ settings:', e);
return false;
}
}
}
// Export singleton instance
export const audioContextManager = new AudioContextManager();
// Export presets and helper functions for settings UI
export {
EQ_PRESETS,
generateFrequencies,
generateFrequencyLabels,
getPresetsForBandCount,
interpolatePreset,
EQ_PRESETS_16,
};

View File

@@ -0,0 +1,178 @@
//js/cache.js
export class APICache {
constructor(options = {}) {
this.memoryCache = new Map();
this.maxSize = options.maxSize || 200;
this.ttl = options.ttl || 1000 * 60 * 30;
this.dbName = 'monochrome-cache';
this.dbVersion = 1;
this.db = null;
this.initDB();
}
async initDB() {
if (typeof indexedDB === 'undefined') return;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('responses')) {
const store = db.createObjectStore('responses', { keyPath: 'key' });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
});
}
generateKey(type, params) {
const paramString = typeof params === 'object' ? JSON.stringify(params) : String(params);
return `${type}:${paramString}`;
}
async get(type, params) {
const key = this.generateKey(type, params);
if (this.memoryCache.has(key)) {
const cached = this.memoryCache.get(key);
if (Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
this.memoryCache.delete(key);
}
if (this.db) {
try {
const cached = await this.getFromIndexedDB(key);
if (cached && Date.now() - cached.timestamp < this.ttl) {
this.memoryCache.set(key, cached);
return cached.data;
}
} catch (error) {
console.log('IndexedDB read error:', error);
}
}
return null;
}
async set(type, params, data) {
const key = this.generateKey(type, params);
const entry = {
key,
data,
timestamp: Date.now(),
};
this.memoryCache.set(key, entry);
if (this.memoryCache.size > this.maxSize) {
const firstKey = this.memoryCache.keys().next().value;
this.memoryCache.delete(firstKey);
}
if (this.db) {
try {
await this.setInIndexedDB(entry);
} catch (error) {
console.log('IndexedDB write error:', error);
}
}
}
getFromIndexedDB(key) {
return new Promise((resolve, reject) => {
if (!this.db) {
resolve(null);
return;
}
const transaction = this.db.transaction(['responses'], 'readonly');
const store = transaction.objectStore('responses');
const request = store.get(key);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
setInIndexedDB(entry) {
return new Promise((resolve, reject) => {
if (!this.db) {
resolve();
return;
}
const transaction = this.db.transaction(['responses'], 'readwrite');
const store = transaction.objectStore('responses');
const request = store.put(entry);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async clear() {
this.memoryCache.clear();
if (this.db) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['responses'], 'readwrite');
const store = transaction.objectStore('responses');
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
async clearExpired() {
const now = Date.now();
const expired = [];
for (const [key, entry] of this.memoryCache.entries()) {
if (now - entry.timestamp >= this.ttl) {
expired.push(key);
}
}
expired.forEach((key) => this.memoryCache.delete(key));
if (this.db) {
try {
const transaction = this.db.transaction(['responses'], 'readwrite');
const store = transaction.objectStore('responses');
const index = store.index('timestamp');
const range = IDBKeyRange.upperBound(now - this.ttl);
const request = index.openCursor(range);
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
cursor.delete();
cursor.continue();
}
};
} catch (error) {
console.log('Failed to clear expired IndexedDB entries:', error);
}
}
}
getCacheStats() {
return {
memoryEntries: this.memoryCache.size,
maxSize: this.maxSize,
ttl: this.ttl,
};
}
}

View File

@@ -0,0 +1,864 @@
import { debounce } from './utils.js';
import { db } from './db.js';
import Fuse from 'fuse.js';
class CommandPalette {
constructor() {
this.overlay = document.getElementById('command-palette-overlay');
this.input = document.getElementById('command-palette-input');
this.resultsContainer = document.getElementById('command-palette-results');
this.isOpen = false;
this.selectedIndex = 0;
this.results = [];
this.allSettings = [];
this.debouncedSearch = debounce(this.performSearch.bind(this), 300);
this.commands = [
{
name: 'theme',
description: 'Change theme (white, dark, ocean, purple, forest, etc.)',
action: (args) => this.handleTheme(args),
},
{
name: 'play',
description: 'Search and play a track',
action: (args, autoPick) => this.handlePlay(args, autoPick),
},
{
name: 'shuffle',
description: 'Shuffle a playlist, artist, or album',
action: (args, autoPick) => this.handleShuffle(args, autoPick),
},
{
name: 'queue',
description: 'Manage the queue (wipe, like all, download)',
action: (args) => this.handleQueue(args),
},
{
name: 'setting',
description: 'Search for a specific setting',
action: (args) => this.handleSettingSearch(args),
},
{
name: 'sleep',
description: 'Set sleep timer in minutes',
action: (args) => this.handleSleepTimer(args),
},
{
name: 'quality',
description: 'Set streaming & download quality',
action: (args) => this.handleQuality(args),
},
{
name: 'visualizer',
description: 'Control visualizer (toggle, preset)',
action: (args) => this.handleVisualizer(args),
},
{
name: 'cache',
description: 'Clear application cache',
action: () => this.handleClearCache(),
},
];
this.init();
}
init() {
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
this.toggle();
}
});
this.input.addEventListener('input', () => this.handleInput());
this.input.addEventListener('keydown', (e) => this.handleKeydown(e));
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.close();
});
this.cacheAllSettings();
}
toggle() {
if (this.isOpen) this.close();
else this.open();
}
open() {
this.isOpen = true;
this.overlay.style.display = 'flex';
this.input.value = '>';
this.input.focus();
this.handleInput();
}
close() {
this.isOpen = false;
this.overlay.style.display = 'none';
}
handleInput() {
const value = this.input.value;
this.selectedIndex = 0;
if (!value.startsWith('>')) {
this.renderResults([
{
name: 'Type > to use commands',
description: 'e.g. >theme White, >play The Whole World Is Free',
action: () => {
this.input.value = '>';
this.handleInput();
},
type: 'hint',
},
]);
return;
}
const fullQuery = value.slice(1);
const match = fullQuery.match(/^(\S+)(?:\s+(.*))?$/);
if (!match) {
this.renderDefaultCommands();
return;
}
const cmdName = match[1].toLowerCase();
const args = match[2] || '';
const command = this.commands.find((c) => c.name === cmdName);
if (command) {
const commandsWithSubmenus = ['queue', 'go', 'visualizer', 'quality', 'sleep', 'setting'];
if (commandsWithSubmenus.includes(command.name) && !args.trim()) {
command.action(args);
return;
}
this.renderResults([
{
name: `Execute: ${command.name} ${args}`,
description: args ? `Run ${command.name} for "${args}"` : command.description,
action: () => command.action(args, ['play', 'shuffle', 'setting'].includes(command.name)),
type: 'execution',
},
]);
if (args.trim().length > 0 && (cmdName === 'play' || cmdName === 'shuffle')) {
this.debouncedSearch(cmdName, args.trim());
}
} else {
this.renderDefaultCommands(cmdName);
}
}
handleKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
this.updateSelection();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.updateSelection();
} else if (e.key === 'Enter') {
e.preventDefault();
this.executeSelected();
} else if (e.key === 'Escape') {
this.close();
}
}
renderDefaultCommands(filter = '') {
let cmds = this.commands;
if (filter) {
if (Fuse) {
const fuse = new Fuse(this.commands, { keys: ['name', 'description'] });
cmds = fuse.search(filter).map((r) => r.item);
} else {
cmds = this.commands.filter((c) => c.name.includes(filter));
}
}
this.renderResults(
cmds.map((c) => ({
name: c.name,
description: c.description,
action: () => {
this.input.value = `>${c.name} `;
this.handleInput();
},
type: 'command',
}))
);
}
renderResults(results) {
this.results = results;
this.resultsContainer.innerHTML = '';
if (results.length === 0) {
this.resultsContainer.innerHTML =
'<div style="padding: 1rem; color: var(--muted-foreground); text-align: center;">No results found</div>';
return;
}
results.forEach((result, index) => {
const div = document.createElement('div');
div.className = `command-result-item ${index === this.selectedIndex ? 'selected' : ''}`;
const imgHtml = result.image
? `<img src="${result.image}" crossorigin="anonymous" style="width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; object-fit: cover;">`
: '';
div.innerHTML = `
<div style="display: flex; align-items: center;">
${imgHtml}
<div style="display: flex; flex-direction: column;"><span class="command-result-name" style="font-weight: 500;">${result.name}</span><span class="command-result-desc" style="font-size: 0.8rem; opacity: 0.7;">${result.description || ''}</span></div>
</div>
`;
div.addEventListener('click', () => {
this.selectedIndex = index;
this.executeSelected();
});
this.resultsContainer.appendChild(div);
});
}
updateSelection() {
const items = this.resultsContainer.querySelectorAll('.command-result-item');
items.forEach((item, index) => {
if (index === this.selectedIndex) {
item.classList.add('selected');
item.scrollIntoView({ block: 'nearest' });
} else {
item.classList.remove('selected');
}
});
}
executeSelected() {
const result = this.results[this.selectedIndex];
if (result && result.action) {
result.action();
if (result.type !== 'hint') {
this.close();
}
} else if (result && result.type === 'command') {
this.input.value = `>${result.name} `;
this.handleInput();
}
}
handleTheme(args) {
if (!args) return;
const theme = args.trim().toLowerCase();
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
const themeOptions = document.querySelectorAll('.theme-option');
themeOptions.forEach((opt) => {
if (opt.dataset.theme === theme) opt.classList.add('active');
else opt.classList.remove('active');
});
}
async showNotification(message) {
const { showNotification } = await import('./downloads.js');
showNotification(message);
}
handleQueue(args) {
const player = window.monochromePlayer;
const ui = window.monochromeUi;
if (!player || !ui) {
console.error('Player or UI not available for queue command');
return;
}
if (!args || !args.trim()) {
this.renderResults(
[
{ name: '>queue wipe', description: 'Clear the queue and stop playback' },
{ name: '>queue like all', description: 'Like all tracks in the current queue' },
{ name: '>queue download', description: 'Download all tracks in the current queue' },
].map((c) => ({
...c,
type: 'command',
action: () => {
this.input.value = c.name;
this.handleInput();
},
}))
);
return;
}
const subCommand = args.trim().toLowerCase();
switch (subCommand) {
case 'wipe':
player.wipeQueue();
this.showNotification('Queue wiped.');
this.close();
break;
case 'like all':
this.likeAllInQueue(player, ui);
break;
case 'download':
this.downloadQueue(player, ui);
break;
default:
this.showNotification(`Unknown queue command: ${subCommand}`);
break;
}
}
async likeAllInQueue(player, ui) {
const queue = player.getCurrentQueue();
if (queue.length === 0) {
this.showNotification('Queue is empty.');
return;
}
const { handleTrackAction } = await import('./events.js');
const scrobbler = window.monochromeScrobbler;
let likedCount = 0;
this.showNotification('Liking all tracks in queue...');
for (const track of queue) {
const isLiked = await db.isFavorite('track', track.id);
if (!isLiked) {
await handleTrackAction('toggle-like', track, player, ui.api, ui.lyricsManager, 'track', ui, scrobbler);
likedCount++;
}
}
this.showNotification(`Liked ${likedCount} new track(s) in the queue.`);
this.close();
}
async downloadQueue(player, ui) {
const queue = player.getCurrentQueue();
if (queue.length === 0) {
this.showNotification('Queue is empty.');
return;
}
const { downloadTracks } = await import('./downloads.js');
const { downloadQualitySettings } = await import('./storage.js');
const lyricsManager = ui.lyricsManager;
downloadTracks(queue, ui.api, downloadQualitySettings.getQuality(), lyricsManager);
this.close();
}
handleNavigation(args) {
const validPages = ['home', 'library', 'recent', 'settings', 'unreleased', 'about', 'download'];
if (!args || !args.trim()) {
this.renderResults(
validPages.map((p) => ({
name: `>go ${p}`,
description: `Navigate to ${p}`,
action: () => {
this.close();
import('./router.js').then((m) => m.navigate(p === 'home' ? '/' : `/${p}`));
},
type: 'command',
}))
);
return;
}
const page = args.trim().toLowerCase();
if (validPages.includes(page)) {
this.close();
import('./router.js').then((m) => m.navigate(page === 'home' ? '/' : `/${page}`));
} else {
this.showNotification(`Unknown page: ${page}`);
}
}
handleSleepTimer(args) {
if (!args || !args.trim()) {
this.renderResults(
[15, 30, 45, 60, 120].map((m) => ({
name: `>sleep ${m}`,
description: `Set sleep timer for ${m} minutes`,
action: () => {
this.setSleepTimer(m);
this.close();
},
type: 'command',
}))
);
return;
}
const minutes = parseInt(args.trim());
if (!isNaN(minutes) && minutes > 0) {
this.setSleepTimer(minutes);
this.close();
} else {
this.showNotification('Invalid duration');
}
}
setSleepTimer(minutes) {
if (window.monochromePlayer) {
window.monochromePlayer.setSleepTimer(minutes);
this.showNotification(`Sleep timer set for ${minutes} minutes`);
}
}
handleQuality(args) {
const qualityMap = {
low: 'LOW',
high: 'HIGH',
lossless: 'LOSSLESS',
hires: 'HI_RES_LOSSLESS',
'hi-res': 'HI_RES_LOSSLESS',
master: 'HI_RES_LOSSLESS',
};
const displayQualities = [
{ id: 'low', name: 'Low', code: 'LOW' },
{ id: 'high', name: 'High', code: 'HIGH' },
{ id: 'lossless', name: 'Lossless', code: 'LOSSLESS' },
{ id: 'hi-res', name: 'Hi-Res', code: 'HI_RES_LOSSLESS' },
];
if (!args || !args.trim()) {
const results = displayQualities.map((q) => ({
name: `>quality ${q.id}`,
description: `Set quality to ${q.name}`,
action: () => {
this.setQuality(q.code, true, true);
this.close();
},
type: 'command',
}));
results.push({
name: 'Usage: >quality [level] [-S] [-D]',
description: '-S for Streaming only, -D for Download only',
action: () => {},
type: 'hint',
});
this.renderResults(results);
return;
}
const parts = args.trim().split(/\s+/);
const qualityKey = parts.find((p) => !p.startsWith('-'))?.toLowerCase();
const flags = parts.filter((p) => p.startsWith('-')).map((f) => f.toLowerCase());
if (!qualityKey || !qualityMap[qualityKey]) {
this.showNotification('Invalid quality setting');
return;
}
const qualityCode = qualityMap[qualityKey];
let setStreaming = true;
let setDownload = true;
if (flags.includes('-d') && !flags.includes('-s')) {
setStreaming = false;
} else if (flags.includes('-s') && !flags.includes('-d')) {
setDownload = false;
}
this.setQuality(qualityCode, setStreaming, setDownload);
this.close();
}
async setQuality(quality, setStreaming, setDownload) {
const messages = [];
const qualityName = this.getQualityName(quality);
if (setStreaming) {
if (window.monochromePlayer) {
window.monochromePlayer.setQuality(quality);
localStorage.setItem('playback-quality', quality);
messages.push('Streaming');
const streamingSelect = document.getElementById('streaming-quality-setting');
if (streamingSelect) {
streamingSelect.value = quality;
}
}
}
if (setDownload) {
const { downloadQualitySettings } = await import('./storage.js');
downloadQualitySettings.setQuality(quality);
messages.push('Download');
const downloadSelect = document.getElementById('download-quality-setting');
if (downloadSelect) {
downloadSelect.value = quality;
}
}
if (messages.length > 0) {
this.showNotification(`${messages.join(' & ')} quality set to ${qualityName}`);
}
}
getQualityName(code) {
const names = {
LOW: 'Low',
HIGH: 'High',
LOSSLESS: 'Lossless',
HI_RES_LOSSLESS: 'Hi-Res',
};
return names[code] || code;
}
async handleVisualizer(args) {
if (!args || !args.trim()) {
this.renderResults(
[
{ name: '>visualizer toggle', description: 'Toggle visualizer on/off', cmd: 'toggle' },
{ name: '>visualizer butterchurn', description: 'Set preset to Butterchurn', cmd: 'butterchurn' },
{ name: '>visualizer kawarp', description: 'Set preset to Kawarp', cmd: 'kawarp' },
{ name: '>visualizer lcd', description: 'Set preset to LCD', cmd: 'lcd' },
{ name: '>visualizer particles', description: 'Set preset to Particles', cmd: 'particles' },
{
name: '>visualizer unknown-pleasures',
description: 'Set preset to Unknown Pleasures',
cmd: 'unknown-pleasures',
},
].map((c) => ({
...c,
action: () => {
if (c.cmd === 'toggle') {
this.toggleVisualizer();
} else {
this.setVisualizerPreset(c.cmd);
}
this.close();
},
type: 'command',
}))
);
return;
}
const subCmd = args.trim().toLowerCase();
if (subCmd === 'toggle') {
this.toggleVisualizer();
this.close();
} else {
const presets = ['butterchurn', 'kawarp', 'lcd', 'particles', 'unknown-pleasures'];
if (presets.includes(subCmd)) {
this.setVisualizerPreset(subCmd);
this.close();
} else {
this.showNotification('Unknown visualizer command');
}
}
}
async toggleVisualizer() {
const { visualizerSettings } = await import('./storage.js');
const current = visualizerSettings.isEnabled();
visualizerSettings.setEnabled(!current);
this.showNotification(`Visualizer ${!current ? 'enabled' : 'disabled'}`);
const overlay = document.getElementById('fullscreen-cover-overlay');
if (overlay && getComputedStyle(overlay).display !== 'none') {
window.monochromeUi?.closeFullscreenCover();
}
}
async setVisualizerPreset(preset) {
const { visualizerSettings } = await import('./storage.js');
visualizerSettings.setPreset(preset);
if (window.monochromeUi?.visualizer) {
window.monochromeUi.visualizer.setPreset(preset);
}
this.showNotification(`Visualizer preset set to ${preset}`);
}
async handleClearCache() {
const api = window.monochromeUi?.api;
if (api) {
await api.clearCache();
this.showNotification('Cache cleared');
this.close();
}
}
cacheAllSettings() {
const settingItems = document.querySelectorAll('#page-settings .setting-item');
this.allSettings = Array.from(settingItems)
.map((item) => {
const labelEl = item.querySelector('.label');
const descEl = item.querySelector('.description');
const tabEl = item.closest('.settings-tab-content');
const label = labelEl ? labelEl.textContent.trim() : '';
const description = descEl ? descEl.textContent.trim() : '';
const tab = tabEl ? tabEl.id.replace('settings-tab-', '') : '';
if (!item.id) {
const inputEl = item.querySelector('input[id], select[id], button[id]');
item.id = inputEl
? `setting-item-for-${inputEl.id}`
: `setting-item-${Math.random().toString(36).substr(2, 9)}`;
}
return {
id: item.id,
label,
description,
tab,
};
})
.filter((s) => s.label);
}
async handleSettingSearch(args, autoPick = false) {
const query = args.trim().toLowerCase();
if (!query) {
this.renderResults(
this.allSettings.map((setting) => ({
name: setting.label,
description: `[${setting.tab}] ${setting.description}`,
action: () => {
this.navigateToSetting(setting);
this.close();
},
type: 'setting',
}))
);
return;
}
const fuse = new Fuse(this.allSettings, {
keys: ['label', 'description'],
includeScore: true,
threshold: 0.4,
ignoreLocation: true,
});
const results = fuse.search(query).map((r) => r.item);
if (autoPick && results.length > 0) {
this.navigateToSetting(results[0]);
this.close();
return;
}
this.renderResults(
results.map((setting) => ({
name: setting.label,
description: `[${setting.tab}] ${setting.description}`,
action: () => {
this.navigateToSetting(setting);
this.close();
},
type: 'setting',
}))
);
}
async navigateToSetting(setting) {
const router = await import('./router.js');
router.navigate('/settings');
await new Promise((resolve) => setTimeout(resolve, 100));
const tabButton = document.querySelector(`.settings-tab[data-tab="${setting.tab}"]`);
if (tabButton && !tabButton.classList.contains('active')) {
tabButton.click();
}
await new Promise((resolve) => setTimeout(resolve, 50));
const settingElement = document.getElementById(setting.id);
if (settingElement) {
settingElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
settingElement.style.transition = 'background-color 0.3s ease-out, box-shadow 0.3s ease-out';
settingElement.style.backgroundColor = 'rgba(var(--highlight-rgb), 0.2)';
settingElement.style.boxShadow = '0 0 0 2px rgba(var(--highlight-rgb), 0.5)';
setTimeout(() => {
settingElement.style.backgroundColor = '';
settingElement.style.boxShadow = '';
}, 2000);
}
}
async performSearch(cmdName, query) {
if (!this.isOpen) return;
const api = window.monochromeUi?.api;
if (!api) return;
let results = [];
try {
if (cmdName === 'play') {
const data = await api.searchTracks(query);
results = data.items.map((track) => ({
name: track.title,
description: `${track.artist?.name || 'Unknown'}${track.album?.title || 'Unknown'}`,
image: api.getCoverUrl(track.album?.cover, 80),
action: () => {
window.monochromePlayer.setQueue([track], 0);
window.monochromePlayer.playTrackFromQueue();
this.close();
},
type: 'result',
}));
} else if (cmdName === 'shuffle') {
const [albums, artists, playlists, userPlaylists] = await Promise.all([
api.searchAlbums(query),
api.searchArtists(query),
api.searchPlaylists(query),
db.getPlaylists(true),
]);
let matchedUserPlaylists = [];
if (Fuse) {
const fuse = new Fuse(userPlaylists, { keys: ['name'] });
matchedUserPlaylists = fuse.search(query).map((r) => r.item);
} else {
matchedUserPlaylists = userPlaylists.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase())
);
}
const formatResult = (item, type, subtitle, image) => ({
name: item.title || item.name,
description: `${type}${subtitle}`,
image: image,
action: () => this.playCollection(item, type, true),
type: 'result',
});
results = [
...matchedUserPlaylists.map((p) =>
formatResult(
p,
'User Playlist',
`${p.tracks?.length || 0} tracks`,
p.cover || (p.images && p.images[0])
)
),
...artists.items.map((a) =>
formatResult(a, 'Artist', 'Artist', api.getArtistPictureUrl(a.picture, 80))
),
...albums.items.map((a) => formatResult(a, 'Album', a.artist?.name, api.getCoverUrl(a.cover, 80))),
...playlists.items.map((p) =>
formatResult(p, 'Playlist', p.creator?.name || 'Tidal', api.getCoverUrl(p.image, 80))
),
];
}
} catch (e) {
console.error('Command palette search error:', e);
}
if (this.isOpen && results.length > 0) {
this.renderResults(results);
}
}
async handlePlay(args, autoPick) {
if (!args) return;
if (autoPick) {
const api = window.monochromeUi?.api;
const results = await api.searchTracks(args);
if (results.items.length > 0) {
const track = results.items[0];
window.monochromePlayer.setQueue([track], 0);
window.monochromePlayer.playTrackFromQueue();
this.close();
}
}
}
async handleShuffle(args, autoPick) {
if (!args) return;
if (autoPick) {
this.performSearch('shuffle', args).then(() => {
if (this.results.length > 0 && this.results[0].action) {
this.results[0].action();
}
});
}
}
async playCollection(item, type, shuffle) {
const player = window.monochromePlayer;
const api = window.monochromeUi.api;
let tracks = [];
try {
if (type === 'User Playlist') {
tracks = item.tracks;
} else if (type === 'Artist') {
const artist = await api.getArtist(item.id);
const allReleases = [...(artist.albums || []), ...(artist.eps || [])];
const trackSet = new Set();
const allTracks = [];
const chunkSize = 8;
for (let i = 0; i < allReleases.length; i += chunkSize) {
const chunk = allReleases.slice(i, i + chunkSize);
await Promise.all(
chunk.map(async (album) => {
try {
const { tracks: albumTracks } = await api.getAlbum(album.id);
albumTracks.forEach((track) => {
if (!trackSet.has(track.id)) {
trackSet.add(track.id);
allTracks.push(track);
}
});
} catch (err) {
console.warn(`Failed to fetch tracks for album ${album.title}:`, err);
}
})
);
}
if (allTracks.length > 0) {
tracks = allTracks;
} else {
tracks = artist.tracks || [];
}
} else if (type === 'Album') {
tracks = (await api.getAlbum(item.id)).tracks;
} else if (type === 'Playlist') {
tracks = (await api.getPlaylist(item.uuid)).tracks;
}
if (tracks && tracks.length > 0) {
if (shuffle) {
tracks = [...tracks].sort(() => Math.random() - 0.5);
player.shuffleActive = true;
document.getElementById('shuffle-btn')?.classList.add('active');
}
player.setQueue(tracks, 0);
player.playTrackFromQueue();
this.close();
}
} catch (e) {
console.error('Failed to play collection:', e);
}
}
}
new CommandPalette();

Some files were not shown because too many files have changed in this diff Show More