rename to client
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
14
.gitmodules
vendored
Normal 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
|
||||
9
.roo/commands/check-post-module.md
Normal file
9
.roo/commands/check-post-module.md
Normal 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
9
.roo/commands/push.md
Normal 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
0
.roo/mcp.json
Normal file
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"liveServer.settings.port": 5501
|
||||
}
|
||||
182
README.md
Normal file
182
README.md
Normal 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
|
||||
```
|
||||
BIN
__pycache__/serve_local.cpython-313.pyc
Normal file
BIN
__pycache__/serve_local.cpython-313.pyc
Normal file
Binary file not shown.
125
agent_browser_login.sh
Executable file
125
agent_browser_login.sh
Executable 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
82
agent_browser_remote_login.sh
Executable 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
213
build-ndk-bundle.js
Normal 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();
|
||||
1980
build/.cashu-inline-check.mjs
Normal file
1980
build/.cashu-inline-check.mjs
Normal file
File diff suppressed because it is too large
Load Diff
1661
build/.cashu-module-check.mjs
Normal file
1661
build/.cashu-module-check.mjs
Normal file
File diff suppressed because it is too large
Load Diff
1473
build/.event-management-inline-check.mjs
Normal file
1473
build/.event-management-inline-check.mjs
Normal file
File diff suppressed because it is too large
Load Diff
369
build/.feed-check.mjs
Normal file
369
build/.feed-check.mjs
Normal 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>`;
|
||||
}
|
||||
})();
|
||||
|
||||
1948
build/.html-tv-inline-check.mjs
Normal file
1948
build/.html-tv-inline-check.mjs
Normal file
File diff suppressed because it is too large
Load Diff
505
build/.index-inline-check.mjs
Normal file
505
build/.index-inline-check.mjs
Normal 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>`;
|
||||
}
|
||||
})();
|
||||
|
||||
500
build/.index-module-check.mjs
Normal file
500
build/.index-module-check.mjs
Normal 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>`;
|
||||
}
|
||||
})();
|
||||
|
||||
1111
build/.msg-module-check-latest.mjs
Normal file
1111
build/.msg-module-check-latest.mjs
Normal file
File diff suppressed because it is too large
Load Diff
1126
build/.msg-module-check-lazydecrypt.mjs
Normal file
1126
build/.msg-module-check-lazydecrypt.mjs
Normal file
File diff suppressed because it is too large
Load Diff
3258
build/.music-inline-check.mjs
Normal file
3258
build/.music-inline-check.mjs
Normal file
File diff suppressed because it is too large
Load Diff
654
build/.people-module-check.mjs
Normal file
654
build/.people-module-check.mjs
Normal 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();
|
||||
|
||||
1994
build/.post-module-check-latest.mjs
Normal file
1994
build/.post-module-check-latest.mjs
Normal file
File diff suppressed because it is too large
Load Diff
1762
build/.tmp-msg-inline-check.mjs
Normal file
1762
build/.tmp-msg-inline-check.mjs
Normal file
File diff suppressed because it is too large
Load Diff
1924
build/.tmp-msg-module-extracted.mjs
Normal file
1924
build/.tmp-msg-module-extracted.mjs
Normal file
File diff suppressed because it is too large
Load Diff
4029
build/.tmp-music-phase4-check.mjs
Normal file
4029
build/.tmp-music-phase4-check.mjs
Normal file
File diff suppressed because it is too large
Load Diff
4527
build/.tmp-vj-inline-check.mjs
Normal file
4527
build/.tmp-vj-inline-check.mjs
Normal file
File diff suppressed because it is too large
Load Diff
40
build/enable_hls_rtmp.py
Normal file
40
build/enable_hls_rtmp.py
Normal 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
7397
build/meta.json
Normal file
File diff suppressed because it is too large
Load Diff
1576
build/music-inline-check.mjs
Normal file
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
77846
build/ndk-core.bundle.js
Normal file
File diff suppressed because it is too large
Load Diff
7
build/ndk-core.bundle.js.map
Normal file
7
build/ndk-core.bundle.js.map
Normal file
File diff suppressed because one or more lines are too long
35
build/ndk-entry.js
Normal file
35
build/ndk-entry.js
Normal 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
1611
build/post-module-check.mjs
Normal file
File diff suppressed because it is too large
Load Diff
713
build/relays-module-check.mjs
Normal file
713
build/relays-module-check.mjs
Normal 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
34
build_and_copy.sh
Executable 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
8
client.code-workspace
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
106
docs/MUSIC_URL_STRUCTURE.md
Normal file
106
docs/MUSIC_URL_STRUCTURE.md
Normal 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
544
docs/SETTINGS.md
Normal 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) |
|
||||
551
docs/cache-first-page-patterns.md
Normal file
551
docs/cache-first-page-patterns.md
Normal 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.20–v0.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
524
docs/music.md
Normal 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
3
greyscale/.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "monochrome"]
|
||||
path = monochrome
|
||||
url = https://github.com/monochrome-music/monochrome.git
|
||||
250
greyscale/README.md
Normal file
250
greyscale/README.md
Normal 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.
|
||||
235
greyscale/greyscale-app/README.md
Normal file
235
greyscale/greyscale-app/README.md
Normal 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()` |
|
||||
52
greyscale/greyscale-app/index.html
Normal file
52
greyscale/greyscale-app/index.html
Normal 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>
|
||||
184
greyscale/greyscale-app/js/api.js
Normal file
184
greyscale/greyscale-app/js/api.js
Normal 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`;
|
||||
}
|
||||
}
|
||||
204
greyscale/greyscale-app/js/app.js
Normal file
204
greyscale/greyscale-app/js/app.js
Normal 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();
|
||||
121
greyscale/greyscale-app/js/player.js
Normal file
121
greyscale/greyscale-app/js/player.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
64
greyscale/greyscale-app/src/utils.js
Normal file
64
greyscale/greyscale-app/src/utils.js
Normal 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);
|
||||
};
|
||||
}
|
||||
248
greyscale/greyscale-app/style.css
Normal file
248
greyscale/greyscale-app/style.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
42
greyscale/monochrome/.devcontainer/Dockerfile
Normal file
42
greyscale/monochrome/.devcontainer/Dockerfile
Normal 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"]
|
||||
14
greyscale/monochrome/.devcontainer/devcontainer.json
Normal file
14
greyscale/monochrome/.devcontainer/devcontainer.json
Normal 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"]
|
||||
}
|
||||
32
greyscale/monochrome/.dockerignore
Normal file
32
greyscale/monochrome/.dockerignore
Normal 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
|
||||
26
greyscale/monochrome/.env.example
Normal file
26
greyscale/monochrome/.env.example
Normal 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
|
||||
1
greyscale/monochrome/.github/FUNDING.yml
vendored
Normal file
1
greyscale/monochrome/.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ko_fi: monochromemusic
|
||||
12
greyscale/monochrome/.github/dependabot.yml
vendored
Normal file
12
greyscale/monochrome/.github/dependabot.yml
vendored
Normal 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
|
||||
14
greyscale/monochrome/.github/pull_request_template.md
vendored
Normal file
14
greyscale/monochrome/.github/pull_request_template.md
vendored
Normal 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.*
|
||||
125
greyscale/monochrome/.github/workflows/desktop-build.yml
vendored
Normal file
125
greyscale/monochrome/.github/workflows/desktop-build.yml
vendored
Normal 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
|
||||
60
greyscale/monochrome/.github/workflows/lint.yml
vendored
Normal file
60
greyscale/monochrome/.github/workflows/lint.yml
vendored
Normal 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
|
||||
43
greyscale/monochrome/.github/workflows/update-lockfile.yml
vendored
Normal file
43
greyscale/monochrome/.github/workflows/update-lockfile.yml
vendored
Normal 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
BIN
greyscale/monochrome/.gitignore
vendored
Normal file
Binary file not shown.
3
greyscale/monochrome/.htmlhintignore
Normal file
3
greyscale/monochrome/.htmlhintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
legacy/
|
||||
12
greyscale/monochrome/.htmlhintrc
Normal file
12
greyscale/monochrome/.htmlhintrc
Normal 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
|
||||
}
|
||||
6
greyscale/monochrome/.prettierignore
Normal file
6
greyscale/monochrome/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
dist/
|
||||
node_modules/
|
||||
legacy/
|
||||
package-lock.json
|
||||
*.min.js
|
||||
.github/
|
||||
8
greyscale/monochrome/.prettierrc
Normal file
8
greyscale/monochrome/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 120,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
12
greyscale/monochrome/.stylelintrc.json
Normal file
12
greyscale/monochrome/.stylelintrc.json
Normal 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
23
greyscale/monochrome/.vscode/tasks.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
57
greyscale/monochrome/AUTH_GATE.md
Normal file
57
greyscale/monochrome/AUTH_GATE.md
Normal 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`
|
||||
342
greyscale/monochrome/CONTRIBUTING.md
Normal file
342
greyscale/monochrome/CONTRIBUTING.md
Normal 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:
|
||||
|
||||
- We’re already working on the same thing behind the scenes.
|
||||
- The feature doesn't quite fit where the project is headed right now.
|
||||
- We’re 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!
|
||||
284
greyscale/monochrome/DESIGN.md
Normal file
284
greyscale/monochrome/DESIGN.md
Normal 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
|
||||
187
greyscale/monochrome/DOCKER.md
Normal file
187
greyscale/monochrome/DOCKER.md
Normal 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 |
|
||||
32
greyscale/monochrome/Dockerfile
Normal file
32
greyscale/monochrome/Dockerfile
Normal 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"]
|
||||
16
greyscale/monochrome/Dockerfile.dev
Normal file
16
greyscale/monochrome/Dockerfile.dev
Normal 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"]
|
||||
100
greyscale/monochrome/INSTANCES.md
Normal file
100
greyscale/monochrome/INSTANCES.md
Normal 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
|
||||
258
greyscale/monochrome/README.md
Normal file
258
greyscale/monochrome/README.md
Normal 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>
|
||||
77
greyscale/monochrome/THEME_GUIDE.md
Normal file
77
greyscale/monochrome/THEME_GUIDE.md
Normal 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!
|
||||
1894
greyscale/monochrome/bun.lock
Normal file
1894
greyscale/monochrome/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
78
greyscale/monochrome/docker-compose.yml
Normal file
78
greyscale/monochrome/docker-compose.yml
Normal 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:
|
||||
34
greyscale/monochrome/eslint.config.js
Normal file
34
greyscale/monochrome/eslint.config.js
Normal 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'] }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
160
greyscale/monochrome/functions/album/[id].js
Normal file
160
greyscale/monochrome/functions/album/[id].js
Normal 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));
|
||||
}
|
||||
2
greyscale/monochrome/functions/album/t/[id].js
Normal file
2
greyscale/monochrome/functions/album/t/[id].js
Normal 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';
|
||||
152
greyscale/monochrome/functions/artist/[id].js
Normal file
152
greyscale/monochrome/functions/artist/[id].js
Normal 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));
|
||||
}
|
||||
2
greyscale/monochrome/functions/artist/t/[id].js
Normal file
2
greyscale/monochrome/functions/artist/t/[id].js
Normal 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';
|
||||
157
greyscale/monochrome/functions/playlist/[id].js
Normal file
157
greyscale/monochrome/functions/playlist/[id].js
Normal 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));
|
||||
}
|
||||
2
greyscale/monochrome/functions/playlist/t/[id].js
Normal file
2
greyscale/monochrome/functions/playlist/t/[id].js
Normal 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';
|
||||
196
greyscale/monochrome/functions/track/[id].js
Normal file
196
greyscale/monochrome/functions/track/[id].js
Normal 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));
|
||||
}
|
||||
2
greyscale/monochrome/functions/track/t/[id].js
Normal file
2
greyscale/monochrome/functions/track/t/[id].js
Normal 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';
|
||||
195
greyscale/monochrome/functions/upload.js
Normal file
195
greyscale/monochrome/functions/upload.js
Normal 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': '*',
|
||||
};
|
||||
}
|
||||
5806
greyscale/monochrome/index.html
Normal file
5806
greyscale/monochrome/index.html
Normal file
File diff suppressed because one or more lines are too long
181
greyscale/monochrome/js/accounts/auth.js
Normal file
181
greyscale/monochrome/js/accounts/auth.js
Normal 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();
|
||||
20
greyscale/monochrome/js/accounts/config.js
Normal file
20
greyscale/monochrome/js/accounts/config.js
Normal 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');
|
||||
};
|
||||
648
greyscale/monochrome/js/accounts/pocketbase.js
Normal file
648
greyscale/monochrome/js/accounts/pocketbase.js
Normal 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 };
|
||||
755
greyscale/monochrome/js/analytics.js
Normal file
755
greyscale/monochrome/js/analytics.js
Normal 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
1358
greyscale/monochrome/js/api.js
Normal file
1358
greyscale/monochrome/js/api.js
Normal file
File diff suppressed because it is too large
Load Diff
3135
greyscale/monochrome/js/app.js
Normal file
3135
greyscale/monochrome/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
785
greyscale/monochrome/js/audio-context.js
Normal file
785
greyscale/monochrome/js/audio-context.js
Normal 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,
|
||||
};
|
||||
178
greyscale/monochrome/js/cache.js
Normal file
178
greyscale/monochrome/js/cache.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
864
greyscale/monochrome/js/commandPalette.js
Normal file
864
greyscale/monochrome/js/commandPalette.js
Normal 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
Reference in New Issue
Block a user