Merge branch 'main' of https://github.com/monochrome-music/monochrome
This commit is contained in:
commit
d876eeb0ec
46 changed files with 5346 additions and 1842 deletions
1
fix-gen.py → .github/scripts/fix-gen.py
vendored
1
fix-gen.py → .github/scripts/fix-gen.py
vendored
|
|
@ -19,4 +19,3 @@ new_func = """def download_and_process_cover(cover_uuid):
|
||||||
content = re.sub(r"def download_and_process_cover\(cover_uuid\):[\s\S]*?(?=def process_cover)", new_func + "\n\n", content)
|
content = re.sub(r"def download_and_process_cover\(cover_uuid\):[\s\S]*?(?=def process_cover)", new_func + "\n\n", content)
|
||||||
|
|
||||||
with open("gen-editors-picks.py", "w") as f: f.write(content)
|
with open("gen-editors-picks.py", "w") as f: f.write(content)
|
||||||
|
|
||||||
|
|
@ -9,32 +9,58 @@ import hashlib
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import base64
|
||||||
|
|
||||||
INPUT_FILE = "editors-picks-input.txt"
|
INPUT_FILE = "editors-picks-input.txt"
|
||||||
COUNTRY = "US"
|
COUNTRY = "US"
|
||||||
|
|
||||||
# Tidal internal token replace when expired
|
TIDAL_CLIENT_ID = "txNoH4kkV41MfH25"
|
||||||
TIDAL_TOKEN = "eyJraWQiOiJ2OU1GbFhqWSIsImFsZyI6IkVTMjU2In0.eyJ0eXBlIjoibzJfYWNjZXNzIiwic2NvcGUiOiIiLCJnVmVyIjowLCJzVmVyIjowLCJjaWQiOjEzNTU3LCJhdCI6IklOVEVSTkFMIiwiZXhwIjoxNzc1MzY0MTQwLCJpc3MiOiJodHRwczovL2F1dGgudGlkYWwuY29tL3YxIn0.6ui6itHVQ-OXPF0F9mbf5KcKz1fKYJNsa1vBAj60upXpcN-DQG8JPKBlqJN6RuBEH8yhwYj2wh4YJ-TOOuO8DA"
|
TIDAL_CLIENT_SECRET = "dQjy0MinCEvxi1O4UmxvxWnDjt4cgHBPw8ll6nYBk98="
|
||||||
|
|
||||||
TIDAL_HEADERS = {
|
_tidal_token = None
|
||||||
"accept": "*/*",
|
|
||||||
"authorization": f"Bearer {TIDAL_TOKEN}",
|
|
||||||
}
|
|
||||||
|
|
||||||
# PodcastIndex credentials
|
|
||||||
PODCAST_API_KEY = "YU5HMSDYBQQVYDF6QN4P"
|
|
||||||
PODCAST_API_SECRET = "8hCvpjSL7T$S7^5ftnf5MhqQwYUYVjM^fmUL3Ld$"
|
|
||||||
PODCASTINDEX_BASE = "https://api.podcastindex.org/api/1.0"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Tidal helpers ─────────────────────────────────────────────────────────────
|
def get_tidal_token():
|
||||||
|
global _tidal_token
|
||||||
|
if _tidal_token:
|
||||||
|
return _tidal_token
|
||||||
|
|
||||||
|
credentials = base64.b64encode(f"{TIDAL_CLIENT_ID}:{TIDAL_CLIENT_SECRET}".encode()).decode()
|
||||||
|
params = urllib.parse.urlencode({
|
||||||
|
"client_id": TIDAL_CLIENT_ID,
|
||||||
|
"client_secret": TIDAL_CLIENT_SECRET,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
})
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://auth.tidal.com/v1/oauth2/token",
|
||||||
|
data=params.encode(),
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Authorization": f"Basic {credentials}",
|
||||||
|
},
|
||||||
|
method="POST"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
_tidal_token = data["access_token"]
|
||||||
|
return _tidal_token
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting Tidal token: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def tidal_get(path, params=None):
|
def tidal_get(path, params=None):
|
||||||
if params is None:
|
if params is None:
|
||||||
params = {}
|
params = {}
|
||||||
params.setdefault("countryCode", COUNTRY)
|
params.setdefault("countryCode", COUNTRY)
|
||||||
|
|
||||||
|
token = get_tidal_token()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
url = f"https://api.tidal.com/v1/{path}?{urllib.parse.urlencode(params)}"
|
url = f"https://api.tidal.com/v1/{path}?{urllib.parse.urlencode(params)}"
|
||||||
req = urllib.request.Request(url, headers=TIDAL_HEADERS)
|
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as resp:
|
with urllib.request.urlopen(req) as resp:
|
||||||
return json.loads(resp.read().decode())
|
return json.loads(resp.read().decode())
|
||||||
1
.github/workflows/copilot-setup-steps.yml
vendored
1
.github/workflows/copilot-setup-steps.yml
vendored
|
|
@ -20,7 +20,6 @@ jobs:
|
||||||
# Copilot will be given its own token for its operations.
|
# Copilot will be given its own token for its operations.
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
workflows: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|
|
||||||
3
.github/workflows/editors-picks.yml
vendored
3
.github/workflows/editors-picks.yml
vendored
|
|
@ -97,13 +97,14 @@ jobs:
|
||||||
|
|
||||||
- name: Generate new editors picks
|
- name: Generate new editors picks
|
||||||
if: steps.backoff.outputs.skip == 'false'
|
if: steps.backoff.outputs.skip == 'false'
|
||||||
run: python3 gen-editors-picks.py
|
run: python3 .github/scripts/gen-editors-picks.py
|
||||||
|
|
||||||
- name: Commit and push
|
- name: Commit and push
|
||||||
if: steps.backoff.outputs.skip == 'false'
|
if: steps.backoff.outputs.skip == 'false'
|
||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git pull --rebase origin main
|
||||||
git add public/editors-picks.json public/editors-picks-old/
|
git add public/editors-picks.json public/editors-picks-old/
|
||||||
git diff --staged --quiet && echo "No changes to commit." && exit 0
|
git diff --staged --quiet && echo "No changes to commit." && exit 0
|
||||||
git commit -m "chore: update editors picks"
|
git commit -m "chore: update editors picks"
|
||||||
|
|
|
||||||
43
.github/workflows/lighthouse.yml
vendored
Normal file
43
.github/workflows/lighthouse.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
name: Lighthouse
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lighthouse:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Preview build
|
||||||
|
run: npm run preview &
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Wait for preview server
|
||||||
|
run: sleep 10
|
||||||
|
|
||||||
|
- name: Run Lighthouse
|
||||||
|
run: |
|
||||||
|
npx lhci autorun --config=.lhci.yml || true
|
||||||
|
|
||||||
|
- name: Upload results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: lighthouse-results
|
||||||
|
path: .lighthouseci/
|
||||||
|
|
@ -49,7 +49,7 @@ public class AudioPlaybackService extends Service {
|
||||||
|
|
||||||
acquireWakeLock();
|
acquireWakeLock();
|
||||||
|
|
||||||
// If the system kills this service, don't restart it automatically —
|
// If the system kills this service, don't restart it automatically -
|
||||||
// MainActivity will re-start it when audio resumes.
|
// MainActivity will re-start it when audio resumes.
|
||||||
return START_NOT_STICKY;
|
return START_NOT_STICKY;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
320
index.html
320
index.html
|
|
@ -173,6 +173,24 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="eq-node-context-menu">
|
||||||
|
<ul>
|
||||||
|
<li data-action="eq-channel-stereo" class="eq-ctx-channel">Stereo</li>
|
||||||
|
<li data-action="eq-channel-mid" class="eq-ctx-channel">Mid</li>
|
||||||
|
<li data-action="eq-channel-side" class="eq-ctx-channel">Side</li>
|
||||||
|
<li class="separator"></li>
|
||||||
|
<li data-action="eq-type-lowshelf" class="eq-ctx-type">Low Shelf</li>
|
||||||
|
<li data-action="eq-type-peaking" class="eq-ctx-type">Peaking</li>
|
||||||
|
<li data-action="eq-type-highshelf" class="eq-ctx-type">High Shelf</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="eq-empty-context-menu">
|
||||||
|
<ul>
|
||||||
|
<li data-action="eq-add-node">Add Node</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="side-panel" class="side-panel">
|
<div id="side-panel" class="side-panel">
|
||||||
<div id="side-panel-resizer" class="side-panel-resizer"></div>
|
<div id="side-panel-resizer" class="side-panel-resizer"></div>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
|
|
@ -205,13 +223,21 @@
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
"
|
"
|
||||||
></div>
|
></div>
|
||||||
|
<button id="fullscreen-dismiss-handle" type="button" aria-label="Dismiss fullscreen"></button>
|
||||||
|
<button
|
||||||
|
id="toggle-fullscreen-lyrics-mobile-btn"
|
||||||
|
class="fullscreen-mobile-lyrics-toggle"
|
||||||
|
title="Hide Lyrics"
|
||||||
|
>
|
||||||
|
<use svg="!lucide/mic-vocal.svg" size="18" />
|
||||||
|
</button>
|
||||||
<button id="toggle-ui-btn" class="fullscreen-ui-toggle" title="Toggle UI">
|
<button id="toggle-ui-btn" class="fullscreen-ui-toggle" title="Toggle UI">
|
||||||
<use svg="!lucide/eye-off.svg" size="24" />
|
<use svg="!lucide/eye-off.svg" size="24" />
|
||||||
</button>
|
</button>
|
||||||
<button id="toggle-fullscreen-lyrics-btn" class="fullscreen-lyrics-toggle" title="Toggle Lyrics">
|
|
||||||
<use svg="!lucide/mic-vocal.svg" size="24" />
|
|
||||||
</button>
|
|
||||||
<div class="fullscreen-top-actions">
|
<div class="fullscreen-top-actions">
|
||||||
|
<button id="toggle-fullscreen-lyrics-btn" class="fullscreen-lyrics-toggle" title="Hide Lyrics">
|
||||||
|
<use svg="!lucide/mic-vocal.svg" size="20" />
|
||||||
|
</button>
|
||||||
<button id="fs-visualizer-btn" class="fs-visualizer-btn" title="Disable Visualizer">
|
<button id="fs-visualizer-btn" class="fs-visualizer-btn" title="Disable Visualizer">
|
||||||
<use svg="!lucide/audio-lines.svg" size="20" />
|
<use svg="!lucide/audio-lines.svg" size="20" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -257,6 +283,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fullscreen-controls">
|
<div class="fullscreen-controls">
|
||||||
|
<div
|
||||||
|
id="fullscreen-mobile-quality"
|
||||||
|
class="fullscreen-mobile-quality"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
<div class="fullscreen-progress-container">
|
<div class="fullscreen-progress-container">
|
||||||
<span id="fs-current-time">0:00</span>
|
<span id="fs-current-time">0:00</span>
|
||||||
<div id="fs-progress-bar" class="progress-bar">
|
<div id="fs-progress-bar" class="progress-bar">
|
||||||
|
|
@ -4130,9 +4161,108 @@
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">AutoEQ</span>
|
<span class="label">Binaural / Spatial DSP</span>
|
||||||
<span class="description"
|
<span class="description"
|
||||||
>Precision headphone correction & parametric equalizer</span
|
>Multichannel HRTF rendering for Atmos & 3D Audio, crossfeed for
|
||||||
|
stereo</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="binaural-dsp-toggle" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-dsp-container" id="binaural-dsp-container" style="display: none">
|
||||||
|
<div class="binaural-status" id="binaural-status">
|
||||||
|
<span class="binaural-mode-label">Mode: Stereo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-sub-setting">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Auto-enable for Spatial Audio</span>
|
||||||
|
<span class="description"
|
||||||
|
>Automatically activate when Atmos or 3D content is detected</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="binaural-auto-spatial-toggle" checked />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-sub-setting">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Crossfeed</span>
|
||||||
|
<span class="description">Simulate speaker presentation on headphones</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="binaural-crossfeed-toggle" checked />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-sub-setting" id="crossfeed-level-row">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Crossfeed Level</span>
|
||||||
|
</div>
|
||||||
|
<select id="binaural-crossfeed-level">
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium" selected>Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-sub-setting">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">HRTF Preset</span>
|
||||||
|
<span class="description"
|
||||||
|
>Virtual speaker angle for multichannel rendering</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<select id="binaural-hrtf-preset">
|
||||||
|
<option value="intimate">Intimate (±22°)</option>
|
||||||
|
<option value="studio" selected>Studio (±30°)</option>
|
||||||
|
<option value="wide">Wide (±45°)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-sub-setting">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Stereo Width</span>
|
||||||
|
<span class="description"
|
||||||
|
>Adjust spatial width (0 = mono, 1 = neutral, 2 = wide)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="binaural-widening-toggle" checked />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-sub-setting" id="widening-slider-row">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Width Amount</span>
|
||||||
|
<span class="binaural-width-value" id="binaural-width-value">1.0</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="binaural-widening-slider"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="0.05"
|
||||||
|
value="1.0"
|
||||||
|
class="binaural-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">EQ Studio</span>
|
||||||
|
<span class="description"
|
||||||
|
>Multi-mode equalizer with AutoEQ, M/S processing & room
|
||||||
|
correction</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
|
|
@ -4146,10 +4276,10 @@
|
||||||
<div class="autoeq-mode-row">
|
<div class="autoeq-mode-row">
|
||||||
<div class="autoeq-mode-toggle">
|
<div class="autoeq-mode-toggle">
|
||||||
<button class="autoeq-mode-btn" data-mode="legacy">Legacy EQ</button>
|
<button class="autoeq-mode-btn" data-mode="legacy">Legacy EQ</button>
|
||||||
<button class="autoeq-mode-btn active" data-mode="autoeq">AutoEQ</button>
|
|
||||||
<button class="autoeq-mode-btn" data-mode="parametric">
|
<button class="autoeq-mode-btn" data-mode="parametric">
|
||||||
Parametric EQ
|
Parametric EQ
|
||||||
</button>
|
</button>
|
||||||
|
<button class="autoeq-mode-btn active" data-mode="autoeq">AutoEQ</button>
|
||||||
<button class="autoeq-mode-btn" data-mode="speaker">Speaker EQ</button>
|
<button class="autoeq-mode-btn" data-mode="speaker">Speaker EQ</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="eq-howto-btn" id="eq-howto-btn" title="How to use">?</button>
|
<button class="eq-howto-btn" id="eq-howto-btn" title="How to use">?</button>
|
||||||
|
|
@ -4161,6 +4291,48 @@
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="eq-howto-tab legacy" id="eq-howto-legacy" style="display: none">
|
||||||
|
<h4>Legacy EQ - Graphic Equalizer</h4>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
Set the <b>number of bands</b> (3-32) and
|
||||||
|
<b>frequency range</b> (Min/Max Hz) at the top to customize the
|
||||||
|
equalizer layout.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Drag the sliders</b> to boost or cut each frequency band. Bands
|
||||||
|
are spaced logarithmically across your range.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Pick a <b>preset</b> (Bass Boost, Rock, Vocal, etc.) as a starting
|
||||||
|
point - presets auto-scale to your band count.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Adjust the <b>preamp</b> slider to raise or lower the overall level
|
||||||
|
- reduce it if you hear distortion from large boosts.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Save</b> your own custom presets with a name so you can recall
|
||||||
|
them later.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Export</b> saves the EQ in EqualizerAPO text format.
|
||||||
|
<b>Export APO</b> saves a GraphicEQ config line you can paste
|
||||||
|
directly into Equalizer APO's config.txt.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Import</b> loads EQ settings from EqualizerAPO text files or
|
||||||
|
simple frequency/gain CSV files - points are mapped to your current
|
||||||
|
bands automatically.
|
||||||
|
</li>
|
||||||
|
<li>Click <b>Reset</b> to flatten all bands back to 0 dB.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="eq-howto-tip">
|
||||||
|
Tip: Cut problem frequencies rather than boosting others - it sounds
|
||||||
|
cleaner and avoids clipping.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="eq-howto-tab autoeq" id="eq-howto-autoeq">
|
<div class="eq-howto-tab autoeq" id="eq-howto-autoeq">
|
||||||
<h4>AutoEQ - Headphone Correction</h4>
|
<h4>AutoEQ - Headphone Correction</h4>
|
||||||
<ol>
|
<ol>
|
||||||
|
|
@ -4185,9 +4357,16 @@
|
||||||
result.
|
result.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>Double-click an empty area</b> on the graph to add a new filter
|
<b>Drag nodes</b> to adjust frequency and gain.
|
||||||
band at that frequency. <b>Double-click an existing node</b> to
|
<b>Scroll on a node</b> to adjust Q (bandwidth).
|
||||||
remove it.
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Right-click a node</b> to change its filter type (Peaking, Low
|
||||||
|
Shelf, High Shelf) or channel mode (Stereo, Mid, Side).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Right-click empty space</b> or <b>double-click</b> to add a node.
|
||||||
|
<b>Double-click a node</b> to remove it.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>Save</b> the profile so you can switch between headphones
|
<b>Save</b> the profile so you can switch between headphones
|
||||||
|
|
@ -4208,26 +4387,25 @@
|
||||||
<h4>Parametric EQ - Manual Control</h4>
|
<h4>Parametric EQ - Manual Control</h4>
|
||||||
<ol>
|
<ol>
|
||||||
<li>
|
<li>
|
||||||
Each band supports <b>peaking, low-shelf, and high-shelf</b> filter
|
Each band supports <b>Peaking, Low Shelf, and High Shelf</b> filter
|
||||||
types with frequency, gain, and Q (width).
|
types with frequency, gain, and Q (bandwidth).
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>Drag nodes</b> on the graph to adjust frequency and gain
|
<b>Drag nodes</b> on the graph to adjust frequency and gain.
|
||||||
visually.
|
<b>Scroll on a node</b> to adjust Q.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>Double-click an empty area</b> on the graph to add a new band at
|
<b>Right-click a node</b> to change its filter type or set its
|
||||||
that exact frequency and gain.
|
channel mode to <b>Stereo</b>, <b>Mid</b>, or <b>Side</b>.
|
||||||
<b>Double-click an existing node</b> to delete it.
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Right-click empty space</b> or <b>double-click</b> to add a node
|
||||||
|
at that position. <b>Double-click a node</b> to delete it.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Use <b>+ Add Band / - Remove Band</b> to change the number of
|
Use <b>+ Add Band / - Remove Band</b> to change the number of
|
||||||
filters.
|
filters.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
Pick a <b>preset</b> (Bass Boost, Vocal, etc.) as a starting point,
|
|
||||||
then fine-tune.
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<b>Import/Export</b> settings in EqualizerAPO format for use in
|
<b>Import/Export</b> settings in EqualizerAPO format for use in
|
||||||
other apps.
|
other apps.
|
||||||
|
|
@ -4238,7 +4416,14 @@
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p class="eq-howto-tip">
|
<p class="eq-howto-tip">
|
||||||
Tip: Lower Q = wider curve. Higher Q = narrower surgical cut.
|
Tip: Lower Q = wider curve, higher Q = narrower surgical cut.
|
||||||
|
</p>
|
||||||
|
<p class="eq-howto-tip">
|
||||||
|
Mid/Side tips: Set a band to <b>Mid</b> to EQ only the center image
|
||||||
|
(vocals, bass, kick). Set it to <b>Side</b> to EQ only the stereo width
|
||||||
|
(reverb, ambience, panned instruments). Try cutting low-end on Side
|
||||||
|
below 200 Hz for tighter, mono-compatible bass - or boost presence on
|
||||||
|
Mid around 2-5 kHz to bring vocals forward without touching the sides.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -4267,9 +4452,12 @@
|
||||||
Click <b>AutoEQ</b> to generate correction filters for that channel.
|
Click <b>AutoEQ</b> to generate correction filters for that channel.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>Double-click an empty area</b> on the graph to add a filter band
|
<b>Drag nodes</b> to fine-tune. <b>Scroll on a node</b> to adjust Q.
|
||||||
at that frequency. <b>Double-click an existing node</b> to remove
|
<b>Right-click a node</b> to change type or channel mode.
|
||||||
it.
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Right-click empty space</b> or <b>double-click</b> to add a node.
|
||||||
|
<b>Double-click a node</b> to remove it.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Repeat for each channel, then <b>Export JSON</b> with all channels.
|
Repeat for each channel, then <b>Export JSON</b> with all channels.
|
||||||
|
|
@ -4284,6 +4472,35 @@
|
||||||
|
|
||||||
<!-- Legacy 16-Band Graphic EQ (visible in legacy mode) -->
|
<!-- Legacy 16-Band Graphic EQ (visible in legacy mode) -->
|
||||||
<div class="graphic-eq-section" id="graphic-eq-section" style="display: none">
|
<div class="graphic-eq-section" id="graphic-eq-section" style="display: none">
|
||||||
|
<div class="graphic-eq-config-row">
|
||||||
|
<label>Bands</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="legacy-geq-band-count"
|
||||||
|
min="3"
|
||||||
|
max="32"
|
||||||
|
value="16"
|
||||||
|
class="geq-config-input"
|
||||||
|
/>
|
||||||
|
<label>Min Hz</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="legacy-geq-freq-min"
|
||||||
|
min="10"
|
||||||
|
max="96000"
|
||||||
|
value="25"
|
||||||
|
class="geq-config-input"
|
||||||
|
/>
|
||||||
|
<label>Max Hz</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="legacy-geq-freq-max"
|
||||||
|
min="10"
|
||||||
|
max="96000"
|
||||||
|
value="20000"
|
||||||
|
class="geq-config-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="graphic-eq-preset-row">
|
<div class="graphic-eq-preset-row">
|
||||||
<label for="legacy-graphic-eq-preset-select" class="graphic-eq-preset-label"
|
<label for="legacy-graphic-eq-preset-select" class="graphic-eq-preset-label"
|
||||||
>Preset</label
|
>Preset</label
|
||||||
|
|
@ -4310,6 +4527,21 @@
|
||||||
<option value="acoustic">Acoustic</option>
|
<option value="acoustic">Acoustic</option>
|
||||||
<option value="podcast">Speech</option>
|
<option value="podcast">Speech</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button
|
||||||
|
id="legacy-geq-save-preset-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Save current EQ as a preset"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="legacy-geq-delete-preset-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Delete selected custom preset"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="graphic-eq-bands" id="legacy-graphic-eq-bands">
|
<div class="graphic-eq-bands" id="legacy-graphic-eq-bands">
|
||||||
<!-- 16 vertical sliders generated by JS -->
|
<!-- 16 vertical sliders generated by JS -->
|
||||||
|
|
@ -4339,6 +4571,33 @@
|
||||||
<button id="legacy-graphic-eq-reset-btn" class="btn-secondary">
|
<button id="legacy-graphic-eq-reset-btn" class="btn-secondary">
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="legacy-geq-import-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Import EQ from text file (frequency/gain pairs, Q values ignored)"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="legacy-geq-export-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Export EQ to text file"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="legacy-geq-export-csv-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Export EQ as Equalizer APO GraphicEQ config line"
|
||||||
|
>
|
||||||
|
Export APO
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="legacy-geq-import-file"
|
||||||
|
accept=".txt,.csv"
|
||||||
|
style="display: none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -4853,6 +5112,19 @@
|
||||||
<option value="r_and_b">R&B</option>
|
<option value="r_and_b">R&B</option>
|
||||||
<option value="acoustic">Acoustic</option>
|
<option value="acoustic">Acoustic</option>
|
||||||
<option value="podcast">Speech</option>
|
<option value="podcast">Speech</option>
|
||||||
|
<option disabled>── Shelf ──</option>
|
||||||
|
<option value="shelf_warm">Warm</option>
|
||||||
|
<option value="shelf_bright">Bright & Airy</option>
|
||||||
|
<option value="shelf_hifi">Hi-Fi</option>
|
||||||
|
<option value="shelf_dark">Dark & Smooth</option>
|
||||||
|
<option value="shelf_radio">Radio Ready</option>
|
||||||
|
<option disabled>── Mid/Side ──</option>
|
||||||
|
<option value="ms_vocal_clarity">M/S Vocal Clarity</option>
|
||||||
|
<option value="ms_wide_stereo">M/S Wide Stereo</option>
|
||||||
|
<option value="ms_mono_bass">M/S Mono Bass</option>
|
||||||
|
<option value="ms_master_polish">M/S Master Polish</option>
|
||||||
|
<option value="ms_rock_master">M/S Rock Master</option>
|
||||||
|
<option value="ms_hiphop">M/S Hip-Hop</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
switch type {
|
switch type {
|
||||||
case .began:
|
case .began:
|
||||||
// Interruption began — system pauses audio automatically
|
// Interruption began - system pauses audio automatically
|
||||||
break
|
break
|
||||||
case .ended:
|
case .ended:
|
||||||
// Interruption ended — reactivate session so playback can resume
|
// Interruption ended - reactivate session so playback can resume
|
||||||
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
||||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||||
if options.contains(.shouldResume) {
|
if options.contains(.shouldResume) {
|
||||||
|
|
@ -75,7 +75,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
if reason == .oldDeviceUnavailable {
|
if reason == .oldDeviceUnavailable {
|
||||||
// Headphones/Bluetooth disconnected — reactivate session to keep background alive
|
// Headphones/Bluetooth disconnected - reactivate session to keep background alive
|
||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
20
js/HiFi.ts
20
js/HiFi.ts
|
|
@ -108,7 +108,7 @@ export interface TidalArtistProfile {
|
||||||
picture: string | null;
|
picture: string | null;
|
||||||
/** Fallback album cover UUID used when no artist picture exists, or `null`. */
|
/** Fallback album cover UUID used when no artist picture exists, or `null`. */
|
||||||
selectedAlbumCoverFallback: string | null;
|
selectedAlbumCoverFallback: string | null;
|
||||||
/** Popularity score (0–100). */
|
/** Popularity score (0-100). */
|
||||||
popularity: number;
|
popularity: number;
|
||||||
/** List of credited roles for this artist. */
|
/** List of credited roles for this artist. */
|
||||||
artistRoles: TidalArtistRole[];
|
artistRoles: TidalArtistRole[];
|
||||||
|
|
@ -150,7 +150,7 @@ export interface TidalTrackAlbumRef {
|
||||||
* Full track object as returned by the `/info` route and embedded in albums, playlists, and mixes.
|
* Full track object as returned by the `/info` route and embedded in albums, playlists, and mixes.
|
||||||
*
|
*
|
||||||
* @remarks
|
* @remarks
|
||||||
* Fields `bpm`, `key`, and `keyScale` are nullable — they are absent for some tracks.
|
* Fields `bpm`, `key`, and `keyScale` are nullable - they are absent for some tracks.
|
||||||
* `version` is present in the payload but may be `null`.
|
* `version` is present in the payload but may be `null`.
|
||||||
*/
|
*/
|
||||||
export interface TidalTrack {
|
export interface TidalTrack {
|
||||||
|
|
@ -162,7 +162,7 @@ export interface TidalTrack {
|
||||||
duration: number;
|
duration: number;
|
||||||
/** Track replay-gain value in dB. */
|
/** Track replay-gain value in dB. */
|
||||||
replayGain: number;
|
replayGain: number;
|
||||||
/** Track peak amplitude (0–1). */
|
/** Track peak amplitude (0-1). */
|
||||||
peak: number;
|
peak: number;
|
||||||
/** Whether the track is available for streaming. */
|
/** Whether the track is available for streaming. */
|
||||||
allowStreaming: boolean;
|
allowStreaming: boolean;
|
||||||
|
|
@ -186,7 +186,7 @@ export interface TidalTrack {
|
||||||
volumeNumber: number;
|
volumeNumber: number;
|
||||||
/** Version suffix (e.g. `"Remastered"`), or `null`. */
|
/** Version suffix (e.g. `"Remastered"`), or `null`. */
|
||||||
version: string | null;
|
version: string | null;
|
||||||
/** Popularity score (0–100). */
|
/** Popularity score (0-100). */
|
||||||
popularity: number;
|
popularity: number;
|
||||||
/** Copyright notice. */
|
/** Copyright notice. */
|
||||||
copyright: string;
|
copyright: string;
|
||||||
|
|
@ -299,7 +299,7 @@ export interface TidalAlbum {
|
||||||
explicit: boolean;
|
explicit: boolean;
|
||||||
/** UPC barcode. */
|
/** UPC barcode. */
|
||||||
upc: string;
|
upc: string;
|
||||||
/** Popularity score (0–100). */
|
/** Popularity score (0-100). */
|
||||||
popularity: number;
|
popularity: number;
|
||||||
/** Highest available audio quality. */
|
/** Highest available audio quality. */
|
||||||
audioQuality: string;
|
audioQuality: string;
|
||||||
|
|
@ -339,7 +339,7 @@ export interface TidalVideoItem {
|
||||||
volumeNumber: number;
|
volumeNumber: number;
|
||||||
/** Track number on the disc. */
|
/** Track number on the disc. */
|
||||||
trackNumber: number;
|
trackNumber: number;
|
||||||
/** Popularity score (0–100). */
|
/** Popularity score (0-100). */
|
||||||
popularity: number;
|
popularity: number;
|
||||||
/** Double-precision popularity score (present in topvideos). */
|
/** Double-precision popularity score (present in topvideos). */
|
||||||
doublePopularity?: number;
|
doublePopularity?: number;
|
||||||
|
|
@ -452,7 +452,7 @@ export interface TidalSimilarAlbum {
|
||||||
releaseDate: string;
|
releaseDate: string;
|
||||||
/** Copyright information. */
|
/** Copyright information. */
|
||||||
copyright: { text: string };
|
copyright: { text: string };
|
||||||
/** Popularity score (0–1 float). */
|
/** Popularity score (0-1 float). */
|
||||||
popularity: number;
|
popularity: number;
|
||||||
/** Access type, e.g. `"PUBLIC"`. */
|
/** Access type, e.g. `"PUBLIC"`. */
|
||||||
accessType: string;
|
accessType: string;
|
||||||
|
|
@ -533,7 +533,7 @@ export interface SimilarArtist {
|
||||||
url: string;
|
url: string;
|
||||||
/** Relation type, e.g. `"SIMILAR_ARTIST"`. */
|
/** Relation type, e.g. `"SIMILAR_ARTIST"`. */
|
||||||
relationType: string;
|
relationType: string;
|
||||||
/** Popularity score (0–1 float). */
|
/** Popularity score (0-1 float). */
|
||||||
popularity: number;
|
popularity: number;
|
||||||
/** External link entries (e.g. TIDAL sharing URL). */
|
/** External link entries (e.g. TIDAL sharing URL). */
|
||||||
externalLinks: Array<{ href: string; meta: { type: string } }>;
|
externalLinks: Array<{ href: string; meta: { type: string } }>;
|
||||||
|
|
@ -911,7 +911,7 @@ export interface TopVideosResponse extends VersionedResponse {
|
||||||
export interface TidalAudioNormData {
|
export interface TidalAudioNormData {
|
||||||
/** Replay gain value in dB. */
|
/** Replay gain value in dB. */
|
||||||
replayGain: number;
|
replayGain: number;
|
||||||
/** Peak amplitude (0–1). */
|
/** Peak amplitude (0-1). */
|
||||||
peakAmplitude: number;
|
peakAmplitude: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -961,7 +961,7 @@ export interface TrackManifestAttributes {
|
||||||
export interface TrackManifestResource {
|
export interface TrackManifestResource {
|
||||||
/** Resource identifier (track ID as a string). */
|
/** Resource identifier (track ID as a string). */
|
||||||
id: string;
|
id: string;
|
||||||
/** JSON:API resource type — always `"trackManifests"`. */
|
/** JSON:API resource type - always `"trackManifests"`. */
|
||||||
type: string;
|
type: string;
|
||||||
/** Manifest attributes. */
|
/** Manifest attributes. */
|
||||||
attributes: TrackManifestAttributes;
|
attributes: TrackManifestAttributes;
|
||||||
|
|
|
||||||
712
js/analytics.js
712
js/analytics.js
|
|
@ -34,722 +34,10 @@ export function trackPageView(path) {
|
||||||
trackEvent('pageview', { 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
|
// Initialize analytics on page load
|
||||||
export function initAnalytics() {
|
export function initAnalytics() {
|
||||||
if (!isAnalyticsEnabled()) return;
|
if (!isAnalyticsEnabled()) return;
|
||||||
|
|
||||||
// Track initial page view
|
// Track initial page view
|
||||||
trackPageView(window.location.pathname);
|
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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
126
js/app.js
126
js/app.js
|
|
@ -35,28 +35,7 @@ import { openEditProfile } from './profile.js';
|
||||||
import { ThemeStore } from './themeStore.js';
|
import { ThemeStore } from './themeStore.js';
|
||||||
import './commandPalette.js';
|
import './commandPalette.js';
|
||||||
import { initTracker } from './tracker.js';
|
import { initTracker } from './tracker.js';
|
||||||
import {
|
import { initAnalytics } from './analytics.js';
|
||||||
initAnalytics,
|
|
||||||
trackSidebarNavigation,
|
|
||||||
trackCreatePlaylist,
|
|
||||||
trackCreateFolder,
|
|
||||||
trackImportJSPF,
|
|
||||||
trackImportCSV,
|
|
||||||
trackImportXSPF,
|
|
||||||
trackImportXML,
|
|
||||||
trackImportM3U,
|
|
||||||
trackSelectLocalFolder,
|
|
||||||
trackChangeLocalFolder,
|
|
||||||
trackOpenModal,
|
|
||||||
trackCloseModal,
|
|
||||||
trackKeyboardShortcut,
|
|
||||||
trackPwaUpdate,
|
|
||||||
trackDismissUpdate,
|
|
||||||
trackOpenFullscreenCover,
|
|
||||||
trackCloseFullscreenCover,
|
|
||||||
trackOpenLyrics,
|
|
||||||
trackCloseLyrics,
|
|
||||||
} from './analytics.js';
|
|
||||||
import {
|
import {
|
||||||
parseCSV,
|
parseCSV,
|
||||||
parseJSPF,
|
parseJSPF,
|
||||||
|
|
@ -253,78 +232,68 @@ function initializeCasting(audioPlayer, castBtn) {
|
||||||
function initializeKeyboardShortcuts(player, _audioPlayer) {
|
function initializeKeyboardShortcuts(player, _audioPlayer) {
|
||||||
const keyActionMap = {
|
const keyActionMap = {
|
||||||
playPause: () => {
|
playPause: () => {
|
||||||
trackKeyboardShortcut('Space');
|
|
||||||
player.handlePlayPause();
|
player.handlePlayPause();
|
||||||
},
|
},
|
||||||
seekForward: () => {
|
seekForward: () => {
|
||||||
trackKeyboardShortcut('Right');
|
|
||||||
player.seekForward(10);
|
player.seekForward(10);
|
||||||
},
|
},
|
||||||
seekBackward: () => {
|
seekBackward: () => {
|
||||||
trackKeyboardShortcut('Left');
|
|
||||||
player.seekBackward(10);
|
player.seekBackward(10);
|
||||||
},
|
},
|
||||||
nextTrack: () => {
|
nextTrack: () => {
|
||||||
trackKeyboardShortcut('Shift+Right');
|
|
||||||
player.playNext();
|
player.playNext();
|
||||||
},
|
},
|
||||||
previousTrack: () => {
|
previousTrack: () => {
|
||||||
trackKeyboardShortcut('Shift+Left');
|
|
||||||
player.playPrev();
|
player.playPrev();
|
||||||
},
|
},
|
||||||
volumeUp: () => {
|
volumeUp: () => {
|
||||||
trackKeyboardShortcut('Up');
|
|
||||||
player.setVolume(player.userVolume + 0.1);
|
player.setVolume(player.userVolume + 0.1);
|
||||||
},
|
},
|
||||||
volumeDown: () => {
|
volumeDown: () => {
|
||||||
trackKeyboardShortcut('Down');
|
|
||||||
player.setVolume(player.userVolume - 0.1);
|
player.setVolume(player.userVolume - 0.1);
|
||||||
},
|
},
|
||||||
mute: () => {
|
mute: () => {
|
||||||
trackKeyboardShortcut('M');
|
|
||||||
const el = player.activeElement;
|
const el = player.activeElement;
|
||||||
el.muted = !el.muted;
|
el.muted = !el.muted;
|
||||||
},
|
},
|
||||||
shuffle: () => {
|
shuffle: () => {
|
||||||
trackKeyboardShortcut('S');
|
|
||||||
document.getElementById('shuffle-btn')?.click();
|
document.getElementById('shuffle-btn')?.click();
|
||||||
},
|
},
|
||||||
repeat: () => {
|
repeat: () => {
|
||||||
trackKeyboardShortcut('R');
|
|
||||||
document.getElementById('repeat-btn')?.click();
|
document.getElementById('repeat-btn')?.click();
|
||||||
},
|
},
|
||||||
queue: () => {
|
queue: () => {
|
||||||
trackKeyboardShortcut('Q');
|
|
||||||
document.getElementById('queue-btn')?.click();
|
document.getElementById('queue-btn')?.click();
|
||||||
},
|
},
|
||||||
lyrics: () => {
|
lyrics: () => {
|
||||||
trackKeyboardShortcut('L');
|
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||||
|
const isFullscreenOpen = overlay && getComputedStyle(overlay).display !== 'none';
|
||||||
|
|
||||||
|
if (isFullscreenOpen && UIRenderer.instance?.toggleFullscreenLyrics(overlay)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('toggle-lyrics-btn')?.click();
|
document.getElementById('toggle-lyrics-btn')?.click();
|
||||||
},
|
},
|
||||||
search: () => {
|
search: () => {
|
||||||
trackKeyboardShortcut('/');
|
|
||||||
document.getElementById('search-input')?.focus();
|
document.getElementById('search-input')?.focus();
|
||||||
},
|
},
|
||||||
escape: () => {
|
escape: () => {
|
||||||
trackKeyboardShortcut('Escape');
|
|
||||||
document.getElementById('search-input')?.blur();
|
document.getElementById('search-input')?.blur();
|
||||||
sidePanelManager.close();
|
sidePanelManager.close();
|
||||||
clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
|
clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
|
||||||
},
|
},
|
||||||
visualizerNext: () => {
|
visualizerNext: () => {
|
||||||
trackKeyboardShortcut('VisualizerNext');
|
|
||||||
if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) {
|
if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) {
|
||||||
UIRenderer.instance.visualizer.presets['butterchurn'].nextPreset();
|
UIRenderer.instance.visualizer.presets['butterchurn'].nextPreset();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
visualizerPrev: () => {
|
visualizerPrev: () => {
|
||||||
trackKeyboardShortcut('VisualizerPrev');
|
|
||||||
if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) {
|
if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) {
|
||||||
UIRenderer.instance.visualizer.presets['butterchurn'].prevPreset();
|
UIRenderer.instance.visualizer.presets['butterchurn'].prevPreset();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
visualizerCycle: () => {
|
visualizerCycle: () => {
|
||||||
trackKeyboardShortcut('VisualizerCycle');
|
|
||||||
if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) {
|
if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) {
|
||||||
UIRenderer.instance.visualizer.presets['butterchurn'].toggleCycle();
|
UIRenderer.instance.visualizer.presets['butterchurn'].toggleCycle();
|
||||||
}
|
}
|
||||||
|
|
@ -361,6 +330,19 @@ function initializeKeyboardShortcuts(player, _audioPlayer) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function closeFullscreenOverlay() {
|
||||||
|
if (UIRenderer.instance?.dismissFullscreenCover) {
|
||||||
|
await UIRenderer.instance.dismissFullscreenCover({ animate: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.hash === '#fullscreen') {
|
||||||
|
window.history.back();
|
||||||
|
} else {
|
||||||
|
UIRenderer.instance?.closeFullscreenCover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showOfflineNotification() {
|
function showOfflineNotification() {
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = 'offline-notification';
|
notification.className = 'offline-notification';
|
||||||
|
|
@ -531,7 +513,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
* visited the local tab yet).
|
* visited the local tab yet).
|
||||||
*/
|
*/
|
||||||
async function scanLocalMediaFolder(onlyIfAlreadyScanned = false) {
|
async function scanLocalMediaFolder(onlyIfAlreadyScanned = false) {
|
||||||
// Skip the scan if the user has never visited the local tab – they'll
|
// Skip the scan if the user has never visited the local tab - they'll
|
||||||
// get a fresh scan when they navigate there for the first time.
|
// get a fresh scan when they navigate there for the first time.
|
||||||
if (onlyIfAlreadyScanned && !window.localFilesCache) return;
|
if (onlyIfAlreadyScanned && !window.localFilesCache) return;
|
||||||
|
|
||||||
|
|
@ -674,7 +656,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const href = link.getAttribute('href');
|
const href = link.getAttribute('href');
|
||||||
if (href && !href.startsWith('http')) {
|
if (href && !href.startsWith('http')) {
|
||||||
const item = link.querySelector('span')?.textContent || href;
|
const item = link.querySelector('span')?.textContent || href;
|
||||||
trackSidebarNavigation(item);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -709,18 +690,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
if (mode === 'lyrics') {
|
if (mode === 'lyrics') {
|
||||||
const isActive = sidePanelManager.isActive('lyrics');
|
const isActive = sidePanelManager.isActive('lyrics');
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
trackCloseLyrics(Player.instance.currentTrack);
|
|
||||||
} else {
|
|
||||||
trackOpenLyrics(Player.instance.currentTrack);
|
|
||||||
}
|
|
||||||
} else if (mode === 'cover') {
|
} else if (mode === 'cover') {
|
||||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||||
if (overlay && overlay.style.display === 'flex') {
|
if (overlay && overlay.style.display === 'flex') {
|
||||||
trackCloseFullscreenCover();
|
|
||||||
} else {
|
} else {
|
||||||
trackOpenFullscreenCover(Player.instance.currentTrack);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -736,11 +709,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
} else if (mode === 'cover') {
|
} else if (mode === 'cover') {
|
||||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||||
if (overlay && overlay.style.display === 'flex') {
|
if (overlay && overlay.style.display === 'flex') {
|
||||||
if (window.location.hash === '#fullscreen') {
|
await closeFullscreenOverlay();
|
||||||
window.history.back();
|
|
||||||
} else {
|
|
||||||
UIRenderer.instance.closeFullscreenCover();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const nextTrack = Player.instance.getNextTrack();
|
const nextTrack = Player.instance.getNextTrack();
|
||||||
UIRenderer.instance.showFullscreenCover(
|
UIRenderer.instance.showFullscreenCover(
|
||||||
|
|
@ -764,13 +733,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
if (shareBtn) shareBtn.style.display = e.target.checked ? 'flex' : 'none';
|
if (shareBtn) shareBtn.style.display = e.target.checked ? 'flex' : 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('close-fullscreen-cover-btn')?.addEventListener('click', () => {
|
document.getElementById('close-fullscreen-cover-btn')?.addEventListener('click', async () => {
|
||||||
trackCloseFullscreenCover();
|
await closeFullscreenOverlay();
|
||||||
if (window.location.hash === '#fullscreen') {
|
|
||||||
window.history.back();
|
|
||||||
} else {
|
|
||||||
UIRenderer.instance.closeFullscreenCover();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('fullscreen-cover-overlay')?.addEventListener('click', (e) => {
|
document.getElementById('fullscreen-cover-overlay')?.addEventListener('click', (e) => {
|
||||||
|
|
@ -785,11 +749,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'exit':
|
case 'exit':
|
||||||
if (window.location.hash === '#fullscreen') {
|
closeFullscreenOverlay();
|
||||||
window.history.back();
|
|
||||||
} else {
|
|
||||||
UIRenderer.instance.closeFullscreenCover();
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'hide-ui':
|
case 'hide-ui':
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
|
|
@ -831,11 +791,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
case 'nothing':
|
case 'nothing':
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (window.location.hash === '#fullscreen') {
|
closeFullscreenOverlay();
|
||||||
window.history.back();
|
|
||||||
} else {
|
|
||||||
UIRenderer.instance.closeFullscreenCover();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1328,7 +1284,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.target.closest('#create-playlist-btn') || e.target.closest('#library-create-playlist-card')) {
|
if (e.target.closest('#create-playlist-btn') || e.target.closest('#library-create-playlist-card')) {
|
||||||
trackOpenModal('Create Playlist');
|
|
||||||
const modal = document.getElementById('playlist-modal');
|
const modal = document.getElementById('playlist-modal');
|
||||||
document.getElementById('playlist-modal-title').textContent = 'Create Playlist';
|
document.getElementById('playlist-modal-title').textContent = 'Create Playlist';
|
||||||
document.getElementById('playlist-name-input').value = '';
|
document.getElementById('playlist-name-input').value = '';
|
||||||
|
|
@ -1382,7 +1337,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.target.closest('#create-folder-btn') || e.target.closest('#library-create-folder-card')) {
|
if (e.target.closest('#create-folder-btn') || e.target.closest('#library-create-folder-card')) {
|
||||||
trackOpenModal('Create Folder');
|
|
||||||
const modal = document.getElementById('folder-modal');
|
const modal = document.getElementById('folder-modal');
|
||||||
document.getElementById('folder-name-input').value = '';
|
document.getElementById('folder-name-input').value = '';
|
||||||
document.getElementById('folder-cover-input').value = '';
|
document.getElementById('folder-cover-input').value = '';
|
||||||
|
|
@ -1396,11 +1350,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
const folder = await db.createFolder(name, cover);
|
const folder = await db.createFolder(name, cover);
|
||||||
trackCreateFolder(folder);
|
|
||||||
await syncManager.syncUserFolder(folder, 'create');
|
await syncManager.syncUserFolder(folder, 'create');
|
||||||
UIRenderer.instance.renderLibraryPage();
|
UIRenderer.instance.renderLibraryPage();
|
||||||
document.getElementById('folder-modal').classList.remove('active');
|
document.getElementById('folder-modal').classList.remove('active');
|
||||||
trackCloseModal('Create Folder');
|
|
||||||
} else {
|
} else {
|
||||||
showNotification('Please enter a folder name.');
|
showNotification('Please enter a folder name.');
|
||||||
}
|
}
|
||||||
|
|
@ -1589,7 +1541,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Imported ${tracks.length} tracks from YouTube`);
|
console.log(`Imported ${tracks.length} tracks from YouTube`);
|
||||||
trackImportCSV(name || 'Untitled', tracks.length, missingTracks.length);
|
|
||||||
|
|
||||||
if (missingTracks.length > 0) {
|
if (missingTracks.length > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -1669,12 +1620,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
jspfPlaylist?.creator ||
|
jspfPlaylist?.creator ||
|
||||||
jspfPlaylist?.extension?.['https://musicbrainz.org/doc/jspf#playlist']?.creator ||
|
jspfPlaylist?.extension?.['https://musicbrainz.org/doc/jspf#playlist']?.creator ||
|
||||||
'unknown';
|
'unknown';
|
||||||
trackImportJSPF(
|
|
||||||
name || jspfPlaylist?.title || 'Untitled',
|
|
||||||
tracks.length,
|
|
||||||
missingTracks.length,
|
|
||||||
jspfCreator
|
|
||||||
);
|
|
||||||
|
|
||||||
if (missingTracks.length > 0) {
|
if (missingTracks.length > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -1788,8 +1733,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
console.log(`Imported ${tracks.length} tracks from CSV`);
|
console.log(`Imported ${tracks.length} tracks from CSV`);
|
||||||
|
|
||||||
trackImportCSV(name || 'Untitled', tracks.length, missingTracks.length);
|
|
||||||
|
|
||||||
if (missingTracks.length > 0) {
|
if (missingTracks.length > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
||||||
|
|
@ -1847,8 +1790,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
console.log(`Imported ${tracks.length} tracks from XSPF`);
|
console.log(`Imported ${tracks.length} tracks from XSPF`);
|
||||||
|
|
||||||
trackImportXSPF(name || 'Untitled', tracks.length, missingTracks.length);
|
|
||||||
|
|
||||||
if (missingTracks.length > 0) {
|
if (missingTracks.length > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
||||||
|
|
@ -1906,8 +1847,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
console.log(`Imported ${tracks.length} tracks from XML`);
|
console.log(`Imported ${tracks.length} tracks from XML`);
|
||||||
|
|
||||||
trackImportXML(name || 'Untitled', tracks.length, missingTracks.length);
|
|
||||||
|
|
||||||
if (missingTracks.length > 0) {
|
if (missingTracks.length > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
||||||
|
|
@ -1965,8 +1904,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
console.log(`Imported ${tracks.length} tracks from M3U`);
|
console.log(`Imported ${tracks.length} tracks from M3U`);
|
||||||
|
|
||||||
trackImportM3U(name || 'Untitled', tracks.length, missingTracks.length);
|
|
||||||
|
|
||||||
if (missingTracks.length > 0) {
|
if (missingTracks.length > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
||||||
|
|
@ -1998,10 +1935,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
// Update DB again with isPublic flag
|
// Update DB again with isPublic flag
|
||||||
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
||||||
await syncManager.syncUserPlaylist(playlist, 'create');
|
await syncManager.syncUserPlaylist(playlist, 'create');
|
||||||
trackCreatePlaylist(playlist, importSource);
|
|
||||||
UIRenderer.instance.renderLibraryPage();
|
UIRenderer.instance.renderLibraryPage();
|
||||||
modal.classList.remove('active');
|
modal.classList.remove('active');
|
||||||
trackCloseModal('Create Playlist');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2503,9 +2438,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.saveSetting('local_folder_handle', handle);
|
await db.saveSetting('local_folder_handle', handle);
|
||||||
if (isChange) {
|
|
||||||
trackChangeLocalFolder();
|
|
||||||
}
|
|
||||||
|
|
||||||
const btn = document.getElementById('select-local-folder-btn');
|
const btn = document.getElementById('select-local-folder-btn');
|
||||||
const btnText = document.getElementById('select-local-folder-text');
|
const btnText = document.getElementById('select-local-folder-text');
|
||||||
|
|
@ -2516,7 +2448,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracks = scanLocalMediaFolder(true);
|
const tracks = scanLocalMediaFolder(true);
|
||||||
trackSelectLocalFolder(tracks?.length ?? 0);
|
|
||||||
UIRenderer.instance.renderLibraryPage();
|
UIRenderer.instance.renderLibraryPage();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name !== 'AbortError') {
|
if (err.name !== 'AbortError') {
|
||||||
|
|
@ -2703,12 +2634,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
onNeedRefresh() {
|
onNeedRefresh() {
|
||||||
if (pwaUpdateSettings.isAutoUpdateEnabled()) {
|
if (pwaUpdateSettings.isAutoUpdateEnabled()) {
|
||||||
// Auto-update: immediately activate the new service worker
|
// Auto-update: immediately activate the new service worker
|
||||||
trackPwaUpdate();
|
|
||||||
updateSW(true);
|
updateSW(true);
|
||||||
} else {
|
} else {
|
||||||
// Show notification with Update button and dismiss option
|
// Show notification with Update button and dismiss option
|
||||||
showUpdateNotification(() => {
|
showUpdateNotification(() => {
|
||||||
trackPwaUpdate();
|
|
||||||
updateSW(true);
|
updateSW(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -2951,7 +2880,6 @@ function showUpdateNotification(updateCallback) {
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('dismiss-update-btn').addEventListener('click', () => {
|
document.getElementById('dismiss-update-btn').addEventListener('click', () => {
|
||||||
trackDismissUpdate();
|
|
||||||
notification.remove();
|
notification.remove();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
// Supports 3-32 parametric EQ bands
|
// Supports 3-32 parametric EQ bands
|
||||||
|
|
||||||
import { isIos } from './platform-detection.js';
|
import { isIos } from './platform-detection.js';
|
||||||
import { equalizerSettings, monoAudioSettings } from './storage.js';
|
import { equalizerSettings, monoAudioSettings, binauralDspSettings } from './storage.js';
|
||||||
|
import { BinauralDSP } from './binaural-dsp.js';
|
||||||
|
|
||||||
// Generate frequency array for given number of bands using logarithmic spacing
|
// Generate frequency array for given number of bands using logarithmic spacing
|
||||||
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
|
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
|
||||||
|
|
@ -11,6 +12,12 @@ function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
|
||||||
const safeMin = Math.max(10, minFreq);
|
const safeMin = Math.max(10, minFreq);
|
||||||
const safeMax = Math.min(96000, maxFreq);
|
const safeMax = Math.min(96000, maxFreq);
|
||||||
|
|
||||||
|
if (bandCount <= 1) {
|
||||||
|
// Single band: use geometric mean of range
|
||||||
|
frequencies.push(Math.round(Math.sqrt(safeMin * safeMax)));
|
||||||
|
return frequencies;
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < bandCount; i++) {
|
for (let i = 0; i < bandCount; i++) {
|
||||||
// Logarithmic interpolation
|
// Logarithmic interpolation
|
||||||
const t = i / (bandCount - 1);
|
const t = i / (bandCount - 1);
|
||||||
|
|
@ -102,6 +109,28 @@ class AudioContextManager {
|
||||||
this.isMonoAudioEnabled = false;
|
this.isMonoAudioEnabled = false;
|
||||||
this.monoMergerNode = null;
|
this.monoMergerNode = null;
|
||||||
this.audio = null;
|
this.audio = null;
|
||||||
|
|
||||||
|
// M/S (Mid/Side) processing state
|
||||||
|
this.msEnabled = false;
|
||||||
|
this.msSplitter = null;
|
||||||
|
this.msEncoderMidL = null;
|
||||||
|
this.msEncoderMidR = null;
|
||||||
|
this.msEncoderSideL = null;
|
||||||
|
this.msEncoderSideR = null;
|
||||||
|
this.msMidInput = null;
|
||||||
|
this.msSideInput = null;
|
||||||
|
this.midFilters = [];
|
||||||
|
this.sideFilters = [];
|
||||||
|
this.midOutputNode = null;
|
||||||
|
this.sideOutputNode = null;
|
||||||
|
this.msDecoderMidToL = null;
|
||||||
|
this.msDecoderSideToL = null;
|
||||||
|
this.msDecoderMidToR = null;
|
||||||
|
this.msDecoderSideToR = null;
|
||||||
|
this.msLMix = null;
|
||||||
|
this.msRMix = null;
|
||||||
|
this.msMerger = null;
|
||||||
|
this.msOutputNode = null;
|
||||||
this.currentVolume = 1.0;
|
this.currentVolume = 1.0;
|
||||||
|
|
||||||
// Band configuration
|
// Band configuration
|
||||||
|
|
@ -109,17 +138,24 @@ class AudioContextManager {
|
||||||
this.freqRange = equalizerSettings.getFreqRange();
|
this.freqRange = equalizerSettings.getFreqRange();
|
||||||
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
||||||
this.currentGains = new Array(this.bandCount).fill(0);
|
this.currentGains = new Array(this.bandCount).fill(0);
|
||||||
|
this.currentChannels = new Array(this.bandCount).fill('stereo');
|
||||||
|
|
||||||
|
// Binaural DSP state
|
||||||
|
this.binauralDsp = null;
|
||||||
|
this.isBinauralEnabled = binauralDspSettings.isEnabled();
|
||||||
|
|
||||||
// Callbacks for audio graph changes (for visualizers like Butterchurn)
|
// Callbacks for audio graph changes (for visualizers like Butterchurn)
|
||||||
this._graphChangeCallbacks = [];
|
this._graphChangeCallbacks = [];
|
||||||
|
|
||||||
// --- Graphic EQ (16-band, separate chain) ---
|
// --- Graphic EQ (configurable bands, separate chain) ---
|
||||||
this.geqFilters = [];
|
this.geqFilters = [];
|
||||||
this.geqPreampNode = null;
|
this.geqPreampNode = null;
|
||||||
this.geqOutputNode = null;
|
this.geqOutputNode = null;
|
||||||
this.isGraphicEQEnabled = equalizerSettings.isGraphicEqEnabled();
|
this.isGraphicEQEnabled = equalizerSettings.isGraphicEqEnabled();
|
||||||
this.geqFrequencies = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000];
|
this.geqBandCount = equalizerSettings.getGraphicEqBandCount();
|
||||||
this.geqGains = equalizerSettings.getGraphicEqGains();
|
this.geqFreqRange = equalizerSettings.getGraphicEqFreqRange();
|
||||||
|
this.geqFrequencies = generateFrequencies(this.geqBandCount, this.geqFreqRange.min, this.geqFreqRange.max);
|
||||||
|
this.geqGains = equalizerSettings.getGraphicEqGains(this.geqBandCount);
|
||||||
this.geqPreamp = equalizerSettings.getGraphicEqPreamp();
|
this.geqPreamp = equalizerSettings.getGraphicEqPreamp();
|
||||||
|
|
||||||
// Load saved settings
|
// Load saved settings
|
||||||
|
|
@ -145,15 +181,16 @@ class AudioContextManager {
|
||||||
this.frequencies = generateFrequencies(newCount, this.freqRange.min, this.freqRange.max);
|
this.frequencies = generateFrequencies(newCount, this.freqRange.min, this.freqRange.max);
|
||||||
|
|
||||||
// Interpolate current gains to new band count
|
// Interpolate current gains to new band count
|
||||||
const newGains = equalizerSettings._interpolateGains(this.currentGains, newCount);
|
const newGains = equalizerSettings.interpolateGains(this.currentGains, newCount);
|
||||||
this.currentGains = newGains;
|
this.currentGains = newGains;
|
||||||
equalizerSettings.setGains(newGains);
|
equalizerSettings.setGains(newGains);
|
||||||
|
|
||||||
// Reinitialize EQ if already initialized
|
// Reinitialize EQ if already initialized
|
||||||
if (this.isInitialized && this.audioContext) {
|
if (this.isInitialized && this.audioContext) {
|
||||||
|
this._destroyMSFilters();
|
||||||
this._destroyEQ();
|
this._destroyEQ();
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
// Reconnect the audio graph without interrupting playback
|
if (this.msEnabled) this._createMSFilters();
|
||||||
this._connectGraph();
|
this._connectGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,9 +225,10 @@ class AudioContextManager {
|
||||||
|
|
||||||
// Reinitialize EQ if already initialized
|
// Reinitialize EQ if already initialized
|
||||||
if (this.isInitialized && this.audioContext) {
|
if (this.isInitialized && this.audioContext) {
|
||||||
|
this._destroyMSFilters();
|
||||||
this._destroyEQ();
|
this._destroyEQ();
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
// Reconnect the audio graph without interrupting playback
|
if (this.msEnabled) this._createMSFilters();
|
||||||
this._connectGraph();
|
this._connectGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,6 +268,131 @@ class AudioContextManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create M/S matrix nodes (encoder, decoder, merger).
|
||||||
|
* These are cheap static nodes created once in init().
|
||||||
|
*/
|
||||||
|
_createMSNodes() {
|
||||||
|
if (!this.audioContext) return;
|
||||||
|
|
||||||
|
this.msSplitter = this.audioContext.createChannelSplitter(2);
|
||||||
|
|
||||||
|
// Encoder: L/R → M/S
|
||||||
|
this.msEncoderMidL = this.audioContext.createGain();
|
||||||
|
this.msEncoderMidL.gain.value = 0.5;
|
||||||
|
this.msEncoderMidR = this.audioContext.createGain();
|
||||||
|
this.msEncoderMidR.gain.value = 0.5;
|
||||||
|
this.msEncoderSideL = this.audioContext.createGain();
|
||||||
|
this.msEncoderSideL.gain.value = 0.5;
|
||||||
|
this.msEncoderSideR = this.audioContext.createGain();
|
||||||
|
this.msEncoderSideR.gain.value = -0.5;
|
||||||
|
|
||||||
|
// Mono mixing points for M and S signals
|
||||||
|
this.msMidInput = this.audioContext.createGain();
|
||||||
|
this.msMidInput.channelCount = 1;
|
||||||
|
this.msMidInput.channelCountMode = 'explicit';
|
||||||
|
this.msSideInput = this.audioContext.createGain();
|
||||||
|
this.msSideInput.channelCount = 1;
|
||||||
|
this.msSideInput.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
// Chain output nodes
|
||||||
|
this.midOutputNode = this.audioContext.createGain();
|
||||||
|
this.sideOutputNode = this.audioContext.createGain();
|
||||||
|
|
||||||
|
// Decoder: M/S → L/R
|
||||||
|
this.msDecoderMidToL = this.audioContext.createGain();
|
||||||
|
this.msDecoderMidToL.gain.value = 1.0;
|
||||||
|
this.msDecoderSideToL = this.audioContext.createGain();
|
||||||
|
this.msDecoderSideToL.gain.value = 1.0;
|
||||||
|
this.msDecoderMidToR = this.audioContext.createGain();
|
||||||
|
this.msDecoderMidToR.gain.value = 1.0;
|
||||||
|
this.msDecoderSideToR = this.audioContext.createGain();
|
||||||
|
this.msDecoderSideToR.gain.value = -1.0;
|
||||||
|
|
||||||
|
// L/R recombination points (mono)
|
||||||
|
this.msLMix = this.audioContext.createGain();
|
||||||
|
this.msLMix.channelCount = 1;
|
||||||
|
this.msLMix.channelCountMode = 'explicit';
|
||||||
|
this.msRMix = this.audioContext.createGain();
|
||||||
|
this.msRMix.channelCount = 1;
|
||||||
|
this.msRMix.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this.msMerger = this.audioContext.createChannelMerger(2);
|
||||||
|
this.msOutputNode = this.audioContext.createGain();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create parallel M/S filter chains based on current band settings.
|
||||||
|
* Mid filters process the center image, Side filters process stereo width.
|
||||||
|
*/
|
||||||
|
_createMSFilters() {
|
||||||
|
if (!this.audioContext) return;
|
||||||
|
|
||||||
|
this.midFilters = this.frequencies.map((freq, i) => {
|
||||||
|
const type = (this.currentTypes && this.currentTypes[i]) || 'peaking';
|
||||||
|
const q = this.currentQs && this.currentQs[i] > 0 ? this.currentQs[i] : this._calculateQ(i);
|
||||||
|
const ch = (this.currentChannels && this.currentChannels[i]) || 'stereo';
|
||||||
|
const gain = ch === 'side' ? 0 : this.currentGains[i] || 0;
|
||||||
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
|
filter.type = type;
|
||||||
|
filter.frequency.value = freq;
|
||||||
|
filter.Q.value = q;
|
||||||
|
filter.gain.value = gain;
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sideFilters = this.frequencies.map((freq, i) => {
|
||||||
|
const type = (this.currentTypes && this.currentTypes[i]) || 'peaking';
|
||||||
|
const q = this.currentQs && this.currentQs[i] > 0 ? this.currentQs[i] : this._calculateQ(i);
|
||||||
|
const ch = (this.currentChannels && this.currentChannels[i]) || 'stereo';
|
||||||
|
const gain = ch === 'mid' ? 0 : this.currentGains[i] || 0;
|
||||||
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
|
filter.type = type;
|
||||||
|
filter.frequency.value = freq;
|
||||||
|
filter.Q.value = q;
|
||||||
|
filter.gain.value = gain;
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy M/S parallel filter chains
|
||||||
|
*/
|
||||||
|
_destroyMSFilters() {
|
||||||
|
const sd = (node) => {
|
||||||
|
try {
|
||||||
|
node?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.midFilters.forEach(sd);
|
||||||
|
this.sideFilters.forEach(sd);
|
||||||
|
this.midFilters = [];
|
||||||
|
this.sideFilters = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing filter chain in place.
|
||||||
|
* @param {Array} chain - Filter array to update (this.filters, this.midFilters, or this.sideFilters)
|
||||||
|
* @param {Array} freqs - New frequencies
|
||||||
|
* @param {Array} types - New filter types
|
||||||
|
* @param {Array} qs - New Q values
|
||||||
|
* @param {Array} gains - New gain values
|
||||||
|
* @param {number} now - Current audio context time
|
||||||
|
*/
|
||||||
|
_updateFilterChain(chain, freqs, types, qs, gains, now) {
|
||||||
|
chain.forEach((filter, i) => {
|
||||||
|
const type = types[i] || 'peaking';
|
||||||
|
const q = qs[i] > 0 ? qs[i] : this._calculateQ(i);
|
||||||
|
const gain = gains[i];
|
||||||
|
filter.type = type;
|
||||||
|
filter.frequency.setTargetAtTime(freqs[i], now, 0.005);
|
||||||
|
filter.gain.setTargetAtTime(gain, now, 0.005);
|
||||||
|
filter.Q.setTargetAtTime(q, now, 0.005);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create EQ filters
|
* Create EQ filters
|
||||||
*/
|
*/
|
||||||
|
|
@ -245,14 +408,16 @@ class AudioContextManager {
|
||||||
const gainValue = Math.pow(10, preampValue / 20);
|
const gainValue = Math.pow(10, preampValue / 20);
|
||||||
this.preampNode.gain.value = gainValue;
|
this.preampNode.gain.value = gainValue;
|
||||||
|
|
||||||
// Create biquad filters for each frequency band
|
// Create filters for each frequency band
|
||||||
this.filters = this.frequencies.map((freq, index) => {
|
this.filters = this.frequencies.map((freq, index) => {
|
||||||
|
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
||||||
|
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
||||||
|
const gain = this.currentGains[index] || 0;
|
||||||
const filter = this.audioContext.createBiquadFilter();
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
filter.type = type;
|
||||||
filter.frequency.value = freq;
|
filter.frequency.value = freq;
|
||||||
filter.Q.value =
|
filter.Q.value = q;
|
||||||
this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
filter.gain.value = gain;
|
||||||
filter.gain.value = this.currentGains[index] || 0;
|
|
||||||
return filter;
|
return filter;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -326,16 +491,34 @@ class AudioContextManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.sources.has(audioElement)) {
|
if (!this.sources.has(audioElement)) {
|
||||||
this.sources.set(audioElement, this.audioContext.createMediaElementSource(audioElement));
|
const src = this.audioContext.createMediaElementSource(audioElement);
|
||||||
|
this.sources.set(audioElement, src);
|
||||||
}
|
}
|
||||||
this.source = this.sources.get(audioElement);
|
this.source = this.sources.get(audioElement);
|
||||||
|
|
||||||
|
// Enable multichannel passthrough for Atmos/spatial content
|
||||||
|
try {
|
||||||
|
this.audioContext.destination.channelCount = Math.min(this.audioContext.destination.maxChannelCount, 8);
|
||||||
|
this.audioContext.destination.channelCountMode = 'explicit';
|
||||||
|
this.audioContext.destination.channelInterpretation = 'discrete';
|
||||||
|
} catch {
|
||||||
|
// Some browsers may not support changing destination channel count
|
||||||
|
}
|
||||||
|
|
||||||
this.analyser = this.audioContext.createAnalyser();
|
this.analyser = this.audioContext.createAnalyser();
|
||||||
this.analyser.fftSize = 1024;
|
this.analyser.fftSize = 1024;
|
||||||
this.analyser.smoothingTimeConstant = 0.7;
|
this.analyser.smoothingTimeConstant = 0.7;
|
||||||
|
|
||||||
|
// Create binaural DSP processor
|
||||||
|
this.binauralDsp = new BinauralDSP(this.audioContext);
|
||||||
|
void this._loadBinauralSettings();
|
||||||
|
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
this._createGraphicEQ();
|
this._createGraphicEQ();
|
||||||
|
this._createMSNodes();
|
||||||
|
if (this.msEnabled) {
|
||||||
|
this._createMSFilters();
|
||||||
|
}
|
||||||
|
|
||||||
this.outputNode = this.audioContext.createGain();
|
this.outputNode = this.audioContext.createGain();
|
||||||
this.outputNode.gain.value = 1;
|
this.outputNode.gain.value = 1;
|
||||||
|
|
@ -447,9 +630,36 @@ class AudioContextManager {
|
||||||
safeDisconnect(this.source);
|
safeDisconnect(this.source);
|
||||||
safeDisconnect(this.monoGainNode);
|
safeDisconnect(this.monoGainNode);
|
||||||
safeDisconnect(this.monoMergerNode);
|
safeDisconnect(this.monoMergerNode);
|
||||||
|
// Binaural DSP disconnects internally
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
const { input, output } = this.binauralDsp.getNodes();
|
||||||
|
safeDisconnect(input);
|
||||||
|
safeDisconnect(output);
|
||||||
|
}
|
||||||
safeDisconnect(this.preampNode);
|
safeDisconnect(this.preampNode);
|
||||||
this.filters.forEach(safeDisconnect);
|
this.filters.forEach(safeDisconnect);
|
||||||
safeDisconnect(this.outputNode);
|
safeDisconnect(this.outputNode);
|
||||||
|
// M/S nodes
|
||||||
|
safeDisconnect(this.msSplitter);
|
||||||
|
safeDisconnect(this.msEncoderMidL);
|
||||||
|
safeDisconnect(this.msEncoderMidR);
|
||||||
|
safeDisconnect(this.msEncoderSideL);
|
||||||
|
safeDisconnect(this.msEncoderSideR);
|
||||||
|
safeDisconnect(this.msMidInput);
|
||||||
|
safeDisconnect(this.msSideInput);
|
||||||
|
this.midFilters.forEach(safeDisconnect);
|
||||||
|
this.sideFilters.forEach(safeDisconnect);
|
||||||
|
safeDisconnect(this.midOutputNode);
|
||||||
|
safeDisconnect(this.sideOutputNode);
|
||||||
|
safeDisconnect(this.msDecoderMidToL);
|
||||||
|
safeDisconnect(this.msDecoderSideToL);
|
||||||
|
safeDisconnect(this.msDecoderMidToR);
|
||||||
|
safeDisconnect(this.msDecoderSideToR);
|
||||||
|
safeDisconnect(this.msLMix);
|
||||||
|
safeDisconnect(this.msRMix);
|
||||||
|
safeDisconnect(this.msMerger);
|
||||||
|
safeDisconnect(this.msOutputNode);
|
||||||
|
// Graphic EQ + tail
|
||||||
safeDisconnect(this.geqPreampNode);
|
safeDisconnect(this.geqPreampNode);
|
||||||
this.geqFilters.forEach(safeDisconnect);
|
this.geqFilters.forEach(safeDisconnect);
|
||||||
safeDisconnect(this.geqOutputNode);
|
safeDisconnect(this.geqOutputNode);
|
||||||
|
|
@ -466,18 +676,77 @@ class AudioContextManager {
|
||||||
lastNode = this.monoMergerNode;
|
lastNode = this.monoMergerNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert binaural DSP before EQ
|
||||||
|
if (this.isBinauralEnabled && this.binauralDsp) {
|
||||||
|
const { input, output } = this.binauralDsp.getNodes();
|
||||||
|
lastNode.connect(input);
|
||||||
|
this.binauralDsp.reconnect();
|
||||||
|
lastNode = output;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isEQEnabled && this.filters.length > 0) {
|
if (this.isEQEnabled && this.filters.length > 0) {
|
||||||
for (let i = 0; i < this.filters.length - 1; i++) {
|
const useMS = this.msEnabled && this.midFilters.length > 0 && this.sideFilters.length > 0;
|
||||||
this.filters[i].connect(this.filters[i + 1]);
|
|
||||||
}
|
// Connect preamp
|
||||||
if (this.preampNode) {
|
if (this.preampNode) {
|
||||||
lastNode.connect(this.preampNode);
|
lastNode.connect(this.preampNode);
|
||||||
this.preampNode.connect(this.filters[0]);
|
lastNode = this.preampNode;
|
||||||
} else {
|
}
|
||||||
lastNode.connect(this.filters[0]);
|
|
||||||
|
if (useMS) {
|
||||||
|
// === M/S processing path ===
|
||||||
|
// Encode L/R → M/S
|
||||||
|
lastNode.connect(this.msSplitter);
|
||||||
|
|
||||||
|
this.msSplitter.connect(this.msEncoderMidL, 0); // L → Mid
|
||||||
|
this.msSplitter.connect(this.msEncoderMidR, 1); // R → Mid
|
||||||
|
this.msEncoderMidL.connect(this.msMidInput);
|
||||||
|
this.msEncoderMidR.connect(this.msMidInput); // Mid = (L+R)*0.5
|
||||||
|
|
||||||
|
this.msSplitter.connect(this.msEncoderSideL, 0); // L → Side
|
||||||
|
this.msSplitter.connect(this.msEncoderSideR, 1); // R → Side (-0.5)
|
||||||
|
this.msEncoderSideL.connect(this.msSideInput);
|
||||||
|
this.msEncoderSideR.connect(this.msSideInput); // Side = (L-R)*0.5
|
||||||
|
|
||||||
|
// Mid filter chain
|
||||||
|
this.msMidInput.connect(this.midFilters[0]);
|
||||||
|
for (let i = 0; i < this.midFilters.length - 1; i++) {
|
||||||
|
this.midFilters[i].connect(this.midFilters[i + 1]);
|
||||||
|
}
|
||||||
|
this.midFilters[this.midFilters.length - 1].connect(this.midOutputNode);
|
||||||
|
|
||||||
|
// Side filter chain
|
||||||
|
this.msSideInput.connect(this.sideFilters[0]);
|
||||||
|
for (let i = 0; i < this.sideFilters.length - 1; i++) {
|
||||||
|
this.sideFilters[i].connect(this.sideFilters[i + 1]);
|
||||||
|
}
|
||||||
|
this.sideFilters[this.sideFilters.length - 1].connect(this.sideOutputNode);
|
||||||
|
|
||||||
|
// Decode M/S → L/R
|
||||||
|
this.midOutputNode.connect(this.msDecoderMidToL);
|
||||||
|
this.sideOutputNode.connect(this.msDecoderSideToL);
|
||||||
|
this.msDecoderMidToL.connect(this.msLMix);
|
||||||
|
this.msDecoderSideToL.connect(this.msLMix); // L = Mid + Side
|
||||||
|
|
||||||
|
this.midOutputNode.connect(this.msDecoderMidToR);
|
||||||
|
this.sideOutputNode.connect(this.msDecoderSideToR);
|
||||||
|
this.msDecoderMidToR.connect(this.msRMix);
|
||||||
|
this.msDecoderSideToR.connect(this.msRMix); // R = Mid - Side
|
||||||
|
|
||||||
|
this.msLMix.connect(this.msMerger, 0, 0);
|
||||||
|
this.msRMix.connect(this.msMerger, 0, 1);
|
||||||
|
this.msMerger.connect(this.msOutputNode);
|
||||||
|
|
||||||
|
connectTail(this.msOutputNode);
|
||||||
|
} else {
|
||||||
|
// === Normal stereo path ===
|
||||||
|
lastNode.connect(this.filters[0]);
|
||||||
|
for (let i = 0; i < this.filters.length - 1; i++) {
|
||||||
|
this.filters[i].connect(this.filters[i + 1]);
|
||||||
|
}
|
||||||
|
this.filters[this.filters.length - 1].connect(this.outputNode);
|
||||||
|
connectTail(this.outputNode);
|
||||||
}
|
}
|
||||||
this.filters[this.filters.length - 1].connect(this.outputNode);
|
|
||||||
connectTail(this.outputNode);
|
|
||||||
} else {
|
} else {
|
||||||
connectTail(lastNode);
|
connectTail(lastNode);
|
||||||
}
|
}
|
||||||
|
|
@ -602,6 +871,126 @@ class AudioContextManager {
|
||||||
return this.isInitialized && this.isMonoAudioEnabled;
|
return this.isInitialized && this.isMonoAudioEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Binaural DSP controls
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle binaural DSP on/off
|
||||||
|
*/
|
||||||
|
async toggleBinaural(enabled) {
|
||||||
|
this.isBinauralEnabled = enabled;
|
||||||
|
binauralDspSettings.setEnabled(enabled);
|
||||||
|
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
await this.binauralDsp.setEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isInitialized) {
|
||||||
|
this._connectGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.isBinauralEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if binaural DSP is active
|
||||||
|
*/
|
||||||
|
isBinauralActive() {
|
||||||
|
return this.isInitialized && this.isBinauralEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set crossfeed enabled state
|
||||||
|
*/
|
||||||
|
async setBinauralCrossfeedEnabled(enabled) {
|
||||||
|
binauralDspSettings.setCrossfeedEnabled(enabled);
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
await this.binauralDsp.setCrossfeedEnabled(enabled);
|
||||||
|
if (this.isInitialized) this._connectGraph();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set crossfeed level
|
||||||
|
* @param {'low'|'medium'|'high'} level
|
||||||
|
*/
|
||||||
|
setBinauralCrossfeedLevel(level) {
|
||||||
|
binauralDspSettings.setCrossfeedLevel(level);
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
this.binauralDsp.setCrossfeedLevel(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set HRTF preset
|
||||||
|
* @param {'intimate'|'studio'|'wide'} preset
|
||||||
|
*/
|
||||||
|
async setBinauralHrtfPreset(preset) {
|
||||||
|
binauralDspSettings.setHrtfPreset(preset);
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
await this.binauralDsp.setHrtfPreset(preset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stereo widening enabled state
|
||||||
|
*/
|
||||||
|
async setBinauralWideningEnabled(enabled) {
|
||||||
|
binauralDspSettings.setWideningEnabled(enabled);
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
await this.binauralDsp.setWideningEnabled(enabled);
|
||||||
|
if (this.isInitialized) this._connectGraph();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stereo widening amount
|
||||||
|
* @param {number} amount - 0.0 to 2.0 (1.0 = neutral)
|
||||||
|
*/
|
||||||
|
setBinauralWidening(amount) {
|
||||||
|
binauralDspSettings.setWideningAmount(amount);
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
this.binauralDsp.setWideningAmount(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify binaural DSP of channel count change (for multichannel detection)
|
||||||
|
* @param {number} channelCount
|
||||||
|
*/
|
||||||
|
async notifyBinauralChannelCount(channelCount) {
|
||||||
|
if (this.binauralDsp && this.isBinauralEnabled) {
|
||||||
|
await this.binauralDsp.detectAndConfigure(channelCount);
|
||||||
|
if (this.isInitialized) this._connectGraph();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get binaural DSP status
|
||||||
|
*/
|
||||||
|
getBinauralStatus() {
|
||||||
|
return this.binauralDsp ? this.binauralDsp.getStatus() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load binaural settings from storage and apply to DSP
|
||||||
|
*/
|
||||||
|
async _loadBinauralSettings() {
|
||||||
|
if (!this.binauralDsp) return;
|
||||||
|
|
||||||
|
this.isBinauralEnabled = binauralDspSettings.isEnabled();
|
||||||
|
this.binauralDsp.crossfeedEnabled = binauralDspSettings.getCrossfeedEnabled();
|
||||||
|
this.binauralDsp.crossfeedLevel = binauralDspSettings.getCrossfeedLevel();
|
||||||
|
this.binauralDsp.hrtfPreset = binauralDspSettings.getHrtfPreset();
|
||||||
|
this.binauralDsp.wideningEnabled = binauralDspSettings.getWideningEnabled();
|
||||||
|
this.binauralDsp.wideningAmount = binauralDspSettings.getWideningAmount();
|
||||||
|
|
||||||
|
if (this.isBinauralEnabled) {
|
||||||
|
await this.binauralDsp.setEnabled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current gain range
|
* Get current gain range
|
||||||
*/
|
*/
|
||||||
|
|
@ -694,7 +1083,7 @@ class AudioContextManager {
|
||||||
// Ensure gains array matches current band count
|
// Ensure gains array matches current band count
|
||||||
let adjustedGains = gains;
|
let adjustedGains = gains;
|
||||||
if (gains.length !== this.bandCount) {
|
if (gains.length !== this.bandCount) {
|
||||||
adjustedGains = equalizerSettings._interpolateGains(gains, this.bandCount);
|
adjustedGains = equalizerSettings.interpolateGains(gains, this.bandCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = this.audioContext?.currentTime || 0;
|
const now = this.audioContext?.currentTime || 0;
|
||||||
|
|
@ -757,8 +1146,11 @@ class AudioContextManager {
|
||||||
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
||||||
this.currentTypes = equalizerSettings.getBandTypes(this.bandCount);
|
this.currentTypes = equalizerSettings.getBandTypes(this.bandCount);
|
||||||
this.currentQs = equalizerSettings.getBandQs(this.bandCount);
|
this.currentQs = equalizerSettings.getBandQs(this.bandCount);
|
||||||
|
this.currentChannels = equalizerSettings.getBandChannels(this.bandCount);
|
||||||
|
this.msEnabled = this.currentChannels.some((ch) => ch === 'mid' || ch === 'side');
|
||||||
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
|
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
|
||||||
this.preamp = equalizerSettings.getPreamp();
|
this.preamp = equalizerSettings.getPreamp();
|
||||||
|
this.isBinauralEnabled = binauralDspSettings.isEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -796,6 +1188,7 @@ class AudioContextManager {
|
||||||
if (!bands || bands.length === 0) return '';
|
if (!bands || bands.length === 0) return '';
|
||||||
|
|
||||||
const enabledBands = bands.filter((b) => b.enabled);
|
const enabledBands = bands.filter((b) => b.enabled);
|
||||||
|
if (enabledBands.length === 0) return '';
|
||||||
const count = Math.max(equalizerSettings.MIN_BANDS, Math.min(equalizerSettings.MAX_BANDS, enabledBands.length));
|
const count = Math.max(equalizerSettings.MIN_BANDS, Math.min(equalizerSettings.MAX_BANDS, enabledBands.length));
|
||||||
|
|
||||||
// Calculate preamp: negative of cumulative peak gain across all bands to prevent clipping
|
// Calculate preamp: negative of cumulative peak gain across all bands to prevent clipping
|
||||||
|
|
@ -820,39 +1213,77 @@ class AudioContextManager {
|
||||||
// Sort bands by frequency so index order is deterministic
|
// Sort bands by frequency so index order is deterministic
|
||||||
const sortedBands = [...enabledBands].sort((a, b) => a.freq - b.freq);
|
const sortedBands = [...enabledBands].sort((a, b) => a.freq - b.freq);
|
||||||
|
|
||||||
// Build normalized band descriptor arrays
|
// Build normalized band descriptor arrays, pad if fewer enabled bands than minimum
|
||||||
const newFrequencies = sortedBands
|
const maxFreq = (this.audioContext?.sampleRate ?? 48000) / 2 - 1;
|
||||||
.slice(0, count)
|
const slicedBands = sortedBands.slice(0, count);
|
||||||
.map((b) => Math.round(Math.min(b.freq, (this.audioContext?.sampleRate ?? 48000) / 2 - 1)));
|
const newFrequencies = slicedBands.map((b) => Math.round(Math.min(b.freq, maxFreq)));
|
||||||
const newTypes = sortedBands.slice(0, count).map((b) => b.type || 'peaking');
|
const newTypes = slicedBands.map((b) => b.type || 'peaking');
|
||||||
const newQs = sortedBands.slice(0, count).map((b) => b.q);
|
const newQs = slicedBands.map((b) => b.q);
|
||||||
const newGains = sortedBands.slice(0, count).map((b) => this._clampGain(b.gain));
|
const newGains = slicedBands.map((b) => this._clampGain(b.gain));
|
||||||
|
const newChannels = slicedBands.map((b) => b.channel || 'stereo');
|
||||||
|
while (newFrequencies.length < count) {
|
||||||
|
const lastFreq = newFrequencies[newFrequencies.length - 1] || 1000;
|
||||||
|
newFrequencies.push(Math.round(Math.min(lastFreq * 2, maxFreq)));
|
||||||
|
newTypes.push('peaking');
|
||||||
|
newQs.push(1.0);
|
||||||
|
newGains.push(0);
|
||||||
|
newChannels.push('stereo');
|
||||||
|
}
|
||||||
|
|
||||||
// Update band count via class setter to trigger equalizer-band-count-changed event
|
// Update band count via class setter to trigger equalizer-band-count-changed event
|
||||||
if (count !== this.bandCount) {
|
if (count !== this.bandCount) {
|
||||||
this.setBandCount(count);
|
this.setBandCount(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override frequencies, types, and Qs with band-specific values
|
// Override frequencies, types, Qs, and channels with band-specific values
|
||||||
this.frequencies = newFrequencies;
|
this.frequencies = newFrequencies;
|
||||||
this.currentTypes = newTypes;
|
this.currentTypes = newTypes;
|
||||||
this.currentQs = newQs;
|
this.currentQs = newQs;
|
||||||
this.currentGains = newGains;
|
this.currentGains = newGains;
|
||||||
|
this.currentChannels = newChannels;
|
||||||
|
|
||||||
|
// Determine if M/S processing is needed
|
||||||
|
const needsMS = newChannels.some((ch) => ch === 'mid' || ch === 'side');
|
||||||
|
const msChanged = needsMS !== this.msEnabled;
|
||||||
|
this.msEnabled = needsMS;
|
||||||
|
|
||||||
if (this.isInitialized && this.audioContext) {
|
if (this.isInitialized && this.audioContext) {
|
||||||
// If filter count matches, update params in-place (no graph rebuild)
|
const needsRebuild =
|
||||||
if (this.filters.length === count) {
|
msChanged || this.filters.length !== count || (needsMS && this.midFilters.length !== count);
|
||||||
const now = this.audioContext.currentTime;
|
|
||||||
this.filters.forEach((filter, i) => {
|
if (needsRebuild) {
|
||||||
filter.type = newTypes[i] || 'peaking';
|
// M/S state changed or band count changed - full rebuild
|
||||||
filter.frequency.setTargetAtTime(newFrequencies[i], now, 0.005);
|
this._destroyMSFilters();
|
||||||
filter.gain.setTargetAtTime(newGains[i], now, 0.005);
|
|
||||||
filter.Q.setTargetAtTime(newQs[i] > 0 ? newQs[i] : this._calculateQ(i), now, 0.005);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Band count changed — must rebuild
|
|
||||||
this._destroyEQ();
|
this._destroyEQ();
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
|
if (needsMS) {
|
||||||
|
this._createMSFilters();
|
||||||
|
}
|
||||||
|
this._connectGraph();
|
||||||
|
} else if (needsMS) {
|
||||||
|
// M/S active - update both parallel chains in-place
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
// Update main filters (not connected in M/S mode, kept in sync for stereo fallback)
|
||||||
|
this._updateFilterChain(this.filters, newFrequencies, newTypes, newQs, newGains, now);
|
||||||
|
|
||||||
|
// Update mid filters (gain = 0 for side-only bands)
|
||||||
|
const midGains = newGains.map((g, i) => (newChannels[i] === 'side' ? 0 : g));
|
||||||
|
this._updateFilterChain(this.midFilters, newFrequencies, newTypes, newQs, midGains, now);
|
||||||
|
|
||||||
|
// Update side filters (gain = 0 for mid-only bands)
|
||||||
|
const sideGains = newGains.map((g, i) => (newChannels[i] === 'mid' ? 0 : g));
|
||||||
|
this._updateFilterChain(this.sideFilters, newFrequencies, newTypes, newQs, sideGains, now);
|
||||||
|
} else if (this.filters.length === count) {
|
||||||
|
// Normal stereo - update in-place
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
this._updateFilterChain(this.filters, newFrequencies, newTypes, newQs, newGains, now);
|
||||||
|
} else {
|
||||||
|
// Band count changed - must rebuild
|
||||||
|
this._destroyMSFilters();
|
||||||
|
this._destroyEQ();
|
||||||
|
this._createEQ();
|
||||||
|
if (this.msEnabled) this._createMSFilters();
|
||||||
this._connectGraph();
|
this._connectGraph();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -867,12 +1298,13 @@ class AudioContextManager {
|
||||||
equalizerSettings.setGains(this.currentGains);
|
equalizerSettings.setGains(this.currentGains);
|
||||||
equalizerSettings.setBandTypes(this.currentTypes);
|
equalizerSettings.setBandTypes(this.currentTypes);
|
||||||
equalizerSettings.setBandQs(this.currentQs);
|
equalizerSettings.setBandQs(this.currentQs);
|
||||||
|
equalizerSettings.setBandChannels(this.currentChannels);
|
||||||
|
|
||||||
// Generate export text using the actual applied preamp value
|
// Generate export text using the actual applied preamp value
|
||||||
const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`];
|
const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`];
|
||||||
sortedBands.forEach((band, index) => {
|
sortedBands.forEach((band, index) => {
|
||||||
if (index >= count) return;
|
if (index >= count) return;
|
||||||
const filterType = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK';
|
const filterType = band.type === 'lowshelf' ? 'LSC' : band.type === 'highshelf' ? 'HSC' : 'PK';
|
||||||
lines.push(
|
lines.push(
|
||||||
`Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
|
`Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
|
||||||
);
|
);
|
||||||
|
|
@ -893,7 +1325,7 @@ class AudioContextManager {
|
||||||
this.frequencies.forEach((freq, index) => {
|
this.frequencies.forEach((freq, index) => {
|
||||||
const gain = this.currentGains[index] || 0;
|
const gain = this.currentGains[index] || 0;
|
||||||
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
||||||
const filterType = type === 'lowshelf' ? 'LS' : type === 'highshelf' ? 'HS' : 'PK';
|
const filterType = type === 'lowshelf' ? 'LSC' : type === 'highshelf' ? 'HSC' : 'PK';
|
||||||
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
||||||
const filterNum = index + 1;
|
const filterNum = index + 1;
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|
@ -928,13 +1360,13 @@ class AudioContextManager {
|
||||||
|
|
||||||
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
|
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
|
||||||
const filterMatch = line.match(
|
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
|
/^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) {
|
if (filterMatch) {
|
||||||
const type = filterMatch[1].toUpperCase();
|
const type = filterMatch[1].toUpperCase();
|
||||||
const freq = parseInt(filterMatch[2], 10);
|
const freq = parseInt(filterMatch[2], 10);
|
||||||
const gain = parseFloat(filterMatch[3]);
|
const gain = parseFloat(filterMatch[3]);
|
||||||
const q = parseFloat(filterMatch[4]);
|
const q = filterMatch[4] ? parseFloat(filterMatch[4]) : Math.SQRT1_2;
|
||||||
filters.push({ type, freq, gain, q });
|
filters.push({ type, freq, gain, q });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -967,22 +1399,49 @@ class AudioContextManager {
|
||||||
HSC: 'highshelf',
|
HSC: 'highshelf',
|
||||||
HSF: 'highshelf',
|
HSF: 'highshelf',
|
||||||
};
|
};
|
||||||
this.frequencies = sliced.map((f) => f.freq);
|
|
||||||
this.currentTypes = sliced.map((f) => typeMap[f.type] || 'peaking');
|
// Pad arrays to bandCount if import has fewer filters than minimum
|
||||||
this.currentQs = sliced.map((f) => f.q);
|
const padCount = this.bandCount - sliced.length;
|
||||||
this.currentGains = sliced.map((f) => this._clampGain(f.gain));
|
const freqs = sliced.map((f) => f.freq);
|
||||||
|
const types = sliced.map((f) => typeMap[f.type] || 'peaking');
|
||||||
|
const qs = sliced.map((f) => f.q);
|
||||||
|
const gains = sliced.map((f) => this._clampGain(f.gain));
|
||||||
|
if (padCount > 0) {
|
||||||
|
const lastFreq = freqs[freqs.length - 1] || 1000;
|
||||||
|
const maxFreq = (this.audioContext?.sampleRate ?? 48000) / 2 - 1;
|
||||||
|
for (let p = 0; p < padCount; p++) {
|
||||||
|
const padFreq = Math.min(lastFreq * Math.pow(2, p + 1), maxFreq);
|
||||||
|
freqs.push(Math.round(padFreq));
|
||||||
|
types.push('peaking');
|
||||||
|
qs.push(this._calculateQ(freqs.length - 1));
|
||||||
|
gains.push(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.frequencies = freqs;
|
||||||
|
this.currentTypes = types;
|
||||||
|
this.currentQs = qs;
|
||||||
|
this.currentGains = gains;
|
||||||
|
|
||||||
|
// Reset M/S channel assignments - imported config has no channel info
|
||||||
|
this.currentChannels = new Array(this.bandCount).fill('stereo');
|
||||||
|
this.msEnabled = false;
|
||||||
|
|
||||||
// Rebuild EQ chain to apply new frequencies, types, and Qs
|
// Rebuild EQ chain to apply new frequencies, types, and Qs
|
||||||
if (this.isInitialized && this.audioContext) {
|
if (this.isInitialized && this.audioContext) {
|
||||||
|
this._destroyMSFilters();
|
||||||
this._destroyEQ();
|
this._destroyEQ();
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
|
if (this.msEnabled) this._createMSFilters();
|
||||||
this._connectGraph();
|
this._connectGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist all band settings
|
// Persist all band settings including custom frequencies
|
||||||
|
equalizerSettings.setCustomFrequencies(this.frequencies);
|
||||||
equalizerSettings.setGains(this.currentGains);
|
equalizerSettings.setGains(this.currentGains);
|
||||||
equalizerSettings.setBandTypes(this.currentTypes);
|
equalizerSettings.setBandTypes(this.currentTypes);
|
||||||
equalizerSettings.setBandQs(this.currentQs);
|
equalizerSettings.setBandQs(this.currentQs);
|
||||||
|
equalizerSettings.setBandChannels(this.currentChannels);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -1004,11 +1463,12 @@ class AudioContextManager {
|
||||||
this.geqOutputNode = this.audioContext.createGain();
|
this.geqOutputNode = this.audioContext.createGain();
|
||||||
this.geqOutputNode.gain.value = 1;
|
this.geqOutputNode.gain.value = 1;
|
||||||
|
|
||||||
|
const geqQ = 2.5 * Math.sqrt(16 / this.geqBandCount);
|
||||||
this.geqFilters = this.geqFrequencies.map((freq, i) => {
|
this.geqFilters = this.geqFrequencies.map((freq, i) => {
|
||||||
const filter = this.audioContext.createBiquadFilter();
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
filter.type = 'peaking';
|
filter.type = 'peaking';
|
||||||
filter.frequency.value = freq;
|
filter.frequency.value = freq;
|
||||||
filter.Q.value = 2.5; // constant Q for 16-band
|
filter.Q.value = geqQ;
|
||||||
filter.gain.value = this.geqGains[i] || 0;
|
filter.gain.value = this.geqGains[i] || 0;
|
||||||
return filter;
|
return filter;
|
||||||
});
|
});
|
||||||
|
|
@ -1050,7 +1510,7 @@ class AudioContextManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setGraphicEqBandGain(bandIndex, gainDb) {
|
setGraphicEqBandGain(bandIndex, gainDb) {
|
||||||
if (bandIndex < 0 || bandIndex >= 16) return;
|
if (bandIndex < 0 || bandIndex >= this.geqBandCount) return;
|
||||||
this.geqGains[bandIndex] = Math.max(-30, Math.min(30, gainDb));
|
this.geqGains[bandIndex] = Math.max(-30, Math.min(30, gainDb));
|
||||||
if (this.geqFilters[bandIndex] && this.audioContext) {
|
if (this.geqFilters[bandIndex] && this.audioContext) {
|
||||||
const now = this.audioContext.currentTime;
|
const now = this.audioContext.currentTime;
|
||||||
|
|
@ -1063,7 +1523,7 @@ class AudioContextManager {
|
||||||
if (!Array.isArray(gains)) return;
|
if (!Array.isArray(gains)) return;
|
||||||
const now = this.audioContext?.currentTime || 0;
|
const now = this.audioContext?.currentTime || 0;
|
||||||
gains.forEach((g, i) => {
|
gains.forEach((g, i) => {
|
||||||
if (i >= 16) return;
|
if (i >= this.geqBandCount) return;
|
||||||
this.geqGains[i] = Math.max(-30, Math.min(30, g));
|
this.geqGains[i] = Math.max(-30, Math.min(30, g));
|
||||||
if (this.geqFilters[i]) {
|
if (this.geqFilters[i]) {
|
||||||
this.geqFilters[i].gain.setTargetAtTime(this.geqGains[i], now, 0.01);
|
this.geqFilters[i].gain.setTargetAtTime(this.geqGains[i], now, 0.01);
|
||||||
|
|
@ -1072,6 +1532,51 @@ class AudioContextManager {
|
||||||
equalizerSettings.setGraphicEqGains([...this.geqGains]);
|
equalizerSettings.setGraphicEqGains([...this.geqGains]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setGraphicEqBandCount(count) {
|
||||||
|
const newCount = Math.max(3, Math.min(32, parseInt(count, 10) || 16));
|
||||||
|
if (newCount === this.geqBandCount) return;
|
||||||
|
|
||||||
|
const oldGains = this.geqGains;
|
||||||
|
this.geqBandCount = newCount;
|
||||||
|
this.geqFrequencies = generateFrequencies(newCount, this.geqFreqRange.min, this.geqFreqRange.max);
|
||||||
|
this.geqGains = equalizerSettings.interpolateGains(oldGains, newCount);
|
||||||
|
|
||||||
|
equalizerSettings.setGraphicEqBandCount(newCount);
|
||||||
|
equalizerSettings.setGraphicEqGains(this.geqGains);
|
||||||
|
|
||||||
|
if (this.isInitialized && this.audioContext) {
|
||||||
|
this._destroyGraphicEQ();
|
||||||
|
this._createGraphicEQ();
|
||||||
|
this._connectGraph();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setGraphicEqFreqRange(minFreq, maxFreq) {
|
||||||
|
const newMin = Math.max(10, Math.min(96000, parseInt(minFreq, 10) || 25));
|
||||||
|
const newMax = Math.max(10, Math.min(96000, parseInt(maxFreq, 10) || 20000));
|
||||||
|
if (newMin >= newMax) return;
|
||||||
|
if (newMin === this.geqFreqRange.min && newMax === this.geqFreqRange.max) return;
|
||||||
|
|
||||||
|
this.geqFreqRange = { min: newMin, max: newMax };
|
||||||
|
this.geqFrequencies = generateFrequencies(this.geqBandCount, newMin, newMax);
|
||||||
|
|
||||||
|
equalizerSettings.setGraphicEqFreqRange(newMin, newMax);
|
||||||
|
|
||||||
|
if (this.isInitialized && this.audioContext) {
|
||||||
|
this._destroyGraphicEQ();
|
||||||
|
this._createGraphicEQ();
|
||||||
|
this._connectGraph();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getGraphicEqFrequencies() {
|
||||||
|
return this.geqFrequencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGraphicEqBandCount() {
|
||||||
|
return this.geqBandCount;
|
||||||
|
}
|
||||||
|
|
||||||
setGraphicEqPreamp(db) {
|
setGraphicEqPreamp(db) {
|
||||||
this.geqPreamp = Math.max(-20, Math.min(20, parseFloat(db) || 0));
|
this.geqPreamp = Math.max(-20, Math.min(20, parseFloat(db) || 0));
|
||||||
if (this.geqPreampNode && this.audioContext) {
|
if (this.geqPreampNode && this.audioContext) {
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,7 @@ function calculateBiquadResponse(f, band, sr = DEFAULT_SR) {
|
||||||
const w = (2 * PI * band.freq) / sr;
|
const w = (2 * PI * band.freq) / sr;
|
||||||
const p = (2 * PI * f) / sr;
|
const p = (2 * PI * f) / sr;
|
||||||
const t = band.type[0];
|
const t = band.type[0];
|
||||||
// WebAudio ignores Q for shelf filters; use 1/√2 (slope = 1) to match
|
const effectiveQ = band.q;
|
||||||
const effectiveQ = t === 'l' || t === 'h' ? Math.SQRT1_2 : band.q;
|
|
||||||
const s = Math.sin(w) / (2 * effectiveQ);
|
const s = Math.sin(w) / (2 * effectiveQ);
|
||||||
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
|
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
|
||||||
const c = Math.cos(w);
|
const c = Math.cos(w);
|
||||||
|
|
@ -244,7 +243,7 @@ function runAutoEqAlgorithm(
|
||||||
if (peakFreq > 5000 && q > 3.0) q = 3.0;
|
if (peakFreq > 5000 && q > 3.0) q = 3.0;
|
||||||
if (gain > 0 && q > 2.0) q = 2.0;
|
if (gain > 0 && q > 2.0) q = 2.0;
|
||||||
|
|
||||||
const newBand = { id: i, type: 'peaking', freq: peakFreq, gain, q, enabled: true };
|
const newBand = { id: i, type: 'peaking', freq: peakFreq, gain, q, enabled: true, channel: 'stereo' };
|
||||||
|
|
||||||
// Check cumulative gain at the peak frequency across all existing bands + this one
|
// Check cumulative gain at the peak frequency across all existing bands + this one
|
||||||
let cumulativeGain = gain;
|
let cumulativeGain = gain;
|
||||||
|
|
|
||||||
746
js/binaural-dsp.js
Normal file
746
js/binaural-dsp.js
Normal file
|
|
@ -0,0 +1,746 @@
|
||||||
|
// js/binaural-dsp.js
|
||||||
|
// Binaural DSP engine: multichannel HRTF rendering, crossfeed, and stereo widening.
|
||||||
|
// Placed before EQ in the audio chain.
|
||||||
|
|
||||||
|
import { generateHRTFSet, HRTF_PRESETS, CHANNEL_ANGLES_51 } from './hrtf-generator.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crossfeed presets (Bauer bs2b-style)
|
||||||
|
*/
|
||||||
|
const CROSSFEED_PRESETS = {
|
||||||
|
low: { cutoff: 500, crossGainDb: -6, delayMs: 0.2 },
|
||||||
|
medium: { cutoff: 700, crossGainDb: -4.5, delayMs: 0.3 },
|
||||||
|
high: { cutoff: 1000, crossGainDb: -3, delayMs: 0.4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export class BinauralDSP {
|
||||||
|
/**
|
||||||
|
* @param {AudioContext} audioContext
|
||||||
|
*/
|
||||||
|
constructor(audioContext) {
|
||||||
|
this.ctx = audioContext;
|
||||||
|
this.enabled = false;
|
||||||
|
this.mode = 'stereo'; // 'stereo' | 'multichannel'
|
||||||
|
this.channelCount = 2;
|
||||||
|
|
||||||
|
// Sub-feature states
|
||||||
|
this.crossfeedEnabled = true;
|
||||||
|
this.crossfeedLevel = 'medium';
|
||||||
|
this.hrtfPreset = 'studio';
|
||||||
|
this.wideningEnabled = true;
|
||||||
|
this.wideningAmount = 1.0;
|
||||||
|
|
||||||
|
// Graph nodes (created lazily)
|
||||||
|
this.inputNode = this.ctx.createGain();
|
||||||
|
this.outputNode = this.ctx.createGain();
|
||||||
|
this.bypassNode = this.ctx.createGain(); // direct path when disabled
|
||||||
|
|
||||||
|
// Crossfeed nodes
|
||||||
|
this._cfSplitter = null;
|
||||||
|
this._cfMerger = null;
|
||||||
|
this._cfDirectL = null;
|
||||||
|
this._cfDirectR = null;
|
||||||
|
this._cfCrossLR = null; // L → R cross path
|
||||||
|
this._cfCrossRL = null; // R → L cross path
|
||||||
|
this._cfFilterLR = null;
|
||||||
|
this._cfFilterRL = null;
|
||||||
|
this._cfDelayLR = null;
|
||||||
|
this._cfDelayRL = null;
|
||||||
|
this._cfOutputNode = null;
|
||||||
|
|
||||||
|
// Multichannel HRTF nodes
|
||||||
|
this._mcSplitter = null;
|
||||||
|
this._mcMerger = null;
|
||||||
|
this._mcConvolversL = []; // per-channel left-ear convolvers
|
||||||
|
this._mcConvolversR = []; // per-channel right-ear convolvers
|
||||||
|
this._mcLfeGain = null;
|
||||||
|
this._mcOutputNode = null;
|
||||||
|
this._hrtfBuffers = null; // Map from generateHRTFSet
|
||||||
|
|
||||||
|
// Stereo widener nodes
|
||||||
|
this._wSplitter = null;
|
||||||
|
this._wMerger = null;
|
||||||
|
this._wMidL = null;
|
||||||
|
this._wMidR = null;
|
||||||
|
this._wSideL = null;
|
||||||
|
this._wSideR = null;
|
||||||
|
this._wMidGain = null;
|
||||||
|
this._wSideGain = null;
|
||||||
|
this._wMidMix = null;
|
||||||
|
this._wSideMix = null;
|
||||||
|
this._wDecoderMidToL = null;
|
||||||
|
this._wDecoderSideToL = null;
|
||||||
|
this._wDecoderMidToR = null;
|
||||||
|
this._wDecoderSideToR = null;
|
||||||
|
this._wLMix = null;
|
||||||
|
this._wRMix = null;
|
||||||
|
this._wOutputMerger = null;
|
||||||
|
this._wOutputNode = null;
|
||||||
|
|
||||||
|
// Initialize the internal bypass connection
|
||||||
|
this._connectInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the input/output nodes for graph insertion.
|
||||||
|
*/
|
||||||
|
getNodes() {
|
||||||
|
return { input: this.inputNode, output: this.outputNode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect internal graph (public API for external callers).
|
||||||
|
*/
|
||||||
|
reconnect() {
|
||||||
|
this._connectInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect internal graph based on current state.
|
||||||
|
*/
|
||||||
|
_connectInternal() {
|
||||||
|
this._disconnectAll();
|
||||||
|
|
||||||
|
if (!this.enabled) {
|
||||||
|
// Bypass: input → output directly
|
||||||
|
this.inputNode.connect(this.outputNode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mode === 'multichannel' && this._mcOutputNode) {
|
||||||
|
this._connectMultichannelPath();
|
||||||
|
} else {
|
||||||
|
this._connectStereoPath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect the stereo processing path: crossfeed → widener → output
|
||||||
|
*/
|
||||||
|
_connectStereoPath() {
|
||||||
|
let lastNode = this.inputNode;
|
||||||
|
|
||||||
|
if (this.crossfeedEnabled && this._cfOutputNode) {
|
||||||
|
lastNode.connect(this._cfSplitter);
|
||||||
|
|
||||||
|
// Direct paths
|
||||||
|
this._cfSplitter.connect(this._cfDirectL, 0);
|
||||||
|
this._cfSplitter.connect(this._cfDirectR, 1);
|
||||||
|
|
||||||
|
// Cross paths: L → R
|
||||||
|
this._cfSplitter.connect(this._cfFilterLR, 0);
|
||||||
|
this._cfFilterLR.connect(this._cfDelayLR);
|
||||||
|
this._cfDelayLR.connect(this._cfCrossLR);
|
||||||
|
|
||||||
|
// Cross paths: R → L
|
||||||
|
this._cfSplitter.connect(this._cfFilterRL, 1);
|
||||||
|
this._cfFilterRL.connect(this._cfDelayRL);
|
||||||
|
this._cfDelayRL.connect(this._cfCrossRL);
|
||||||
|
|
||||||
|
// Merge: L channel = directL + crossRL, R channel = directR + crossLR
|
||||||
|
this._cfDirectL.connect(this._cfMerger, 0, 0);
|
||||||
|
this._cfCrossRL.connect(this._cfMerger, 0, 0);
|
||||||
|
this._cfDirectR.connect(this._cfMerger, 0, 1);
|
||||||
|
this._cfCrossLR.connect(this._cfMerger, 0, 1);
|
||||||
|
|
||||||
|
this._cfMerger.connect(this._cfOutputNode);
|
||||||
|
lastNode = this._cfOutputNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.wideningEnabled && this._wOutputNode) {
|
||||||
|
this._connectWidener(lastNode);
|
||||||
|
lastNode = this._wOutputNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastNode.connect(this.outputNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect the multichannel HRTF rendering path: splitter → per-ch HRTF → merger → widener → output
|
||||||
|
*/
|
||||||
|
_connectMultichannelPath() {
|
||||||
|
// Input must pass multichannel through
|
||||||
|
this.inputNode.channelCount = this.channelCount;
|
||||||
|
this.inputNode.channelCountMode = 'max';
|
||||||
|
this.inputNode.channelInterpretation = 'discrete';
|
||||||
|
|
||||||
|
this.inputNode.connect(this._mcSplitter);
|
||||||
|
|
||||||
|
const numChannels = Math.min(this.channelCount, CHANNEL_ANGLES_51.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < numChannels; i++) {
|
||||||
|
const chInfo = CHANNEL_ANGLES_51[i];
|
||||||
|
|
||||||
|
if (chInfo.isLFE) {
|
||||||
|
// LFE: direct mix to both ears at reduced level
|
||||||
|
this._mcSplitter.connect(this._mcLfeGain, i);
|
||||||
|
this._mcLfeGain.connect(this._mcMerger, 0, 0);
|
||||||
|
this._mcLfeGain.connect(this._mcMerger, 0, 1);
|
||||||
|
} else {
|
||||||
|
// HRTF convolution: split to left and right ear convolvers
|
||||||
|
this._mcSplitter.connect(this._mcConvolversL[i], i);
|
||||||
|
this._mcSplitter.connect(this._mcConvolversR[i], i);
|
||||||
|
this._mcConvolversL[i].connect(this._mcMerger, 0, 0); // left ear
|
||||||
|
this._mcConvolversR[i].connect(this._mcMerger, 0, 1); // right ear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._mcMerger.connect(this._mcOutputNode);
|
||||||
|
let lastNode = this._mcOutputNode;
|
||||||
|
|
||||||
|
if (this.wideningEnabled && this._wOutputNode) {
|
||||||
|
this._connectWidener(lastNode);
|
||||||
|
lastNode = this._wOutputNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastNode.connect(this.outputNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect the stereo widener from a source node.
|
||||||
|
*/
|
||||||
|
_connectWidener(sourceNode) {
|
||||||
|
sourceNode.connect(this._wSplitter);
|
||||||
|
|
||||||
|
// Encode L/R → M/S
|
||||||
|
this._wSplitter.connect(this._wMidL, 0);
|
||||||
|
this._wSplitter.connect(this._wMidR, 1);
|
||||||
|
this._wMidL.connect(this._wMidMix);
|
||||||
|
this._wMidR.connect(this._wMidMix);
|
||||||
|
|
||||||
|
this._wSplitter.connect(this._wSideL, 0);
|
||||||
|
this._wSplitter.connect(this._wSideR, 1);
|
||||||
|
this._wSideL.connect(this._wSideMix);
|
||||||
|
this._wSideR.connect(this._wSideMix);
|
||||||
|
|
||||||
|
// Apply width gains
|
||||||
|
this._wMidMix.connect(this._wMidGain);
|
||||||
|
this._wSideMix.connect(this._wSideGain);
|
||||||
|
|
||||||
|
// Decode M/S → L/R
|
||||||
|
this._wMidGain.connect(this._wDecoderMidToL);
|
||||||
|
this._wSideGain.connect(this._wDecoderSideToL);
|
||||||
|
this._wDecoderMidToL.connect(this._wLMix);
|
||||||
|
this._wDecoderSideToL.connect(this._wLMix);
|
||||||
|
|
||||||
|
this._wMidGain.connect(this._wDecoderMidToR);
|
||||||
|
this._wSideGain.connect(this._wDecoderSideToR);
|
||||||
|
this._wDecoderMidToR.connect(this._wRMix);
|
||||||
|
this._wDecoderSideToR.connect(this._wRMix);
|
||||||
|
|
||||||
|
// Merge L/R back to stereo
|
||||||
|
this._wLMix.connect(this._wOutputMerger, 0, 0);
|
||||||
|
this._wRMix.connect(this._wOutputMerger, 0, 1);
|
||||||
|
this._wOutputMerger.connect(this._wOutputNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect all internal nodes safely.
|
||||||
|
*/
|
||||||
|
_disconnectAll() {
|
||||||
|
const sd = (node) => {
|
||||||
|
try {
|
||||||
|
node?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sd(this.inputNode);
|
||||||
|
sd(this.bypassNode);
|
||||||
|
|
||||||
|
// Crossfeed
|
||||||
|
sd(this._cfSplitter);
|
||||||
|
sd(this._cfMerger);
|
||||||
|
sd(this._cfDirectL);
|
||||||
|
sd(this._cfDirectR);
|
||||||
|
sd(this._cfCrossLR);
|
||||||
|
sd(this._cfCrossRL);
|
||||||
|
sd(this._cfFilterLR);
|
||||||
|
sd(this._cfFilterRL);
|
||||||
|
sd(this._cfDelayLR);
|
||||||
|
sd(this._cfDelayRL);
|
||||||
|
sd(this._cfOutputNode);
|
||||||
|
|
||||||
|
// Multichannel
|
||||||
|
sd(this._mcSplitter);
|
||||||
|
sd(this._mcMerger);
|
||||||
|
sd(this._mcLfeGain);
|
||||||
|
this._mcConvolversL.forEach(sd);
|
||||||
|
this._mcConvolversR.forEach(sd);
|
||||||
|
sd(this._mcOutputNode);
|
||||||
|
|
||||||
|
// Widener
|
||||||
|
sd(this._wSplitter);
|
||||||
|
sd(this._wMerger);
|
||||||
|
sd(this._wMidL);
|
||||||
|
sd(this._wMidR);
|
||||||
|
sd(this._wSideL);
|
||||||
|
sd(this._wSideR);
|
||||||
|
sd(this._wMidGain);
|
||||||
|
sd(this._wSideGain);
|
||||||
|
sd(this._wMidMix);
|
||||||
|
sd(this._wSideMix);
|
||||||
|
sd(this._wDecoderMidToL);
|
||||||
|
sd(this._wDecoderSideToL);
|
||||||
|
sd(this._wDecoderMidToR);
|
||||||
|
sd(this._wDecoderSideToR);
|
||||||
|
sd(this._wLMix);
|
||||||
|
sd(this._wRMix);
|
||||||
|
sd(this._wOutputMerger);
|
||||||
|
sd(this._wOutputNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Crossfeed creation
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
_createCrossfeedNodes() {
|
||||||
|
const preset = CROSSFEED_PRESETS[this.crossfeedLevel] || CROSSFEED_PRESETS.medium;
|
||||||
|
const crossGain = Math.pow(10, preset.crossGainDb / 20);
|
||||||
|
const directGain = 1.0 - crossGain * 0.5; // Slightly reduce direct to compensate
|
||||||
|
|
||||||
|
this._cfSplitter = this.ctx.createChannelSplitter(2);
|
||||||
|
this._cfMerger = this.ctx.createChannelMerger(2);
|
||||||
|
|
||||||
|
// Direct paths
|
||||||
|
this._cfDirectL = this.ctx.createGain();
|
||||||
|
this._cfDirectL.gain.value = directGain;
|
||||||
|
this._cfDirectL.channelCount = 1;
|
||||||
|
this._cfDirectL.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._cfDirectR = this.ctx.createGain();
|
||||||
|
this._cfDirectR.gain.value = directGain;
|
||||||
|
this._cfDirectR.channelCount = 1;
|
||||||
|
this._cfDirectR.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
// Cross paths: L → R
|
||||||
|
this._cfFilterLR = this.ctx.createBiquadFilter();
|
||||||
|
this._cfFilterLR.type = 'lowpass';
|
||||||
|
this._cfFilterLR.frequency.value = preset.cutoff;
|
||||||
|
this._cfFilterLR.Q.value = 0.707;
|
||||||
|
this._cfFilterLR.channelCount = 1;
|
||||||
|
this._cfFilterLR.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._cfDelayLR = this.ctx.createDelay(0.01);
|
||||||
|
this._cfDelayLR.delayTime.value = preset.delayMs / 1000;
|
||||||
|
|
||||||
|
this._cfCrossLR = this.ctx.createGain();
|
||||||
|
this._cfCrossLR.gain.value = crossGain;
|
||||||
|
this._cfCrossLR.channelCount = 1;
|
||||||
|
this._cfCrossLR.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
// Cross paths: R → L
|
||||||
|
this._cfFilterRL = this.ctx.createBiquadFilter();
|
||||||
|
this._cfFilterRL.type = 'lowpass';
|
||||||
|
this._cfFilterRL.frequency.value = preset.cutoff;
|
||||||
|
this._cfFilterRL.Q.value = 0.707;
|
||||||
|
this._cfFilterRL.channelCount = 1;
|
||||||
|
this._cfFilterRL.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._cfDelayRL = this.ctx.createDelay(0.01);
|
||||||
|
this._cfDelayRL.delayTime.value = preset.delayMs / 1000;
|
||||||
|
|
||||||
|
this._cfCrossRL = this.ctx.createGain();
|
||||||
|
this._cfCrossRL.gain.value = crossGain;
|
||||||
|
this._cfCrossRL.channelCount = 1;
|
||||||
|
this._cfCrossRL.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._cfOutputNode = this.ctx.createGain();
|
||||||
|
}
|
||||||
|
|
||||||
|
_destroyCrossfeedNodes() {
|
||||||
|
const nodes = [
|
||||||
|
this._cfSplitter,
|
||||||
|
this._cfMerger,
|
||||||
|
this._cfDirectL,
|
||||||
|
this._cfDirectR,
|
||||||
|
this._cfCrossLR,
|
||||||
|
this._cfCrossRL,
|
||||||
|
this._cfFilterLR,
|
||||||
|
this._cfFilterRL,
|
||||||
|
this._cfDelayLR,
|
||||||
|
this._cfDelayRL,
|
||||||
|
this._cfOutputNode,
|
||||||
|
];
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
try {
|
||||||
|
n?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._cfSplitter = null;
|
||||||
|
this._cfMerger = null;
|
||||||
|
this._cfDirectL = null;
|
||||||
|
this._cfDirectR = null;
|
||||||
|
this._cfCrossLR = null;
|
||||||
|
this._cfCrossRL = null;
|
||||||
|
this._cfFilterLR = null;
|
||||||
|
this._cfFilterRL = null;
|
||||||
|
this._cfDelayLR = null;
|
||||||
|
this._cfDelayRL = null;
|
||||||
|
this._cfOutputNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Multichannel HRTF creation
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async _createMultichannelNodes() {
|
||||||
|
const numChannels = Math.min(this.channelCount, CHANNEL_ANGLES_51.length);
|
||||||
|
|
||||||
|
this._mcSplitter = this.ctx.createChannelSplitter(numChannels);
|
||||||
|
this._mcMerger = this.ctx.createChannelMerger(2); // binaural output
|
||||||
|
|
||||||
|
this._mcLfeGain = this.ctx.createGain();
|
||||||
|
this._mcLfeGain.gain.value = 0.5;
|
||||||
|
this._mcLfeGain.channelCount = 1;
|
||||||
|
this._mcLfeGain.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
// Generate HRTF impulse responses
|
||||||
|
if (!this._hrtfBuffers || this._hrtfBuffers._preset !== this.hrtfPreset) {
|
||||||
|
this._hrtfBuffers = await generateHRTFSet(this.ctx, this.hrtfPreset);
|
||||||
|
this._hrtfBuffers._preset = this.hrtfPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._mcConvolversL = [];
|
||||||
|
this._mcConvolversR = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < numChannels; i++) {
|
||||||
|
const chInfo = CHANNEL_ANGLES_51[i];
|
||||||
|
if (chInfo.isLFE) {
|
||||||
|
// Placeholder - LFE uses gain node instead
|
||||||
|
this._mcConvolversL.push(null);
|
||||||
|
this._mcConvolversR.push(null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hrtf = this._hrtfBuffers.get(i);
|
||||||
|
|
||||||
|
const convL = this.ctx.createConvolver();
|
||||||
|
convL.normalize = false;
|
||||||
|
convL.buffer = hrtf.left;
|
||||||
|
convL.channelCount = 1;
|
||||||
|
convL.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
const convR = this.ctx.createConvolver();
|
||||||
|
convR.normalize = false;
|
||||||
|
convR.buffer = hrtf.right;
|
||||||
|
convR.channelCount = 1;
|
||||||
|
convR.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._mcConvolversL.push(convL);
|
||||||
|
this._mcConvolversR.push(convR);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._mcOutputNode = this.ctx.createGain();
|
||||||
|
}
|
||||||
|
|
||||||
|
_destroyMultichannelNodes() {
|
||||||
|
const sd = (n) => {
|
||||||
|
try {
|
||||||
|
n?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sd(this._mcSplitter);
|
||||||
|
sd(this._mcMerger);
|
||||||
|
sd(this._mcLfeGain);
|
||||||
|
this._mcConvolversL.forEach(sd);
|
||||||
|
this._mcConvolversR.forEach(sd);
|
||||||
|
sd(this._mcOutputNode);
|
||||||
|
|
||||||
|
this._mcSplitter = null;
|
||||||
|
this._mcMerger = null;
|
||||||
|
this._mcLfeGain = null;
|
||||||
|
this._mcConvolversL = [];
|
||||||
|
this._mcConvolversR = [];
|
||||||
|
this._mcOutputNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Stereo widener creation
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
_createWidenerNodes() {
|
||||||
|
this._wSplitter = this.ctx.createChannelSplitter(2);
|
||||||
|
this._wOutputMerger = this.ctx.createChannelMerger(2);
|
||||||
|
|
||||||
|
// M/S encoder gains
|
||||||
|
this._wMidL = this.ctx.createGain();
|
||||||
|
this._wMidL.gain.value = 0.5;
|
||||||
|
this._wMidL.channelCount = 1;
|
||||||
|
this._wMidL.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._wMidR = this.ctx.createGain();
|
||||||
|
this._wMidR.gain.value = 0.5;
|
||||||
|
this._wMidR.channelCount = 1;
|
||||||
|
this._wMidR.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._wSideL = this.ctx.createGain();
|
||||||
|
this._wSideL.gain.value = 0.5;
|
||||||
|
this._wSideL.channelCount = 1;
|
||||||
|
this._wSideL.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._wSideR = this.ctx.createGain();
|
||||||
|
this._wSideR.gain.value = -0.5;
|
||||||
|
this._wSideR.channelCount = 1;
|
||||||
|
this._wSideR.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
// Mono mix points
|
||||||
|
this._wMidMix = this.ctx.createGain();
|
||||||
|
this._wMidMix.channelCount = 1;
|
||||||
|
this._wMidMix.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._wSideMix = this.ctx.createGain();
|
||||||
|
this._wSideMix.channelCount = 1;
|
||||||
|
this._wSideMix.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
// Width control: mid and side gains
|
||||||
|
this._wMidGain = this.ctx.createGain();
|
||||||
|
this._wMidGain.gain.value = this._calcMidGain();
|
||||||
|
this._wSideGain = this.ctx.createGain();
|
||||||
|
this._wSideGain.gain.value = this._calcSideGain();
|
||||||
|
|
||||||
|
// M/S decoder
|
||||||
|
this._wDecoderMidToL = this.ctx.createGain();
|
||||||
|
this._wDecoderMidToL.gain.value = 1.0;
|
||||||
|
this._wDecoderSideToL = this.ctx.createGain();
|
||||||
|
this._wDecoderSideToL.gain.value = 1.0;
|
||||||
|
this._wDecoderMidToR = this.ctx.createGain();
|
||||||
|
this._wDecoderMidToR.gain.value = 1.0;
|
||||||
|
this._wDecoderSideToR = this.ctx.createGain();
|
||||||
|
this._wDecoderSideToR.gain.value = -1.0;
|
||||||
|
|
||||||
|
// L/R recombination
|
||||||
|
this._wLMix = this.ctx.createGain();
|
||||||
|
this._wLMix.channelCount = 1;
|
||||||
|
this._wLMix.channelCountMode = 'explicit';
|
||||||
|
this._wRMix = this.ctx.createGain();
|
||||||
|
this._wRMix.channelCount = 1;
|
||||||
|
this._wRMix.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._wOutputNode = this.ctx.createGain();
|
||||||
|
}
|
||||||
|
|
||||||
|
_destroyWidenerNodes() {
|
||||||
|
const nodes = [
|
||||||
|
this._wSplitter,
|
||||||
|
this._wOutputMerger,
|
||||||
|
this._wMidL,
|
||||||
|
this._wMidR,
|
||||||
|
this._wSideL,
|
||||||
|
this._wSideR,
|
||||||
|
this._wMidGain,
|
||||||
|
this._wSideGain,
|
||||||
|
this._wMidMix,
|
||||||
|
this._wSideMix,
|
||||||
|
this._wDecoderMidToL,
|
||||||
|
this._wDecoderSideToL,
|
||||||
|
this._wDecoderMidToR,
|
||||||
|
this._wDecoderSideToR,
|
||||||
|
this._wLMix,
|
||||||
|
this._wRMix,
|
||||||
|
this._wOutputNode,
|
||||||
|
];
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
try {
|
||||||
|
n?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._wSplitter = null;
|
||||||
|
this._wOutputMerger = null;
|
||||||
|
this._wMidL = null;
|
||||||
|
this._wMidR = null;
|
||||||
|
this._wSideL = null;
|
||||||
|
this._wSideR = null;
|
||||||
|
this._wMidGain = null;
|
||||||
|
this._wSideGain = null;
|
||||||
|
this._wMidMix = null;
|
||||||
|
this._wSideMix = null;
|
||||||
|
this._wDecoderMidToL = null;
|
||||||
|
this._wDecoderSideToL = null;
|
||||||
|
this._wDecoderMidToR = null;
|
||||||
|
this._wDecoderSideToR = null;
|
||||||
|
this._wLMix = null;
|
||||||
|
this._wRMix = null;
|
||||||
|
this._wOutputNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_calcMidGain() {
|
||||||
|
// At amount=1.0, mid=1.0; at amount=2.0, mid~0.6; at amount=0, mid=2.0
|
||||||
|
return 2.0 - this.wideningAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
_calcSideGain() {
|
||||||
|
return this.wideningAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Public API
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable the entire binaural DSP block.
|
||||||
|
*/
|
||||||
|
async setEnabled(enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
if (enabled) {
|
||||||
|
await this._ensureNodesCreated();
|
||||||
|
}
|
||||||
|
this._connectInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect channel count and configure mode accordingly.
|
||||||
|
* Call this when source changes or track starts playing.
|
||||||
|
* @param {number} channelCount - Number of channels in the source
|
||||||
|
*/
|
||||||
|
async detectAndConfigure(channelCount) {
|
||||||
|
const prevMode = this.mode;
|
||||||
|
const prevChannels = this.channelCount;
|
||||||
|
this.channelCount = channelCount;
|
||||||
|
|
||||||
|
if (channelCount > 2) {
|
||||||
|
this.mode = 'multichannel';
|
||||||
|
} else {
|
||||||
|
this.mode = 'stereo';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.enabled && (this.mode !== prevMode || channelCount !== prevChannels)) {
|
||||||
|
await this._ensureNodesCreated();
|
||||||
|
this._connectInternal();
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('binaural-mode-changed', {
|
||||||
|
detail: { mode: this.mode, channels: this.channelCount },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set crossfeed level.
|
||||||
|
* @param {'low'|'medium'|'high'} level
|
||||||
|
*/
|
||||||
|
setCrossfeedLevel(level) {
|
||||||
|
if (!CROSSFEED_PRESETS[level]) return;
|
||||||
|
this.crossfeedLevel = level;
|
||||||
|
|
||||||
|
// Update existing crossfeed nodes if they exist
|
||||||
|
if (this._cfFilterLR) {
|
||||||
|
const preset = CROSSFEED_PRESETS[level];
|
||||||
|
const crossGain = Math.pow(10, preset.crossGainDb / 20);
|
||||||
|
const directGain = 1.0 - crossGain * 0.5;
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
|
||||||
|
this._cfFilterLR.frequency.setTargetAtTime(preset.cutoff, now, 0.005);
|
||||||
|
this._cfFilterRL.frequency.setTargetAtTime(preset.cutoff, now, 0.005);
|
||||||
|
this._cfDelayLR.delayTime.setTargetAtTime(preset.delayMs / 1000, now, 0.005);
|
||||||
|
this._cfDelayRL.delayTime.setTargetAtTime(preset.delayMs / 1000, now, 0.005);
|
||||||
|
this._cfCrossLR.gain.setTargetAtTime(crossGain, now, 0.005);
|
||||||
|
this._cfCrossRL.gain.setTargetAtTime(crossGain, now, 0.005);
|
||||||
|
this._cfDirectL.gain.setTargetAtTime(directGain, now, 0.005);
|
||||||
|
this._cfDirectR.gain.setTargetAtTime(directGain, now, 0.005);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable crossfeed sub-feature.
|
||||||
|
*/
|
||||||
|
async setCrossfeedEnabled(enabled) {
|
||||||
|
this.crossfeedEnabled = enabled;
|
||||||
|
if (this.enabled) {
|
||||||
|
await this._ensureNodesCreated();
|
||||||
|
this._connectInternal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set HRTF preset (changes virtual speaker angles).
|
||||||
|
* @param {'intimate'|'studio'|'wide'} preset
|
||||||
|
*/
|
||||||
|
async setHrtfPreset(preset) {
|
||||||
|
if (!HRTF_PRESETS[preset]) return;
|
||||||
|
this.hrtfPreset = preset;
|
||||||
|
|
||||||
|
if (this.enabled && this.mode === 'multichannel') {
|
||||||
|
// Regenerate HRTF buffers with new angles
|
||||||
|
this._destroyMultichannelNodes();
|
||||||
|
await this._createMultichannelNodes();
|
||||||
|
this._connectInternal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stereo widening amount.
|
||||||
|
* @param {number} amount - 0.0 (mono) to 2.0 (extra wide), 1.0 = neutral
|
||||||
|
*/
|
||||||
|
setWideningAmount(amount) {
|
||||||
|
this.wideningAmount = Math.max(0, Math.min(2, amount));
|
||||||
|
|
||||||
|
if (this._wMidGain && this._wSideGain) {
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
this._wMidGain.gain.setTargetAtTime(this._calcMidGain(), now, 0.005);
|
||||||
|
this._wSideGain.gain.setTargetAtTime(this._calcSideGain(), now, 0.005);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable stereo widening sub-feature.
|
||||||
|
*/
|
||||||
|
async setWideningEnabled(enabled) {
|
||||||
|
this.wideningEnabled = enabled;
|
||||||
|
if (this.enabled) {
|
||||||
|
await this._ensureNodesCreated();
|
||||||
|
this._connectInternal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure all required nodes are created for the current mode.
|
||||||
|
*/
|
||||||
|
async _ensureNodesCreated() {
|
||||||
|
// Always create widener and crossfeed nodes
|
||||||
|
if (!this._cfOutputNode && this.crossfeedEnabled) {
|
||||||
|
this._createCrossfeedNodes();
|
||||||
|
}
|
||||||
|
if (!this._wOutputNode && this.wideningEnabled) {
|
||||||
|
this._createWidenerNodes();
|
||||||
|
}
|
||||||
|
if (this.mode === 'multichannel' && !this._mcOutputNode) {
|
||||||
|
await this._createMultichannelNodes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current processing mode info.
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
enabled: this.enabled,
|
||||||
|
mode: this.mode,
|
||||||
|
channels: this.channelCount,
|
||||||
|
crossfeed: { enabled: this.crossfeedEnabled, level: this.crossfeedLevel },
|
||||||
|
hrtfPreset: this.hrtfPreset,
|
||||||
|
widening: { enabled: this.wideningEnabled, amount: this.wideningAmount },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy all nodes and clean up.
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this._disconnectAll();
|
||||||
|
this._destroyCrossfeedNodes();
|
||||||
|
this._destroyMultichannelNodes();
|
||||||
|
this._destroyWidenerNodes();
|
||||||
|
this._hrtfBuffers = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CROSSFEED_PRESETS, HRTF_PRESETS };
|
||||||
39
js/db.js
39
js/db.js
|
|
@ -102,8 +102,6 @@ export class MusicDatabase {
|
||||||
async addToHistory(track) {
|
async addToHistory(track) {
|
||||||
const storeName = 'history_tracks';
|
const storeName = 'history_tracks';
|
||||||
const minified = this._minifyItem(track.type || 'track', track);
|
const minified = this._minifyItem(track.type || 'track', track);
|
||||||
const timestamp = Date.now();
|
|
||||||
const entry = { ...minified, timestamp };
|
|
||||||
|
|
||||||
const db = await this.open();
|
const db = await this.open();
|
||||||
|
|
||||||
|
|
@ -112,25 +110,34 @@ export class MusicDatabase {
|
||||||
const store = transaction.objectStore(storeName);
|
const store = transaction.objectStore(storeName);
|
||||||
const index = store.index('timestamp');
|
const index = store.index('timestamp');
|
||||||
|
|
||||||
const cursorReq = index.openCursor(null, 'prev');
|
const lastReq = index.openCursor(null, 'prev');
|
||||||
|
let lastTimestamp = 0;
|
||||||
|
|
||||||
cursorReq.onsuccess = (e) => {
|
lastReq.onsuccess = (e) => {
|
||||||
const cursor = e.target.result;
|
const cursor = e.target.result;
|
||||||
if (cursor) {
|
if (cursor && lastTimestamp === 0) {
|
||||||
const lastTrack = cursor.value;
|
lastTimestamp = cursor.value.timestamp;
|
||||||
if (lastTrack.id === track.id) {
|
|
||||||
store.delete(cursor.primaryKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
store.put(entry);
|
|
||||||
|
const timestamp = Math.max(Date.now(), lastTimestamp + 1);
|
||||||
|
const entry = { ...minified, timestamp };
|
||||||
|
|
||||||
|
const dedupeReq = index.openCursor(null, 'prev');
|
||||||
|
dedupeReq.onsuccess = (e2) => {
|
||||||
|
const dedupeCursor = e2.target.result;
|
||||||
|
if (dedupeCursor) {
|
||||||
|
const trackInHistory = dedupeCursor.value;
|
||||||
|
if (trackInHistory.id === track.id) {
|
||||||
|
store.delete(dedupeCursor.primaryKey);
|
||||||
|
}
|
||||||
|
dedupeCursor.continue();
|
||||||
|
} else {
|
||||||
|
store.put(entry);
|
||||||
|
resolve(entry);
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
cursorReq.onerror = (_e) => {
|
|
||||||
// If cursor fails, just try to put (fallback)
|
|
||||||
store.put(entry);
|
|
||||||
};
|
|
||||||
|
|
||||||
transaction.oncomplete = () => resolve(entry);
|
|
||||||
transaction.onerror = (e) => reject(e.target.error);
|
transaction.onerror = (e) => reject(e.target.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -499,7 +499,7 @@ async function bulkDownload({
|
||||||
* to the configured folder (Local Media Folder or saved Folder Picker handle),
|
* to the configured folder (Local Media Folder or saved Folder Picker handle),
|
||||||
* or `null` if the feature is not active / no folder is configured.
|
* or `null` if the feature is not active / no folder is configured.
|
||||||
*
|
*
|
||||||
* In contrast to {@link createBulkWriter}, this never prompts the user – it
|
* In contrast to {@link createBulkWriter}, this never prompts the user - it
|
||||||
* only succeeds when the folder is already known.
|
* only succeeds when the folder is already known.
|
||||||
*/
|
*/
|
||||||
async function createSingleTrackFolderWriter() {
|
async function createSingleTrackFolderWriter() {
|
||||||
|
|
@ -533,7 +533,7 @@ async function createSingleTrackFolderWriter() {
|
||||||
// fall through to picker
|
// fall through to picker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No usable saved handle – open the picker so the user can choose a folder.
|
// No usable saved handle - open the picker so the user can choose a folder.
|
||||||
try {
|
try {
|
||||||
const writer = await FolderPickerWriter.create();
|
const writer = await FolderPickerWriter.create();
|
||||||
if (rememberFolder) {
|
if (rememberFolder) {
|
||||||
|
|
@ -542,7 +542,7 @@ async function createSingleTrackFolderWriter() {
|
||||||
return writer;
|
return writer;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
// User cancelled the picker – return null so we fall back to the
|
// User cancelled the picker - return null so we fall back to the
|
||||||
// normal browser download instead of erroring out.
|
// normal browser download instead of erroring out.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -578,7 +578,7 @@ async function createBulkWriter(folderName) {
|
||||||
// fall through to picker
|
// fall through to picker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No usable handle – prompt and persist
|
// No usable handle - prompt and persist
|
||||||
try {
|
try {
|
||||||
const writer = await FolderPickerWriter.create();
|
const writer = await FolderPickerWriter.create();
|
||||||
await db.saveSetting('local_folder_handle', writer.getDirHandle());
|
await db.saveSetting('local_folder_handle', writer.getDirHandle());
|
||||||
|
|
@ -590,7 +590,7 @@ async function createBulkWriter(folderName) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Browser without File System Access API – fall through to ZIP
|
// Browser without File System Access API - fall through to ZIP
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Folder Picker method ─────────────────────────────────────────────────
|
// ── Folder Picker method ─────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ export class Equalizer {
|
||||||
this.frequencyLabels = generateFrequencyLabels(this.frequencies);
|
this.frequencyLabels = generateFrequencyLabels(this.frequencies);
|
||||||
|
|
||||||
// Interpolate current gains to new band count
|
// Interpolate current gains to new band count
|
||||||
const newGains = equalizerSettings._interpolateGains(this.currentGains, newCount);
|
const newGains = equalizerSettings.interpolateGains(this.currentGains, newCount);
|
||||||
this.currentGains = newGains;
|
this.currentGains = newGains;
|
||||||
equalizerSettings.setGains(newGains);
|
equalizerSettings.setGains(newGains);
|
||||||
|
|
||||||
|
|
@ -455,7 +455,7 @@ export class Equalizer {
|
||||||
// Ensure gains array matches current band count
|
// Ensure gains array matches current band count
|
||||||
let adjustedGains = gains;
|
let adjustedGains = gains;
|
||||||
if (gains.length !== this.bandCount) {
|
if (gains.length !== this.bandCount) {
|
||||||
adjustedGains = equalizerSettings._interpolateGains(gains, this.bandCount);
|
adjustedGains = equalizerSettings.interpolateGains(gains, this.bandCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = this.audioContext?.currentTime || 0;
|
const now = this.audioContext?.currentTime || 0;
|
||||||
|
|
@ -621,9 +621,12 @@ export class Equalizer {
|
||||||
|
|
||||||
this.frequencies.forEach((freq, index) => {
|
this.frequencies.forEach((freq, index) => {
|
||||||
const gain = this.currentGains[index] || 0;
|
const gain = this.currentGains[index] || 0;
|
||||||
const q = this.filters[index] ? this.filters[index].Q.value : this._calculateQ(index);
|
const type = this.currentTypes[index] || 'peaking';
|
||||||
|
const typeMap = { peaking: 'PK', lowshelf: 'LSC', highshelf: 'HSC' };
|
||||||
|
const typeStr = typeMap[type] || 'PK';
|
||||||
|
const q = this.currentQs[index] || this._calculateQ(index);
|
||||||
const filterNum = index + 1;
|
const filterNum = index + 1;
|
||||||
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`);
|
lines.push(`Filter ${filterNum}: ON ${typeStr} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
|
|
@ -653,13 +656,13 @@ export class Equalizer {
|
||||||
|
|
||||||
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
|
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
|
||||||
const filterMatch = line.match(
|
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
|
/^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) {
|
if (filterMatch) {
|
||||||
const type = filterMatch[1].toUpperCase();
|
const type = filterMatch[1].toUpperCase();
|
||||||
const freq = parseInt(filterMatch[2], 10);
|
const freq = parseInt(filterMatch[2], 10);
|
||||||
const gain = parseFloat(filterMatch[3]);
|
const gain = parseFloat(filterMatch[3]);
|
||||||
const q = parseFloat(filterMatch[4]);
|
const q = filterMatch[4] ? parseFloat(filterMatch[4]) : Math.SQRT1_2;
|
||||||
filters.push({ type, freq, gain, q });
|
filters.push({ type, freq, gain, q });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
78
js/events.js
78
js/events.js
|
|
@ -23,37 +23,6 @@ import { syncManager } from './accounts/pocketbase.js';
|
||||||
import { waveformGenerator } from './waveform.js';
|
import { waveformGenerator } from './waveform.js';
|
||||||
import { audioContextManager } from './audio-context.js';
|
import { audioContextManager } from './audio-context.js';
|
||||||
import { hapticLongPress, hapticMedium, hapticLight } from './haptics.js';
|
import { hapticLongPress, hapticMedium, hapticLight } from './haptics.js';
|
||||||
import {
|
|
||||||
trackPlayTrack,
|
|
||||||
trackPauseTrack,
|
|
||||||
trackSkipTrack,
|
|
||||||
trackToggleShuffle,
|
|
||||||
trackToggleRepeat,
|
|
||||||
trackAddToQueue,
|
|
||||||
trackPlayNext,
|
|
||||||
trackLikeTrack,
|
|
||||||
trackUnlikeTrack,
|
|
||||||
trackLikeAlbum,
|
|
||||||
trackUnlikeAlbum,
|
|
||||||
trackLikeArtist,
|
|
||||||
trackUnlikeArtist,
|
|
||||||
trackLikePlaylist,
|
|
||||||
trackUnlikePlaylist,
|
|
||||||
trackDownloadTrack,
|
|
||||||
trackContextMenuAction,
|
|
||||||
trackBlockTrack,
|
|
||||||
trackUnblockTrack,
|
|
||||||
trackBlockAlbum,
|
|
||||||
trackUnblockAlbum,
|
|
||||||
trackBlockArtist,
|
|
||||||
trackUnblockArtist,
|
|
||||||
trackCopyLink,
|
|
||||||
trackOpenInNewTab,
|
|
||||||
trackSetSleepTimer,
|
|
||||||
trackCancelSleepTimer,
|
|
||||||
trackStartMix,
|
|
||||||
trackEvent,
|
|
||||||
} from './analytics.js';
|
|
||||||
import { SVG_BIN, SVG_MUTE, SVG_PAUSE, SVG_PLAY, SVG_VOLUME, SVG_CHECKBOX, SVG_CHECKBOX_CHECKED } from './icons.js';
|
import { SVG_BIN, SVG_MUTE, SVG_PAUSE, SVG_PLAY, SVG_VOLUME, SVG_CHECKBOX, SVG_CHECKBOX_CHECKED } from './icons.js';
|
||||||
import { partyManager } from './listening-party.js';
|
import { partyManager } from './listening-party.js';
|
||||||
import { MusicAPI } from './music-api.js';
|
import { MusicAPI } from './music-api.js';
|
||||||
|
|
@ -435,9 +404,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
await audioContextManager.resume();
|
await audioContextManager.resume();
|
||||||
|
|
||||||
if (player.currentTrack) {
|
if (player.currentTrack) {
|
||||||
// Track play event
|
|
||||||
trackPlayTrack(player.currentTrack);
|
|
||||||
|
|
||||||
// Scrobble
|
// Scrobble
|
||||||
if (scrobbler.isAuthenticated()) {
|
if (scrobbler.isAuthenticated()) {
|
||||||
scrobbler.updateNowPlaying(player.currentTrack);
|
scrobbler.updateNowPlaying(player.currentTrack);
|
||||||
|
|
@ -460,9 +426,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
|
|
||||||
element.addEventListener('pause', () => {
|
element.addEventListener('pause', () => {
|
||||||
if (player.activeElement !== element) return;
|
if (player.activeElement !== element) return;
|
||||||
if (player.currentTrack) {
|
|
||||||
trackPauseTrack(player.currentTrack);
|
|
||||||
}
|
|
||||||
playPauseBtn.innerHTML = SVG_PLAY(20);
|
playPauseBtn.innerHTML = SVG_PLAY(20);
|
||||||
player.updateMediaSessionPlaybackState();
|
player.updateMediaSessionPlaybackState();
|
||||||
player.updateMediaSessionPositionState();
|
player.updateMediaSessionPositionState();
|
||||||
|
|
@ -545,8 +508,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
if (player.isFallbackInProgress || canFallback) {
|
if (player.isFallbackInProgress || canFallback) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.warn('Skipping to next track due to playback error');
|
|
||||||
setTimeout(() => player.playNext(), 1000);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -568,27 +529,23 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
});
|
});
|
||||||
nextBtn.addEventListener('click', async () => {
|
nextBtn.addEventListener('click', async () => {
|
||||||
await hapticMedium();
|
await hapticMedium();
|
||||||
trackSkipTrack(player.currentTrack, 'next');
|
|
||||||
player.playNext();
|
player.playNext();
|
||||||
});
|
});
|
||||||
prevBtn.addEventListener('click', async () => {
|
prevBtn.addEventListener('click', async () => {
|
||||||
await hapticMedium();
|
await hapticMedium();
|
||||||
trackSkipTrack(player.currentTrack, 'previous');
|
|
||||||
player.playPrev();
|
player.playPrev();
|
||||||
});
|
});
|
||||||
|
|
||||||
shuffleBtn.addEventListener('click', async () => {
|
shuffleBtn.addEventListener('click', async () => {
|
||||||
await hapticLight();
|
await hapticLight();
|
||||||
player.toggleShuffle();
|
player.toggleShuffle();
|
||||||
trackToggleShuffle(player.shuffleActive);
|
|
||||||
shuffleBtn.classList.toggle('active', player.shuffleActive);
|
shuffleBtn.classList.toggle('active', player.shuffleActive);
|
||||||
if (window.renderQueueFunction) await window.renderQueueFunction();
|
if (window.renderQueueFunction) await window.renderQueueFunction();
|
||||||
});
|
});
|
||||||
|
|
||||||
repeatBtn.addEventListener('click', async () => {
|
repeatBtn.addEventListener('click', async () => {
|
||||||
await hapticLight();
|
await hapticLight();
|
||||||
const mode = player.toggleRepeat();
|
const mode = await player.toggleRepeat();
|
||||||
trackToggleRepeat(mode === REPEAT_MODE.OFF ? 'off' : mode === REPEAT_MODE.ALL ? 'all' : 'one');
|
|
||||||
repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF);
|
repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF);
|
||||||
repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE);
|
repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE);
|
||||||
repeatBtn.title =
|
repeatBtn.title =
|
||||||
|
|
@ -606,7 +563,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
sleepTimerBtnDesktop.addEventListener('click', () => {
|
sleepTimerBtnDesktop.addEventListener('click', () => {
|
||||||
if (player.isSleepTimerActive()) {
|
if (player.isSleepTimerActive()) {
|
||||||
player.clearSleepTimer();
|
player.clearSleepTimer();
|
||||||
trackCancelSleepTimer();
|
|
||||||
showNotification('Sleep timer cancelled');
|
showNotification('Sleep timer cancelled');
|
||||||
} else {
|
} else {
|
||||||
showSleepTimerModal(player);
|
showSleepTimerModal(player);
|
||||||
|
|
@ -619,7 +575,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
sleepTimerBtnMobile.addEventListener('click', () => {
|
sleepTimerBtnMobile.addEventListener('click', () => {
|
||||||
if (player.isSleepTimerActive()) {
|
if (player.isSleepTimerActive()) {
|
||||||
player.clearSleepTimer();
|
player.clearSleepTimer();
|
||||||
trackCancelSleepTimer();
|
|
||||||
showNotification('Sleep timer cancelled');
|
showNotification('Sleep timer cancelled');
|
||||||
} else {
|
} else {
|
||||||
showSleepTimerModal(player);
|
showSleepTimerModal(player);
|
||||||
|
|
@ -1351,12 +1306,10 @@ export async function handleTrackAction(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'add-to-queue') {
|
if (action === 'add-to-queue') {
|
||||||
trackAddToQueue(item, 'end');
|
|
||||||
player.addToQueue(item);
|
player.addToQueue(item);
|
||||||
if (window.renderQueueFunction) await window.renderQueueFunction();
|
if (window.renderQueueFunction) await window.renderQueueFunction();
|
||||||
showNotification(`Added to queue: ${item.title}`);
|
showNotification(`Added to queue: ${item.title}`);
|
||||||
} else if (action === 'play-next') {
|
} else if (action === 'play-next') {
|
||||||
trackPlayNext(item);
|
|
||||||
player.addNextToQueue(item);
|
player.addNextToQueue(item);
|
||||||
if (window.renderQueueFunction) await window.renderQueueFunction();
|
if (window.renderQueueFunction) await window.renderQueueFunction();
|
||||||
showNotification(`Playing next: ${item.title}`);
|
showNotification(`Playing next: ${item.title}`);
|
||||||
|
|
@ -1365,34 +1318,17 @@ export async function handleTrackAction(
|
||||||
player.playAtIndex(0);
|
player.playAtIndex(0);
|
||||||
showNotification(`Playing track: ${item.title}`);
|
showNotification(`Playing track: ${item.title}`);
|
||||||
} else if (action === 'start-mix') {
|
} else if (action === 'start-mix') {
|
||||||
trackStartMix(type, item);
|
|
||||||
if (item.mixes?.TRACK_MIX) {
|
if (item.mixes?.TRACK_MIX) {
|
||||||
navigate(`/mix/${item.mixes.TRACK_MIX}`);
|
navigate(`/mix/${item.mixes.TRACK_MIX}`);
|
||||||
} else {
|
} else {
|
||||||
showNotification('No mix available for this track');
|
showNotification('No mix available for this track');
|
||||||
}
|
}
|
||||||
} else if (action === 'download') {
|
} else if (action === 'download') {
|
||||||
trackDownloadTrack(item, downloadQualitySettings.getQuality());
|
|
||||||
await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager);
|
await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager);
|
||||||
} else if (action === 'toggle-like') {
|
} else if (action === 'toggle-like') {
|
||||||
const added = await db.toggleFavorite(type, item);
|
const added = await db.toggleFavorite(type, item);
|
||||||
await syncManager.syncLibraryItem(type, item, added);
|
await syncManager.syncLibraryItem(type, item, added);
|
||||||
|
|
||||||
// Track like/unlike
|
|
||||||
if (added) {
|
|
||||||
if (type === 'track') trackLikeTrack(item);
|
|
||||||
else if (type === 'video') trackEvent('Like Video', { title: item.title });
|
|
||||||
else if (type === 'album') trackLikeAlbum(item);
|
|
||||||
else if (type === 'artist') trackLikeArtist(item);
|
|
||||||
else if (type === 'playlist' || type === 'user-playlist') trackLikePlaylist(item);
|
|
||||||
} else {
|
|
||||||
if (type === 'track') trackUnlikeTrack(item);
|
|
||||||
else if (type === 'video') trackEvent('Unlike Video', { title: item.title });
|
|
||||||
else if (type === 'album') trackUnlikeAlbum(item);
|
|
||||||
else if (type === 'artist') trackUnlikeArtist(item);
|
|
||||||
else if (type === 'playlist' || type === 'user-playlist') trackUnlikePlaylist(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (added && type === 'track' && scrobbler) {
|
if (added && type === 'track' && scrobbler) {
|
||||||
if (lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) {
|
if (lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) {
|
||||||
scrobbler.loveTrack(item);
|
scrobbler.loveTrack(item);
|
||||||
|
|
@ -1677,7 +1613,6 @@ export async function handleTrackAction(
|
||||||
const typeForUrl = type === 'user-playlist' ? 'userplaylist' : type;
|
const typeForUrl = type === 'user-playlist' ? 'userplaylist' : type;
|
||||||
const url = getShareUrl(storedHref ? storedHref : `/${typeForUrl}/${item.id || item.uuid}`);
|
const url = getShareUrl(storedHref ? storedHref : `/${typeForUrl}/${item.id || item.uuid}`);
|
||||||
|
|
||||||
trackCopyLink(type, item.id || item.uuid);
|
|
||||||
await navigator.clipboard
|
await navigator.clipboard
|
||||||
.writeText(url)
|
.writeText(url)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
@ -1692,7 +1627,6 @@ export async function handleTrackAction(
|
||||||
? `${window.location.origin}${storedHref}`
|
? `${window.location.origin}${storedHref}`
|
||||||
: `${window.location.origin}/${type}/${item.id || item.uuid}`;
|
: `${window.location.origin}/${type}/${item.id || item.uuid}`;
|
||||||
|
|
||||||
trackOpenInNewTab(type, item.id || item.uuid);
|
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
} else if (action === 'open-in-harmony') {
|
} else if (action === 'open-in-harmony') {
|
||||||
const albumId = item.id;
|
const albumId = item.id;
|
||||||
|
|
@ -1870,11 +1804,9 @@ export async function handleTrackAction(
|
||||||
const { contentBlockingSettings } = await import('./storage.js');
|
const { contentBlockingSettings } = await import('./storage.js');
|
||||||
if (contentBlockingSettings.isTrackBlocked(item.id)) {
|
if (contentBlockingSettings.isTrackBlocked(item.id)) {
|
||||||
contentBlockingSettings.unblockTrack(item.id);
|
contentBlockingSettings.unblockTrack(item.id);
|
||||||
trackUnblockTrack(item);
|
|
||||||
showNotification(`Unblocked track: ${item.title}`);
|
showNotification(`Unblocked track: ${item.title}`);
|
||||||
} else {
|
} else {
|
||||||
contentBlockingSettings.blockTrack(item);
|
contentBlockingSettings.blockTrack(item);
|
||||||
trackBlockTrack(item);
|
|
||||||
showNotification(`Blocked track: ${item.title}`);
|
showNotification(`Blocked track: ${item.title}`);
|
||||||
}
|
}
|
||||||
} else if (action === 'block-album') {
|
} else if (action === 'block-album') {
|
||||||
|
|
@ -1893,11 +1825,9 @@ export async function handleTrackAction(
|
||||||
|
|
||||||
if (contentBlockingSettings.isAlbumBlocked(albumId)) {
|
if (contentBlockingSettings.isAlbumBlocked(albumId)) {
|
||||||
contentBlockingSettings.unblockAlbum(albumId);
|
contentBlockingSettings.unblockAlbum(albumId);
|
||||||
trackUnblockAlbum(albumObj);
|
|
||||||
showNotification(`Unblocked album: ${albumTitle || 'Unknown Album'}`);
|
showNotification(`Unblocked album: ${albumTitle || 'Unknown Album'}`);
|
||||||
} else {
|
} else {
|
||||||
contentBlockingSettings.blockAlbum(albumObj);
|
contentBlockingSettings.blockAlbum(albumObj);
|
||||||
trackBlockAlbum(albumObj);
|
|
||||||
showNotification(`Blocked album: ${albumTitle || 'Unknown Album'}`);
|
showNotification(`Blocked album: ${albumTitle || 'Unknown Album'}`);
|
||||||
}
|
}
|
||||||
} else if (action === 'block-artist') {
|
} else if (action === 'block-artist') {
|
||||||
|
|
@ -1914,11 +1844,9 @@ export async function handleTrackAction(
|
||||||
|
|
||||||
if (contentBlockingSettings.isArtistBlocked(artistId)) {
|
if (contentBlockingSettings.isArtistBlocked(artistId)) {
|
||||||
contentBlockingSettings.unblockArtist(artistId);
|
contentBlockingSettings.unblockArtist(artistId);
|
||||||
trackUnblockArtist(artistObj);
|
|
||||||
showNotification(`Unblocked artist: ${artistName || 'Unknown Artist'}`);
|
showNotification(`Unblocked artist: ${artistName || 'Unknown Artist'}`);
|
||||||
} else {
|
} else {
|
||||||
contentBlockingSettings.blockArtist(artistObj);
|
contentBlockingSettings.blockArtist(artistObj);
|
||||||
trackBlockArtist(artistObj);
|
|
||||||
showNotification(`Blocked artist: ${artistName || 'Unknown Artist'}`);
|
showNotification(`Blocked artist: ${artistName || 'Unknown Artist'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2496,7 +2424,6 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'play-next':
|
case 'play-next':
|
||||||
selectedTracks.forEach((t) => {
|
selectedTracks.forEach((t) => {
|
||||||
trackPlayNext(t);
|
|
||||||
player.addNextToQueue(t);
|
player.addNextToQueue(t);
|
||||||
});
|
});
|
||||||
if (window.renderQueueFunction) await window.renderQueueFunction();
|
if (window.renderQueueFunction) await window.renderQueueFunction();
|
||||||
|
|
@ -2538,8 +2465,6 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Track context menu action
|
|
||||||
trackContextMenuAction(action, type, track);
|
|
||||||
await handleTrackAction(action, track, player, api, lyricsManager, type, ui, scrobbler, target.dataset);
|
await handleTrackAction(action, track, player, api, lyricsManager, type, ui, scrobbler, target.dataset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2702,7 +2627,6 @@ function showSleepTimerModal(player) {
|
||||||
|
|
||||||
if (minutes) {
|
if (minutes) {
|
||||||
player.setSleepTimer(minutes);
|
player.setSleepTimer(minutes);
|
||||||
trackSetSleepTimer(minutes);
|
|
||||||
showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`);
|
showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`);
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
207
js/hrtf-generator.js
Normal file
207
js/hrtf-generator.js
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
// js/hrtf-generator.js
|
||||||
|
// Procedural HRTF impulse response generation for binaural rendering.
|
||||||
|
// Synthesizes per-angle stereo IRs modeling ITD, ILD, and head shadow.
|
||||||
|
|
||||||
|
const HEAD_RADIUS = 0.0875; // meters (average human head radius)
|
||||||
|
const SPEED_OF_SOUND = 343; // m/s
|
||||||
|
const IR_LENGTH = 256; // samples
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the interaural time difference (ITD) for a given azimuth.
|
||||||
|
* Uses Woodworth's spherical head model.
|
||||||
|
* @param {number} azimuthRad - Azimuth in radians (0 = front, positive = right)
|
||||||
|
* @returns {number} ITD in seconds (positive = right ear leads)
|
||||||
|
*/
|
||||||
|
function calculateITD(azimuthRad) {
|
||||||
|
const absAz = Math.abs(azimuthRad);
|
||||||
|
if (absAz <= Math.PI / 2) {
|
||||||
|
return (HEAD_RADIUS / SPEED_OF_SOUND) * (absAz + Math.sin(absAz));
|
||||||
|
}
|
||||||
|
// Behind the head
|
||||||
|
return (HEAD_RADIUS / SPEED_OF_SOUND) * (Math.PI - absAz + Math.sin(absAz));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate frequency-dependent ILD (head shadow attenuation) for the far ear.
|
||||||
|
* Higher frequencies are attenuated more by the head.
|
||||||
|
* @param {number} frequency - Frequency in Hz
|
||||||
|
* @param {number} azimuthRad - Absolute azimuth in radians
|
||||||
|
* @returns {number} Attenuation factor (0-1) for the shadowed ear
|
||||||
|
*/
|
||||||
|
function calculateHeadShadow(frequency, azimuthRad) {
|
||||||
|
const absAz = Math.abs(azimuthRad);
|
||||||
|
if (absAz < 0.01) return 1.0; // Source in front, no shadow
|
||||||
|
|
||||||
|
// Head shadow increases with frequency and angle
|
||||||
|
// Based on simplified spherical head diffraction model
|
||||||
|
const ka = (2 * Math.PI * frequency * HEAD_RADIUS) / SPEED_OF_SOUND;
|
||||||
|
const shadowFactor = 1.0 / (1.0 + 0.5 * ka * Math.sin(absAz));
|
||||||
|
return Math.max(0.05, shadowFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single HRTF impulse response for a given azimuth angle.
|
||||||
|
* Returns a stereo AudioBuffer: channel 0 = left ear, channel 1 = right ear.
|
||||||
|
*
|
||||||
|
* @param {AudioContext} audioContext
|
||||||
|
* @param {number} azimuthDeg - Azimuth in degrees (-180 to 180, 0 = front, positive = right)
|
||||||
|
* @param {number} [elevationDeg=0] - Elevation in degrees (currently simplified)
|
||||||
|
* @returns {AudioBuffer} Stereo AudioBuffer with HRTF IR
|
||||||
|
*/
|
||||||
|
export function generateHRTF(audioContext, azimuthDeg, elevationDeg = 0) {
|
||||||
|
const sampleRate = audioContext.sampleRate;
|
||||||
|
const buffer = audioContext.createBuffer(2, IR_LENGTH, sampleRate);
|
||||||
|
|
||||||
|
const leftData = buffer.getChannelData(0);
|
||||||
|
const rightData = buffer.getChannelData(1);
|
||||||
|
|
||||||
|
const azimuthRad = (azimuthDeg * Math.PI) / 180;
|
||||||
|
const itd = calculateITD(azimuthRad);
|
||||||
|
const itdSamples = Math.round(itd * sampleRate);
|
||||||
|
|
||||||
|
// Determine which ear is ipsilateral (closer to source) and contralateral (farther)
|
||||||
|
const sourceOnRight = azimuthDeg > 0;
|
||||||
|
const ipsiData = sourceOnRight ? rightData : leftData;
|
||||||
|
const contraData = sourceOnRight ? leftData : rightData;
|
||||||
|
|
||||||
|
// Generate ipsilateral (near ear) IR - mostly a delayed impulse with slight coloring
|
||||||
|
// Ipsilateral ear (near source) receives sound first; contralateral ear is delayed by ITD
|
||||||
|
const ipsiDelay = 0;
|
||||||
|
const contraDelay = Math.abs(itdSamples);
|
||||||
|
|
||||||
|
// Create frequency-domain representation for head shadow
|
||||||
|
const fftSize = IR_LENGTH;
|
||||||
|
const halfFFT = fftSize / 2;
|
||||||
|
|
||||||
|
// Ipsilateral ear: near-flat response with slight high-frequency boost at extreme angles
|
||||||
|
for (let i = 0; i < fftSize; i++) {
|
||||||
|
const t = i / sampleRate;
|
||||||
|
let sum = 0;
|
||||||
|
for (let k = 1; k <= halfFFT; k++) {
|
||||||
|
const freq = (k * sampleRate) / fftSize;
|
||||||
|
const absAz = Math.abs(azimuthRad);
|
||||||
|
|
||||||
|
// Ipsilateral ear gets a slight boost at high frequencies for angles > 30°
|
||||||
|
let ipsiGain = 1.0;
|
||||||
|
if (absAz > 0.5 && freq > 2000) {
|
||||||
|
ipsiGain = 1.0 + 0.15 * Math.min(1, (freq - 2000) / 8000) * Math.sin(absAz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinna notch around 8-10kHz (elevation dependent)
|
||||||
|
const elevRad = (elevationDeg * Math.PI) / 180;
|
||||||
|
const notchFreq = 8000 + elevationDeg * 50; // Shifts with elevation
|
||||||
|
const notchWidth = 2000;
|
||||||
|
const notchDepth = 0.15 * Math.abs(Math.sin(elevRad + 0.3));
|
||||||
|
const notchFactor = 1.0 - notchDepth * Math.exp(-Math.pow((freq - notchFreq) / notchWidth, 2));
|
||||||
|
|
||||||
|
const phase = 2 * Math.PI * freq * (t - ipsiDelay / sampleRate);
|
||||||
|
sum += ((ipsiGain * notchFactor) / halfFFT) * Math.cos(phase);
|
||||||
|
}
|
||||||
|
ipsiData[i] = sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contralateral ear: apply head shadow (frequency-dependent attenuation)
|
||||||
|
for (let i = 0; i < fftSize; i++) {
|
||||||
|
const t = i / sampleRate;
|
||||||
|
let sum = 0;
|
||||||
|
for (let k = 1; k <= halfFFT; k++) {
|
||||||
|
const freq = (k * sampleRate) / fftSize;
|
||||||
|
const shadowGain = calculateHeadShadow(freq, azimuthRad);
|
||||||
|
|
||||||
|
const phase = 2 * Math.PI * freq * (t - contraDelay / sampleRate);
|
||||||
|
sum += (shadowGain / halfFFT) * Math.cos(phase);
|
||||||
|
}
|
||||||
|
contraData[i] = sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize to prevent clipping
|
||||||
|
let maxVal = 0;
|
||||||
|
for (let i = 0; i < IR_LENGTH; i++) {
|
||||||
|
maxVal = Math.max(maxVal, Math.abs(leftData[i]), Math.abs(rightData[i]));
|
||||||
|
}
|
||||||
|
if (maxVal > 0) {
|
||||||
|
const normFactor = 0.9 / maxVal;
|
||||||
|
for (let i = 0; i < IR_LENGTH; i++) {
|
||||||
|
leftData[i] *= normFactor;
|
||||||
|
rightData[i] *= normFactor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HRTF angle presets for virtual speaker configurations.
|
||||||
|
*/
|
||||||
|
export const HRTF_PRESETS = {
|
||||||
|
intimate: { label: 'Intimate', angleScale: 0.73 }, // ±22° front
|
||||||
|
studio: { label: 'Studio', angleScale: 1.0 }, // ±30° front (standard)
|
||||||
|
wide: { label: 'Wide', angleScale: 1.5 }, // ±45° front
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard 5.1 channel angles (ITU-R BS.775)
|
||||||
|
*/
|
||||||
|
export const CHANNEL_ANGLES_51 = [
|
||||||
|
{ index: 0, name: 'FL', azimuth: -30 },
|
||||||
|
{ index: 1, name: 'FR', azimuth: 30 },
|
||||||
|
{ index: 2, name: 'C', azimuth: 0 },
|
||||||
|
{ index: 3, name: 'LFE', azimuth: 0, isLFE: true },
|
||||||
|
{ index: 4, name: 'SL', azimuth: -110 },
|
||||||
|
{ index: 5, name: 'SR', azimuth: 110 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a complete set of HRTF impulse responses for 5.1 surround.
|
||||||
|
* Each entry contains separate left-ear and right-ear mono AudioBuffers
|
||||||
|
* suitable for use with ConvolverNode.
|
||||||
|
*
|
||||||
|
* @param {AudioContext} audioContext
|
||||||
|
* @param {string} [preset='studio'] - HRTF preset name
|
||||||
|
* @returns {Promise<Map<number, {left: AudioBuffer, right: AudioBuffer, stereo: AudioBuffer}>>}
|
||||||
|
*/
|
||||||
|
export async function generateHRTFSet(audioContext, preset = 'studio') {
|
||||||
|
const presetConfig = HRTF_PRESETS[preset] || HRTF_PRESETS.studio;
|
||||||
|
const angleScale = presetConfig.angleScale;
|
||||||
|
const results = new Map();
|
||||||
|
|
||||||
|
for (const ch of CHANNEL_ANGLES_51) {
|
||||||
|
if (ch.isLFE) {
|
||||||
|
// LFE: no HRTF, just pass through equally to both ears
|
||||||
|
const lfeBuffer = audioContext.createBuffer(2, IR_LENGTH, audioContext.sampleRate);
|
||||||
|
const lfeL = lfeBuffer.getChannelData(0);
|
||||||
|
const lfeR = lfeBuffer.getChannelData(1);
|
||||||
|
// Simple impulse at sample 0
|
||||||
|
lfeL[0] = 0.5;
|
||||||
|
lfeR[0] = 0.5;
|
||||||
|
results.set(ch.index, {
|
||||||
|
stereo: lfeBuffer,
|
||||||
|
left: extractChannel(audioContext, lfeBuffer, 0),
|
||||||
|
right: extractChannel(audioContext, lfeBuffer, 1),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale angle by preset
|
||||||
|
const scaledAzimuth = ch.azimuth * angleScale;
|
||||||
|
const stereoBuffer = await generateHRTF(audioContext, scaledAzimuth);
|
||||||
|
|
||||||
|
results.set(ch.index, {
|
||||||
|
stereo: stereoBuffer,
|
||||||
|
left: extractChannel(audioContext, stereoBuffer, 0),
|
||||||
|
right: extractChannel(audioContext, stereoBuffer, 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a single channel from a stereo buffer into a mono AudioBuffer.
|
||||||
|
* ConvolverNode requires the IR buffer channel count to match input or be mono.
|
||||||
|
*/
|
||||||
|
function extractChannel(audioContext, stereoBuffer, channelIndex) {
|
||||||
|
const mono = audioContext.createBuffer(1, stereoBuffer.length, audioContext.sampleRate);
|
||||||
|
mono.copyToChannel(stereoBuffer.getChannelData(channelIndex), 0);
|
||||||
|
return mono;
|
||||||
|
}
|
||||||
15
js/lyrics.js
15
js/lyrics.js
|
|
@ -74,8 +74,13 @@ class GeniusManager {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// idgaf anymore im js hardcoding this lmaooo
|
||||||
getToken() {
|
getToken() {
|
||||||
return 'QmS9OvsS-7ifRBKx_ochIPQU7oejIS9Eo_z5iWHmCPyhwLVQID3pYTHJmJTa6z8z'; // idgaf anymore im js hardcoding this lmaooo
|
const hostname = window.location.hostname;
|
||||||
|
if (hostname.endsWith('monochrome.tf') || hostname === 'monochrome.tf') {
|
||||||
|
return 'OpITG-h86oehKYuJJ5QVY5F-HxUWXb31EwGKarx2Tle3W9rBUVnMaUL9qo_Oh9Q7';
|
||||||
|
}
|
||||||
|
return 'QmS9OvsS-7ifRBKx_ochIPQU7oejIS9Eo_z5iWHmCPyhwLVQID3pYTHJmJTa6z8z';
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchTrack(title, artist) {
|
async searchTrack(title, artist) {
|
||||||
|
|
@ -1013,12 +1018,16 @@ function applyFullscreenLyricsShadowTweaks(amLyrics, container) {
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-line {
|
.lyrics-line {
|
||||||
|
transform-origin: left center;
|
||||||
transition:
|
transition:
|
||||||
opacity 0.42s ease,
|
opacity 0.42s ease,
|
||||||
transform 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--lyrics-line-delay, 0ms),
|
transform 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--lyrics-line-delay, 0ms),
|
||||||
filter 0.48s cubic-bezier(0.22, 1, 0.36, 1) !important;
|
filter 0.48s cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lyrics-line:not(.active):not(.pre-active) {
|
||||||
|
opacity: 0.44;
|
||||||
|
}
|
||||||
.lyrics-line-container {
|
.lyrics-line-container {
|
||||||
transition:
|
transition:
|
||||||
transform 0.72s cubic-bezier(0.22, 1, 0.36, 1),
|
transform 0.72s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
|
@ -1033,6 +1042,10 @@ function applyFullscreenLyricsShadowTweaks(amLyrics, container) {
|
||||||
background-color 0.22s ease,
|
background-color 0.22s ease,
|
||||||
color 0.22s ease !important;
|
color 0.22s ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lyrics-line.active .lyrics-line-container {
|
||||||
|
transform: scale(1.015);
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,10 @@ export class MusicAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
/*
|
||||||
|
Maintainer of artwork.boidu.dev has asked for his API to be removed for the time being due to spam
|
||||||
|
*/
|
||||||
|
/*
|
||||||
const url = `https://artwork.boidu.dev/?s=${encodeURIComponent(title)}&a=${encodeURIComponent(artist)}`;
|
const url = `https://artwork.boidu.dev/?s=${encodeURIComponent(title)}&a=${encodeURIComponent(artist)}`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
|
|
@ -257,6 +261,8 @@ export class MusicAPI {
|
||||||
};
|
};
|
||||||
this.videoArtworkCache.set(cacheKey, result);
|
this.videoArtworkCache.set(cacheKey, result);
|
||||||
return result;
|
return result;
|
||||||
|
*/
|
||||||
|
throw new Error('Video artwork is disabled for now.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to fetch video artwork:', error);
|
console.warn('Failed to fetch video artwork:', error);
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
28
js/player.js
28
js/player.js
|
|
@ -16,6 +16,7 @@ import {
|
||||||
exponentialVolumeSettings,
|
exponentialVolumeSettings,
|
||||||
audioEffectsSettings,
|
audioEffectsSettings,
|
||||||
radioSettings,
|
radioSettings,
|
||||||
|
binauralDspSettings,
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { audioContextManager } from './audio-context.js';
|
import { audioContextManager } from './audio-context.js';
|
||||||
import { isIos, isSafari } from './platform-detection.js';
|
import { isIos, isSafari } from './platform-detection.js';
|
||||||
|
|
@ -291,7 +292,8 @@ export class Player {
|
||||||
}
|
}
|
||||||
|
|
||||||
setPlaybackSpeed(speed) {
|
setPlaybackSpeed(speed) {
|
||||||
const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0));
|
const parsed = parseFloat(speed);
|
||||||
|
const validSpeed = Math.max(0.01, Math.min(100, isNaN(parsed) ? 1.0 : parsed));
|
||||||
audioEffectsSettings.setSpeed(validSpeed);
|
audioEffectsSettings.setSpeed(validSpeed);
|
||||||
this.applyAudioEffects();
|
this.applyAudioEffects();
|
||||||
}
|
}
|
||||||
|
|
@ -1881,9 +1883,29 @@ export class Player {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAtmosPlaying) {
|
if (isAtmosPlaying) {
|
||||||
|
// Auto-enable binaural DSP for spatial content
|
||||||
|
if (binauralDspSettings.getAutoEnableForSpatial() && !binauralDspSettings.isEnabled()) {
|
||||||
|
void audioContextManager.toggleBinaural(true);
|
||||||
|
// Update toggle in settings UI if visible
|
||||||
|
const toggle = document.getElementById('binaural-dsp-toggle');
|
||||||
|
if (toggle) toggle.checked = true;
|
||||||
|
const container = document.getElementById('binaural-dsp-container');
|
||||||
|
if (container) container.style.display = 'block';
|
||||||
|
}
|
||||||
|
// Notify binaural DSP of the actual multichannel layout when Shaka exposes it.
|
||||||
|
const atmosChannelCount =
|
||||||
|
Number.isFinite(activeVariant.channelsCount) && activeVariant.channelsCount > 0
|
||||||
|
? activeVariant.channelsCount
|
||||||
|
: 6;
|
||||||
|
void audioContextManager.notifyBinauralChannelCount(atmosChannelCount);
|
||||||
|
|
||||||
|
const binauralActive = audioContextManager.isBinauralActive();
|
||||||
badgeEl.className = 'quality-badge quality-atmos shaka-quality-badge';
|
badgeEl.className = 'quality-badge quality-atmos shaka-quality-badge';
|
||||||
badgeEl.innerHTML = SVG_ATMOS(20);
|
badgeEl.innerHTML =
|
||||||
|
SVG_ATMOS(20) + (binauralActive ? ' <span class="binaural-badge">Binaural</span>' : '');
|
||||||
} else {
|
} else {
|
||||||
|
// Notify binaural DSP that we're in stereo mode
|
||||||
|
void audioContextManager.notifyBinauralChannelCount(2);
|
||||||
badgeEl.className = 'quality-badge quality-hires shaka-quality-badge';
|
badgeEl.className = 'quality-badge quality-hires shaka-quality-badge';
|
||||||
badgeEl.textContent = text;
|
badgeEl.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
@ -2128,7 +2150,7 @@ export class Player {
|
||||||
await this._bgAudioPlugin.stop();
|
await this._bgAudioPlugin.stop();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Not running in Capacitor or plugin unavailable — ignore
|
// Not running in Capacitor or plugin unavailable - ignore
|
||||||
} finally {
|
} finally {
|
||||||
this._bgAudioPending = false;
|
this._bgAudioPending = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,12 +188,28 @@ export async function loadProfile(username) {
|
||||||
const statusEl = document.getElementById('profile-status');
|
const statusEl = document.getElementById('profile-status');
|
||||||
try {
|
try {
|
||||||
const statusObj = JSON.parse(profile.status);
|
const statusObj = JSON.parse(profile.status);
|
||||||
statusEl.innerHTML = `
|
|
||||||
<span style="opacity: 0.7; margin-right: 0.25rem;">Listening to:</span>
|
statusEl.replaceChildren();
|
||||||
<img src="${statusObj.image}" style="width: 20px; height: 20px; border-radius: 2px; vertical-align: middle; margin-right: 0.5rem;">
|
|
||||||
<a href="${statusObj.link}" class="status-link" style="color: inherit; text-decoration: none; font-weight: 500;">${statusObj.text}</a>
|
const label = document.createElement('span');
|
||||||
`;
|
label.style.cssText = 'opacity: 0.7; margin-right: 0.25rem;';
|
||||||
statusEl.querySelector('.status-link').onclick = (e) => {
|
label.textContent = 'Listening to:';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = statusObj.image;
|
||||||
|
img.style.cssText =
|
||||||
|
'width: 20px; height: 20px; border-radius: 2px; vertical-align: middle; margin-right: 0.5rem;';
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
if (statusObj.link.startsWith('/')) {
|
||||||
|
link.href = statusObj.link;
|
||||||
|
}
|
||||||
|
link.className = 'status-link';
|
||||||
|
link.style.cssText = 'color: inherit; text-decoration: none; font-weight: 500;';
|
||||||
|
link.textContent = statusObj.text;
|
||||||
|
|
||||||
|
statusEl.append(label, img, link);
|
||||||
|
link.onclick = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(statusObj.link);
|
navigate(statusObj.link);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
1041
js/settings.js
1041
js/settings.js
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,3 @@
|
||||||
import { trackCloseSidePanel, trackCloseQueue, trackCloseLyrics } from './analytics.js';
|
|
||||||
|
|
||||||
export class SidePanelManager {
|
export class SidePanelManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.panel = document.getElementById('side-panel');
|
this.panel = document.getElementById('side-panel');
|
||||||
|
|
@ -103,15 +101,9 @@ export class SidePanelManager {
|
||||||
close() {
|
close() {
|
||||||
// Track side panel close
|
// Track side panel close
|
||||||
if (this.currentView) {
|
if (this.currentView) {
|
||||||
trackCloseSidePanel();
|
if (this.currentView === 'lyrics') {
|
||||||
if (this.currentView === 'queue') {
|
|
||||||
trackCloseQueue();
|
|
||||||
} else if (this.currentView === 'lyrics') {
|
|
||||||
// Get current track from audio player context
|
// Get current track from audio player context
|
||||||
const audioPlayer = document.getElementById('audio-player');
|
const audioPlayer = document.getElementById('audio-player');
|
||||||
if (audioPlayer && audioPlayer._currentTrack) {
|
|
||||||
trackCloseLyrics(audioPlayer._currentTrack);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
217
js/storage.js
217
js/storage.js
|
|
@ -442,7 +442,8 @@ export const lastFMStorage = {
|
||||||
},
|
},
|
||||||
|
|
||||||
setScrobblePercentage(percentage) {
|
setScrobblePercentage(percentage) {
|
||||||
const validPercentage = Math.max(1, Math.min(100, parseInt(percentage, 10) || 75));
|
const parsed = parseInt(percentage, 10);
|
||||||
|
const validPercentage = Math.max(1, Math.min(100, isNaN(parsed) ? 75 : parsed));
|
||||||
localStorage.setItem(this.SCROBBLE_PERCENTAGE_KEY, validPercentage.toString());
|
localStorage.setItem(this.SCROBBLE_PERCENTAGE_KEY, validPercentage.toString());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -948,9 +949,9 @@ export const visualizerSettings = {
|
||||||
|
|
||||||
getPreset() {
|
getPreset() {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(this.PRESET_KEY) || 'butterchurn';
|
return localStorage.getItem(this.PRESET_KEY) || 'kawarp';
|
||||||
} catch {
|
} catch {
|
||||||
return 'butterchurn';
|
return 'kawarp';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1070,6 +1071,7 @@ export const equalizerSettings = {
|
||||||
GAINS_KEY: 'equalizer-gains',
|
GAINS_KEY: 'equalizer-gains',
|
||||||
BAND_TYPES_KEY: 'equalizer-band-types',
|
BAND_TYPES_KEY: 'equalizer-band-types',
|
||||||
BAND_QS_KEY: 'equalizer-band-qs',
|
BAND_QS_KEY: 'equalizer-band-qs',
|
||||||
|
BAND_CHANNELS_KEY: 'equalizer-band-channels',
|
||||||
PRESET_KEY: 'equalizer-preset',
|
PRESET_KEY: 'equalizer-preset',
|
||||||
CUSTOM_PRESETS_KEY: 'equalizer-custom-presets',
|
CUSTOM_PRESETS_KEY: 'equalizer-custom-presets',
|
||||||
BAND_COUNT_KEY: 'equalizer-band-count',
|
BAND_COUNT_KEY: 'equalizer-band-count',
|
||||||
|
|
@ -1123,9 +1125,10 @@ export const equalizerSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
setBandCount(count) {
|
setBandCount(count) {
|
||||||
|
const parsedCount = parseInt(count, 10);
|
||||||
const validCount = Math.max(
|
const validCount = Math.max(
|
||||||
this.MIN_BANDS,
|
this.MIN_BANDS,
|
||||||
Math.min(this.MAX_BANDS, parseInt(count, 10) || this.DEFAULT_BAND_COUNT)
|
Math.min(this.MAX_BANDS, isNaN(parsedCount) ? this.DEFAULT_BAND_COUNT : parsedCount)
|
||||||
);
|
);
|
||||||
localStorage.setItem(this.BAND_COUNT_KEY, validCount.toString());
|
localStorage.setItem(this.BAND_COUNT_KEY, validCount.toString());
|
||||||
},
|
},
|
||||||
|
|
@ -1326,7 +1329,7 @@ export const equalizerSettings = {
|
||||||
}
|
}
|
||||||
// If different band count, try to interpolate or return flat
|
// If different band count, try to interpolate or return flat
|
||||||
if (gains.length > 0) {
|
if (gains.length > 0) {
|
||||||
return this._interpolateGains(gains, count);
|
return this.interpolateGains(gains, count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1422,7 +1425,7 @@ export const equalizerSettings = {
|
||||||
}
|
}
|
||||||
// Interpolate stored Qs to match requested band count instead of discarding
|
// Interpolate stored Qs to match requested band count instead of discarding
|
||||||
if (Array.isArray(qs) && qs.length >= this.MIN_BANDS) {
|
if (Array.isArray(qs) && qs.length >= this.MIN_BANDS) {
|
||||||
return this._interpolateGains(qs, count);
|
return this.interpolateGains(qs, count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -1441,10 +1444,36 @@ export const equalizerSettings = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getBandChannels(bandCount) {
|
||||||
|
const count = bandCount || this.getBandCount();
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.BAND_CHANNELS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const channels = JSON.parse(stored);
|
||||||
|
if (Array.isArray(channels) && channels.length === count) {
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return new Array(count).fill('stereo');
|
||||||
|
},
|
||||||
|
|
||||||
|
setBandChannels(channels) {
|
||||||
|
try {
|
||||||
|
if (Array.isArray(channels) && channels.length >= this.MIN_BANDS && channels.length <= this.MAX_BANDS) {
|
||||||
|
localStorage.setItem(this.BAND_CHANNELS_KEY, JSON.stringify(channels));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[EQ] Failed to save band channels:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interpolate gains array to match target band count
|
* Interpolate gains array to match target band count
|
||||||
*/
|
*/
|
||||||
_interpolateGains(sourceGains, targetCount) {
|
interpolateGains(sourceGains, targetCount) {
|
||||||
if (sourceGains.length === targetCount) {
|
if (sourceGains.length === targetCount) {
|
||||||
return [...sourceGains];
|
return [...sourceGains];
|
||||||
}
|
}
|
||||||
|
|
@ -1698,10 +1727,12 @@ export const equalizerSettings = {
|
||||||
localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
|
localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Graphic EQ (16-band) separate storage ---
|
// --- Graphic EQ separate storage ---
|
||||||
GEQ_ENABLED_KEY: 'graphic-eq-enabled',
|
GEQ_ENABLED_KEY: 'graphic-eq-enabled',
|
||||||
GEQ_GAINS_KEY: 'graphic-eq-gains',
|
GEQ_GAINS_KEY: 'graphic-eq-gains',
|
||||||
GEQ_PREAMP_KEY: 'graphic-eq-preamp',
|
GEQ_PREAMP_KEY: 'graphic-eq-preamp',
|
||||||
|
GEQ_BAND_COUNT_KEY: 'graphic-eq-band-count',
|
||||||
|
GEQ_FREQ_RANGE_KEY: 'graphic-eq-freq-range',
|
||||||
|
|
||||||
isGraphicEqEnabled() {
|
isGraphicEqEnabled() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1719,24 +1750,78 @@ export const equalizerSettings = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getGraphicEqGains() {
|
getGraphicEqBandCount() {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(this.GEQ_GAINS_KEY);
|
const val = localStorage.getItem(this.GEQ_BAND_COUNT_KEY);
|
||||||
|
if (val !== null) {
|
||||||
|
const num = parseInt(val, 10);
|
||||||
|
if (num >= 3 && num <= 32) return num;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return 16;
|
||||||
|
},
|
||||||
|
|
||||||
|
setGraphicEqBandCount(count) {
|
||||||
|
const clamped = Math.max(3, Math.min(32, parseInt(count, 10) || 16));
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.GEQ_BAND_COUNT_KEY, String(clamped));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getGraphicEqFreqRange() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.GEQ_FREQ_RANGE_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
if (Array.isArray(parsed) && parsed.length === 16) {
|
if (parsed && Number.isFinite(parsed.min) && Number.isFinite(parsed.max)) {
|
||||||
return parsed.map((v) => (Number.isFinite(v) ? v : 0));
|
return parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
return new Array(16).fill(0);
|
return { min: 25, max: 20000 };
|
||||||
|
},
|
||||||
|
|
||||||
|
setGraphicEqFreqRange(min, max) {
|
||||||
|
const clampedMin = Math.max(10, Math.min(96000, parseInt(min, 10) || 25));
|
||||||
|
const clampedMax = Math.max(10, Math.min(96000, parseInt(max, 10) || 20000));
|
||||||
|
if (clampedMin >= clampedMax) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.GEQ_FREQ_RANGE_KEY, JSON.stringify({ min: clampedMin, max: clampedMax }));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getGraphicEqGains(bandCount) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.GEQ_GAINS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
const expectedCount = bandCount || this.getGraphicEqBandCount();
|
||||||
|
if (Array.isArray(parsed) && parsed.length === expectedCount) {
|
||||||
|
return parsed.map((v) => (Number.isFinite(v) ? v : 0));
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
return this.interpolateGains(parsed, expectedCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return new Array(bandCount || this.getGraphicEqBandCount()).fill(0);
|
||||||
},
|
},
|
||||||
|
|
||||||
setGraphicEqGains(gains) {
|
setGraphicEqGains(gains) {
|
||||||
|
if (!Array.isArray(gains)) return;
|
||||||
|
const sanitized = gains.map((v) => (Number.isFinite(v) ? v : 0));
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(this.GEQ_GAINS_KEY, JSON.stringify(gains));
|
localStorage.setItem(this.GEQ_GAINS_KEY, JSON.stringify(sanitized));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
@ -1756,8 +1841,9 @@ export const equalizerSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
setGraphicEqPreamp(db) {
|
setGraphicEqPreamp(db) {
|
||||||
|
const clamped = Math.max(-20, Math.min(20, parseFloat(db) || 0));
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(this.GEQ_PREAMP_KEY, String(db));
|
localStorage.setItem(this.GEQ_PREAMP_KEY, String(clamped));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
@ -1780,6 +1866,101 @@ export const monoAudioSettings = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const binauralDspSettings = {
|
||||||
|
STORAGE_KEY: 'binaural-dsp',
|
||||||
|
|
||||||
|
_getAll() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(this.STORAGE_KEY)) || {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_setAll(obj) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(obj));
|
||||||
|
} catch {
|
||||||
|
// QuotaExceededError - storage full
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return this._getAll().enabled === true;
|
||||||
|
},
|
||||||
|
|
||||||
|
setEnabled(enabled) {
|
||||||
|
const all = this._getAll();
|
||||||
|
all.enabled = !!enabled;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCrossfeedEnabled() {
|
||||||
|
const val = this._getAll().crossfeedEnabled;
|
||||||
|
return val === undefined ? true : val;
|
||||||
|
},
|
||||||
|
|
||||||
|
setCrossfeedEnabled(enabled) {
|
||||||
|
const all = this._getAll();
|
||||||
|
all.crossfeedEnabled = !!enabled;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCrossfeedLevel() {
|
||||||
|
return this._getAll().crossfeedLevel || 'medium';
|
||||||
|
},
|
||||||
|
|
||||||
|
setCrossfeedLevel(level) {
|
||||||
|
const all = this._getAll();
|
||||||
|
all.crossfeedLevel = level;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
getHrtfPreset() {
|
||||||
|
return this._getAll().hrtfPreset || 'studio';
|
||||||
|
},
|
||||||
|
|
||||||
|
setHrtfPreset(preset) {
|
||||||
|
const all = this._getAll();
|
||||||
|
all.hrtfPreset = preset;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
getWideningEnabled() {
|
||||||
|
const val = this._getAll().wideningEnabled;
|
||||||
|
return val === undefined ? true : val;
|
||||||
|
},
|
||||||
|
|
||||||
|
setWideningEnabled(enabled) {
|
||||||
|
const all = this._getAll();
|
||||||
|
all.wideningEnabled = !!enabled;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
getWideningAmount() {
|
||||||
|
const val = this._getAll().wideningAmount;
|
||||||
|
return val === undefined ? 1.0 : val;
|
||||||
|
},
|
||||||
|
|
||||||
|
setWideningAmount(amount) {
|
||||||
|
const all = this._getAll();
|
||||||
|
const n = Number(amount);
|
||||||
|
all.wideningAmount = Number.isFinite(n) ? Math.max(0, Math.min(2, n)) : 1.0;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAutoEnableForSpatial() {
|
||||||
|
const val = this._getAll().autoEnableForSpatial;
|
||||||
|
return val === undefined ? true : val;
|
||||||
|
},
|
||||||
|
|
||||||
|
setAutoEnableForSpatial(enabled) {
|
||||||
|
const all = this._getAll();
|
||||||
|
all.autoEnableForSpatial = !!enabled;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const exponentialVolumeSettings = {
|
export const exponentialVolumeSettings = {
|
||||||
STORAGE_KEY: 'exponential-volume-enabled',
|
STORAGE_KEY: 'exponential-volume-enabled',
|
||||||
|
|
||||||
|
|
@ -1830,7 +2011,8 @@ export const audioEffectsSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
setSpeed(speed) {
|
setSpeed(speed) {
|
||||||
const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0));
|
const parsed = parseFloat(speed);
|
||||||
|
const validSpeed = Math.max(0.01, Math.min(100, isNaN(parsed) ? 1.0 : parsed));
|
||||||
localStorage.setItem(this.SPEED_KEY, validSpeed.toString());
|
localStorage.setItem(this.SPEED_KEY, validSpeed.toString());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -2450,7 +2632,8 @@ export const fontSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
setFontSize(size) {
|
setFontSize(size) {
|
||||||
const validSize = Math.max(50, Math.min(200, parseInt(size, 10) || 100));
|
const parsed = parseInt(size, 10);
|
||||||
|
const validSize = Math.max(50, Math.min(200, isNaN(parsed) ? 100 : parsed));
|
||||||
localStorage.setItem(this.FONT_SIZE_KEY, validSize.toString());
|
localStorage.setItem(this.FONT_SIZE_KEY, validSize.toString());
|
||||||
this.applyFontSize();
|
this.applyFontSize();
|
||||||
return validSize;
|
return validSize;
|
||||||
|
|
|
||||||
107
js/tests/db.test.js
Normal file
107
js/tests/db.test.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { expect, test, describe, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { MusicDatabase } from '../db.js';
|
||||||
|
|
||||||
|
describe('MusicDatabase', () => {
|
||||||
|
let db;
|
||||||
|
const TEST_DB_NAME = 'TestMonochromeDB';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
db = new MusicDatabase();
|
||||||
|
db.dbName = TEST_DB_NAME;
|
||||||
|
const req = indexedDB.deleteDatabase(TEST_DB_NAME);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
req.onsuccess = resolve;
|
||||||
|
req.onerror = resolve;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (db.db) {
|
||||||
|
db.db.close();
|
||||||
|
}
|
||||||
|
const req = indexedDB.deleteDatabase(TEST_DB_NAME);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
req.onsuccess = resolve;
|
||||||
|
req.onerror = resolve;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens database and creates stores', async () => {
|
||||||
|
const openedDb = await db.open();
|
||||||
|
expect(openedDb.name).toBe(TEST_DB_NAME);
|
||||||
|
expect(openedDb.objectStoreNames.contains('favorites_tracks')).toBe(true);
|
||||||
|
expect(openedDb.objectStoreNames.contains('history_tracks')).toBe(true);
|
||||||
|
expect(openedDb.objectStoreNames.contains('user_playlists')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleFavorite adds and removes items', async () => {
|
||||||
|
const track = { id: 'track1', title: 'Test Track', artist: { name: 'Artist' } };
|
||||||
|
|
||||||
|
const added = await db.toggleFavorite('track', track);
|
||||||
|
expect(added).toBe(true);
|
||||||
|
const favorites = await db.getFavorites('track');
|
||||||
|
expect(favorites.length).toBe(1);
|
||||||
|
expect(favorites[0].id).toBe('track1');
|
||||||
|
|
||||||
|
const removed = await db.toggleFavorite('track', track);
|
||||||
|
expect(removed).toBe(false);
|
||||||
|
const favoritesAfter = await db.getFavorites('track');
|
||||||
|
expect(favoritesAfter.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addToHistory manages recent tracks and avoids duplicates', async () => {
|
||||||
|
const track1 = { id: 't1', title: 'Track 1' };
|
||||||
|
const track2 = { id: 't2', title: 'Track 2' };
|
||||||
|
|
||||||
|
await db.addToHistory(track1);
|
||||||
|
await db.addToHistory(track2);
|
||||||
|
await db.addToHistory(track1);
|
||||||
|
|
||||||
|
const history = await db.getHistory();
|
||||||
|
expect(history.length).toBe(2);
|
||||||
|
expect(history[0].id).toBe('t1');
|
||||||
|
expect(history[1].id).toBe('t2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('playlist operations: create, add, remove, delete', async () => {
|
||||||
|
const track = { id: 'track1', title: 'Test Track' };
|
||||||
|
|
||||||
|
const playlist = await db.createPlaylist('My Playlist', [track]);
|
||||||
|
expect(playlist.name).toBe('My Playlist');
|
||||||
|
expect(playlist.tracks.length).toBe(1);
|
||||||
|
|
||||||
|
const track2 = { id: 'track2', title: 'Track 2' };
|
||||||
|
await db.addTrackToPlaylist(playlist.id, track2);
|
||||||
|
|
||||||
|
const updated = await db.getPlaylist(playlist.id);
|
||||||
|
expect(updated.tracks.length).toBe(2);
|
||||||
|
expect(updated.tracks[1].id).toBe('track2');
|
||||||
|
|
||||||
|
await db.removeTrackFromPlaylist(playlist.id, 'track1');
|
||||||
|
const afterRemove = await db.getPlaylist(playlist.id);
|
||||||
|
expect(afterRemove.tracks.length).toBe(1);
|
||||||
|
expect(afterRemove.tracks[0].id).toBe('track2');
|
||||||
|
|
||||||
|
await db.deletePlaylist(playlist.id);
|
||||||
|
const deleted = await db.getPlaylist(playlist.id);
|
||||||
|
expect(deleted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pinned items management', async () => {
|
||||||
|
const album = { id: 'album1', title: 'Album 1', type: 'album' };
|
||||||
|
|
||||||
|
await db.togglePinned(album, 'album');
|
||||||
|
let pinned = await db.getPinned();
|
||||||
|
expect(pinned.length).toBe(1);
|
||||||
|
expect(pinned[0].id).toBe('album1');
|
||||||
|
|
||||||
|
await db.togglePinned({ id: 'a2', title: 'A2' }, 'album');
|
||||||
|
await db.togglePinned({ id: 'a3', title: 'A3' }, 'album');
|
||||||
|
await db.togglePinned({ id: 'a4', title: 'A4' }, 'album');
|
||||||
|
|
||||||
|
pinned = await db.getPinned();
|
||||||
|
expect(pinned.length).toBe(3);
|
||||||
|
expect(pinned.some((p) => p.id === 'a4')).toBe(true);
|
||||||
|
expect(pinned.some((p) => p.id === 'album1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
195
js/tests/player.test.js
Normal file
195
js/tests/player.test.js
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { expect, test, describe, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { Player } from '../player.js';
|
||||||
|
import { REPEAT_MODE } from '../utils.js';
|
||||||
|
import { audioEffectsSettings } from '../storage.js';
|
||||||
|
|
||||||
|
vi.mock('../audio-context.js', () => ({
|
||||||
|
audioContextManager: {
|
||||||
|
init: vi.fn(),
|
||||||
|
resume: vi.fn(() => Promise.resolve()),
|
||||||
|
isReady: vi.fn(() => false),
|
||||||
|
setVolume: vi.fn(),
|
||||||
|
changeSource: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../storage.js', () => ({
|
||||||
|
queueManager: {
|
||||||
|
getQueue: vi.fn(() => null),
|
||||||
|
saveQueue: vi.fn(),
|
||||||
|
},
|
||||||
|
replayGainSettings: { getMode: vi.fn(() => 'off'), getPreamp: vi.fn(() => 0) },
|
||||||
|
trackDateSettings: { useAlbumYear: vi.fn(() => true) },
|
||||||
|
exponentialVolumeSettings: { applyCurve: vi.fn((v) => v) },
|
||||||
|
audioEffectsSettings: {
|
||||||
|
getSpeed: vi.fn(() => 1.0),
|
||||||
|
setSpeed: vi.fn(),
|
||||||
|
isPreservePitchEnabled: vi.fn(() => true),
|
||||||
|
setPreservePitch: vi.fn(),
|
||||||
|
},
|
||||||
|
radioSettings: { isEnabled: vi.fn(() => false) },
|
||||||
|
contentBlockingSettings: {
|
||||||
|
shouldHideTrack: vi.fn(() => false),
|
||||||
|
shouldHideAlbum: vi.fn(() => false),
|
||||||
|
shouldHideArtist: vi.fn(() => false),
|
||||||
|
},
|
||||||
|
qualityBadgeSettings: { isEnabled: vi.fn(() => true) },
|
||||||
|
coverArtSizeSettings: { getSize: vi.fn(() => '1280') },
|
||||||
|
apiSettings: {
|
||||||
|
loadInstancesFromGitHub: vi.fn(() => Promise.resolve([])),
|
||||||
|
getInstances: vi.fn(() => Promise.resolve([])),
|
||||||
|
},
|
||||||
|
recentActivityManager: { addArtist: vi.fn(), addAlbum: vi.fn() },
|
||||||
|
themeManager: { getTheme: vi.fn(() => 'dark'), setTheme: vi.fn() },
|
||||||
|
lastFMStorage: { isEnabled: vi.fn(() => false) },
|
||||||
|
nowPlayingSettings: { getMode: vi.fn(() => 'cover') },
|
||||||
|
gaplessPlaybackSettings: { isEnabled: vi.fn(() => true) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../db.js', () => ({
|
||||||
|
db: {
|
||||||
|
get: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../ui.js', () => ({
|
||||||
|
UIRenderer: {
|
||||||
|
renderQueue: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('shaka-player', () => ({
|
||||||
|
default: {
|
||||||
|
polyfill: { installAll: vi.fn() },
|
||||||
|
Player: {
|
||||||
|
isBrowserSupported: vi.fn(() => true),
|
||||||
|
prototype: {
|
||||||
|
configure: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
load: vi.fn(),
|
||||||
|
unload: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
polyfill: { installAll: vi.fn() },
|
||||||
|
Player: class {
|
||||||
|
static isBrowserSupported() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
configure() {}
|
||||||
|
addEventListener() {}
|
||||||
|
load() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
unload() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Player', () => {
|
||||||
|
let audioElement;
|
||||||
|
let api;
|
||||||
|
let player;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<audio id="audio-player"></audio>
|
||||||
|
<video id="video-player"></video>
|
||||||
|
<div class="now-playing-bar">
|
||||||
|
<img class="cover" src="">
|
||||||
|
<div class="title"></div>
|
||||||
|
<div class="artist"></div>
|
||||||
|
<div class="album"></div>
|
||||||
|
</div>
|
||||||
|
<div id="total-duration"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
audioElement = document.getElementById('audio-player');
|
||||||
|
api = {
|
||||||
|
getCoverUrl: vi.fn((id) => `url-${id}`),
|
||||||
|
getCoverSrcset: vi.fn(),
|
||||||
|
getStreamUrl: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Player._instance = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initialization sets up initial state', async () => {
|
||||||
|
player = new Player(audioElement, api);
|
||||||
|
expect(player.audio).toBe(audioElement);
|
||||||
|
expect(player.api).toBe(api);
|
||||||
|
expect(player.queue).toEqual([]);
|
||||||
|
expect(player.shuffleActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setVolume updates userVolume and localStorage', () => {
|
||||||
|
player = new Player(audioElement, api);
|
||||||
|
player.setVolume(0.5);
|
||||||
|
expect(player.userVolume).toBe(0.5);
|
||||||
|
expect(localStorage.getItem('volume')).toBe('0.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shuffle toggles correctly', () => {
|
||||||
|
player = new Player(audioElement, api);
|
||||||
|
player.queue = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
||||||
|
|
||||||
|
player.toggleShuffle();
|
||||||
|
expect(player.shuffleActive).toBe(true);
|
||||||
|
expect(player.shuffledQueue.length).toBe(3);
|
||||||
|
|
||||||
|
player.toggleShuffle();
|
||||||
|
expect(player.shuffleActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeat mode cycles correctly', () => {
|
||||||
|
player = new Player(audioElement, api);
|
||||||
|
expect(player.repeatMode).toBe(REPEAT_MODE.OFF);
|
||||||
|
|
||||||
|
player.toggleRepeat();
|
||||||
|
expect(player.repeatMode).toBe(REPEAT_MODE.ALL);
|
||||||
|
|
||||||
|
player.toggleRepeat();
|
||||||
|
expect(player.repeatMode).toBe(REPEAT_MODE.ONE);
|
||||||
|
|
||||||
|
player.toggleRepeat();
|
||||||
|
expect(player.repeatMode).toBe(REPEAT_MODE.OFF);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addToQueue adds tracks to the end', async () => {
|
||||||
|
player = new Player(audioElement, api);
|
||||||
|
player.queue = [{ id: 1 }];
|
||||||
|
|
||||||
|
await player.addToQueue([{ id: 2 }, { id: 3 }]);
|
||||||
|
expect(player.queue.length).toBe(3);
|
||||||
|
expect(player.queue[2].id).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearQueue resets queue state', async () => {
|
||||||
|
player = new Player(audioElement, api);
|
||||||
|
player.queue = [{ id: 1 }];
|
||||||
|
player.currentQueueIndex = 0;
|
||||||
|
|
||||||
|
await player.clearQueue();
|
||||||
|
expect(player.queue).toEqual([]);
|
||||||
|
expect(player.currentQueueIndex).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setPlaybackSpeed clamps values', () => {
|
||||||
|
player = new Player(audioElement, api);
|
||||||
|
|
||||||
|
player.setPlaybackSpeed(2.0);
|
||||||
|
expect(audioEffectsSettings.setSpeed).toHaveBeenCalledWith(2.0);
|
||||||
|
|
||||||
|
player.setPlaybackSpeed(0);
|
||||||
|
expect(audioEffectsSettings.setSpeed).toHaveBeenCalledWith(0.01);
|
||||||
|
});
|
||||||
|
});
|
||||||
125
js/tests/storage.test.js
Normal file
125
js/tests/storage.test.js
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { expect, test, describe, beforeEach, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
recentActivityManager,
|
||||||
|
themeManager,
|
||||||
|
lastFMStorage,
|
||||||
|
nowPlayingSettings,
|
||||||
|
gaplessPlaybackSettings,
|
||||||
|
exponentialVolumeSettings,
|
||||||
|
audioEffectsSettings,
|
||||||
|
} from '../storage.js';
|
||||||
|
|
||||||
|
describe('storage.js', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recentActivityManager', () => {
|
||||||
|
test('initializes with empty arrays', () => {
|
||||||
|
const recents = recentActivityManager.getRecents();
|
||||||
|
expect(recents.artists).toEqual([]);
|
||||||
|
expect(recents.albums).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds artist and maintains limit', () => {
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
recentActivityManager.addArtist({ id: i, name: `Artist ${i}` });
|
||||||
|
}
|
||||||
|
const recents = recentActivityManager.getRecents();
|
||||||
|
expect(recents.artists.length).toBe(10);
|
||||||
|
expect(recents.artists[0].id).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears recents', () => {
|
||||||
|
recentActivityManager.addArtist({ id: 1, name: 'Artist' });
|
||||||
|
recentActivityManager.clear();
|
||||||
|
const recents = recentActivityManager.getRecents();
|
||||||
|
expect(recents.artists).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('themeManager', () => {
|
||||||
|
test('gets and sets theme', () => {
|
||||||
|
themeManager.setTheme('dark');
|
||||||
|
expect(themeManager.getTheme()).toBe('dark');
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles custom theme', () => {
|
||||||
|
const colors = { primary: '#ff0000', background: '#000000' };
|
||||||
|
themeManager.setCustomTheme(colors);
|
||||||
|
expect(themeManager.getTheme()).toBe('custom');
|
||||||
|
expect(themeManager.getCustomTheme()).toEqual(colors);
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--primary')).toBe('#ff0000');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lastFMStorage', () => {
|
||||||
|
test('handles enabled state', () => {
|
||||||
|
lastFMStorage.setEnabled(true);
|
||||||
|
expect(lastFMStorage.isEnabled()).toBe(true);
|
||||||
|
lastFMStorage.setEnabled(false);
|
||||||
|
expect(lastFMStorage.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('obfuscates sensitive data', () => {
|
||||||
|
const key = 'test-api-key';
|
||||||
|
lastFMStorage.setCustomApiKey(key);
|
||||||
|
expect(localStorage.getItem(lastFMStorage.CUSTOM_API_KEY)).not.toBe(key);
|
||||||
|
expect(lastFMStorage.getCustomApiKey()).toBe(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nowPlayingSettings', () => {
|
||||||
|
test('gets and sets mode', () => {
|
||||||
|
expect(nowPlayingSettings.getMode()).toBe('cover');
|
||||||
|
nowPlayingSettings.setMode('visualizer');
|
||||||
|
expect(nowPlayingSettings.getMode()).toBe('visualizer');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gaplessPlaybackSettings', () => {
|
||||||
|
test('defaults to true', () => {
|
||||||
|
expect(gaplessPlaybackSettings.isEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets enabled state', () => {
|
||||||
|
gaplessPlaybackSettings.setEnabled(false);
|
||||||
|
expect(gaplessPlaybackSettings.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exponentialVolumeSettings', () => {
|
||||||
|
test('applies curve when enabled', () => {
|
||||||
|
exponentialVolumeSettings.setEnabled(true);
|
||||||
|
expect(exponentialVolumeSettings.applyCurve(0.5)).toBeCloseTo(0.125);
|
||||||
|
expect(exponentialVolumeSettings.inverseCurve(0.125)).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not apply curve when disabled', () => {
|
||||||
|
exponentialVolumeSettings.setEnabled(false);
|
||||||
|
expect(exponentialVolumeSettings.applyCurve(0.5)).toBe(0.5);
|
||||||
|
expect(exponentialVolumeSettings.inverseCurve(0.5)).toBe(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('audioEffectsSettings', () => {
|
||||||
|
test('gets and sets speed within bounds', () => {
|
||||||
|
audioEffectsSettings.setSpeed(2.0);
|
||||||
|
expect(audioEffectsSettings.getSpeed()).toBe(2.0);
|
||||||
|
|
||||||
|
audioEffectsSettings.setSpeed(200);
|
||||||
|
expect(audioEffectsSettings.getSpeed()).toBe(100);
|
||||||
|
|
||||||
|
audioEffectsSettings.setSpeed(0);
|
||||||
|
expect(audioEffectsSettings.getSpeed()).toBe(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resets speed', () => {
|
||||||
|
audioEffectsSettings.setSpeed(2.0);
|
||||||
|
audioEffectsSettings.resetSpeed();
|
||||||
|
expect(audioEffectsSettings.getSpeed()).toBe(1.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
211
js/tests/utils.test.js
Normal file
211
js/tests/utils.test.js
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { expect, test, describe, vi } from 'vitest';
|
||||||
|
import * as utils from '../utils.js';
|
||||||
|
|
||||||
|
vi.mock('../ModernSettings.js', () => ({
|
||||||
|
modernSettings: {
|
||||||
|
filenameTemplate: '{artist} - {album} - {trackNumber} - {title}',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../icons.js', () => ({
|
||||||
|
SVG_ATMOS: () => '<svg>atmos</svg>',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../storage.js', () => ({
|
||||||
|
qualityBadgeSettings: { isEnabled: vi.fn(() => true) },
|
||||||
|
coverArtSizeSettings: { getSize: vi.fn(() => '1280') },
|
||||||
|
trackDateSettings: { useAlbumYear: vi.fn(() => false) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('utils.js', () => {
|
||||||
|
describe('formatTime', () => {
|
||||||
|
test('formats seconds into M:SS', () => {
|
||||||
|
expect(utils.formatTime(0)).toBe('0:00');
|
||||||
|
expect(utils.formatTime(5)).toBe('0:05');
|
||||||
|
expect(utils.formatTime(60)).toBe('1:00');
|
||||||
|
expect(utils.formatTime(65)).toBe('1:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats seconds into H:MM:SS', () => {
|
||||||
|
expect(utils.formatTime(3600)).toBe('1:00:00');
|
||||||
|
expect(utils.formatTime(3665)).toBe('1:01:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles NaN', () => {
|
||||||
|
expect(utils.formatTime(NaN)).toBe('0:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeForFilename', () => {
|
||||||
|
test('replaces invalid characters with underscores', () => {
|
||||||
|
expect(utils.sanitizeForFilename('a/b:c*d?e"f<g>h|i')).toBe('a_b_c_d_e_f_g_h_i');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapses multiple spaces and trims', () => {
|
||||||
|
expect(utils.sanitizeForFilename(' hello world ')).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns "Unknown" for empty input', () => {
|
||||||
|
expect(utils.sanitizeForFilename('')).toBe('Unknown');
|
||||||
|
expect(utils.sanitizeForFilename(null)).toBe('Unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('replaceTokens', () => {
|
||||||
|
test('replaces tokens in template', () => {
|
||||||
|
const template = '{artist} - {title}';
|
||||||
|
const tokens = { artist: 'Artist', title: 'Title' };
|
||||||
|
expect(utils.replaceTokens(template, tokens)).toBe('Artist - Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves unknown tokens as is', () => {
|
||||||
|
const template = '{artist} - {unknown}';
|
||||||
|
const tokens = { artist: 'Artist' };
|
||||||
|
expect(utils.replaceTokens(template, tokens)).toBe('Artist - {unknown}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPathTemplate', () => {
|
||||||
|
test('formats path correctly', () => {
|
||||||
|
const data = {
|
||||||
|
artist: 'Artist',
|
||||||
|
album: 'Album',
|
||||||
|
trackNumber: 1,
|
||||||
|
title: 'Title',
|
||||||
|
discNumber: 1,
|
||||||
|
};
|
||||||
|
const template = '{artist}/{album}/{trackNumber} - {title}';
|
||||||
|
expect(utils.formatPathTemplate(template, data)).toBe('Artist/Album/01 - Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips . and .. segments', () => {
|
||||||
|
const data = { artist: '..', title: '.' };
|
||||||
|
const template = '{artist}/{title}/song';
|
||||||
|
expect(utils.formatPathTemplate(template, data)).toBe('song');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectAudioFormat', () => {
|
||||||
|
test('detects flac', () => {
|
||||||
|
const view = new DataView(new Uint8Array([0x66, 0x4c, 0x61, 0x43]).buffer);
|
||||||
|
expect(utils.detectAudioFormat(view)).toBe('flac');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects mp4', () => {
|
||||||
|
const view = new DataView(new Uint8Array([0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70]).buffer);
|
||||||
|
expect(utils.detectAudioFormat(view)).toBe('mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects mp3 (ID3)', () => {
|
||||||
|
const view = new DataView(new Uint8Array([0x49, 0x44, 0x33]).buffer);
|
||||||
|
expect(utils.detectAudioFormat(view)).toBe('mp3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects ogg', () => {
|
||||||
|
const view = new DataView(new Uint8Array([0x4f, 0x67, 0x67, 0x53]).buffer);
|
||||||
|
expect(utils.detectAudioFormat(view)).toBe('ogg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for unknown format', () => {
|
||||||
|
const view = new DataView(new Uint8Array([0, 0, 0, 0]).buffer);
|
||||||
|
expect(utils.detectAudioFormat(view)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeQualityToken', () => {
|
||||||
|
test('normalizes various quality strings', () => {
|
||||||
|
expect(utils.normalizeQualityToken('HI_RES_LOSSLESS')).toBe('HI_RES_LOSSLESS');
|
||||||
|
expect(utils.normalizeQualityToken('MASTER')).toBe('HI_RES_LOSSLESS');
|
||||||
|
expect(utils.normalizeQualityToken('HIFI')).toBe('LOSSLESS');
|
||||||
|
expect(utils.normalizeQualityToken('ATMOS')).toBe('DOLBY_ATMOS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for unknown quality', () => {
|
||||||
|
expect(utils.normalizeQualityToken('UNKNOWN')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickBestQuality', () => {
|
||||||
|
test('picks the highest quality from list', () => {
|
||||||
|
expect(utils.pickBestQuality(['LOSSLESS', 'HI_RES_LOSSLESS', 'HIGH'])).toBe('HI_RES_LOSSLESS');
|
||||||
|
expect(utils.pickBestQuality(['LOW', 'HIGH'])).toBe('HIGH');
|
||||||
|
expect(utils.pickBestQuality(['DOLBY_ATMOS', 'HI_RES_LOSSLESS'])).toBe('DOLBY_ATMOS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTrackTitle', () => {
|
||||||
|
test('returns title with version if present', () => {
|
||||||
|
expect(utils.getTrackTitle({ title: 'Song', version: 'Remix' })).toBe('Song (Remix)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns just title if no version', () => {
|
||||||
|
expect(utils.getTrackTitle({ title: 'Song' })).toBe('Song');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns fallback if no title', () => {
|
||||||
|
expect(utils.getTrackTitle({}, { fallback: 'No Title' })).toBe('No Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTrackArtists', () => {
|
||||||
|
test('joins multiple artists', () => {
|
||||||
|
const track = { artists: [{ name: 'A' }, { name: 'B' }] };
|
||||||
|
expect(utils.getTrackArtists(track)).toBe('A, B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns fallback if no artists', () => {
|
||||||
|
expect(utils.getTrackArtists({})).toBe('Unknown Artist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTrackDiscNumber', () => {
|
||||||
|
test('extracts disc number from various properties', () => {
|
||||||
|
expect(utils.getTrackDiscNumber({ discNumber: 2 })).toBe(2);
|
||||||
|
expect(utils.getTrackDiscNumber({ volumeNumber: 3 })).toBe(3);
|
||||||
|
expect(utils.getTrackDiscNumber({ mediaNumber: 4 })).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for invalid values', () => {
|
||||||
|
expect(utils.getTrackDiscNumber({ discNumber: 0 })).toBeNull();
|
||||||
|
expect(utils.getTrackDiscNumber({ discNumber: 'abc' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tryCatch', () => {
|
||||||
|
test('executes sync function', () => {
|
||||||
|
const fn = vi.fn(() => 'success');
|
||||||
|
const onError = vi.fn();
|
||||||
|
expect(utils.tryCatch(fn, onError)).toBe('success');
|
||||||
|
expect(onError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles sync error', () => {
|
||||||
|
const error = new Error('fail');
|
||||||
|
const fn = vi.fn(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const onError = vi.fn((err) => err.message);
|
||||||
|
expect(utils.tryCatch(fn, onError)).toBe('fail');
|
||||||
|
expect(onError).toHaveBeenCalledWith(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executes async function', async () => {
|
||||||
|
const fn = vi.fn(async () => 'success');
|
||||||
|
const onError = vi.fn();
|
||||||
|
const result = await utils.tryCatch(fn, onError);
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(onError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles async error', async () => {
|
||||||
|
const error = new Error('fail');
|
||||||
|
const fn = vi.fn(async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const onError = vi.fn(async (err) => err.message);
|
||||||
|
const result = await utils.tryCatch(fn, onError);
|
||||||
|
expect(result).toBe('fail');
|
||||||
|
expect(onError).toHaveBeenCalledWith(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -416,7 +416,15 @@ export class ThemeStore {
|
||||||
const customUrl = urlMatch[1].trim().replace(/['"]/g, '');
|
const customUrl = urlMatch[1].trim().replace(/['"]/g, '');
|
||||||
console.log(`Applying custom font URL: ${customUrl}`);
|
console.log(`Applying custom font URL: ${customUrl}`);
|
||||||
|
|
||||||
if (customUrl.match(/\.(css)$/i) || customUrl.includes('fonts.googleapis.com')) {
|
let isGoogleFontsHost = false;
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(customUrl, window.location.href);
|
||||||
|
isGoogleFontsHost = parsedUrl.hostname === 'fonts.googleapis.com';
|
||||||
|
} catch (_e) {
|
||||||
|
isGoogleFontsHost = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customUrl.match(/\.(css)$/i) || isGoogleFontsHost) {
|
||||||
if (!link) {
|
if (!link) {
|
||||||
link = document.createElement('link');
|
link = document.createElement('link');
|
||||||
link.id = FONT_LINK_ID;
|
link.id = FONT_LINK_ID;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import { downloadQualitySettings, contentBlockingSettings } from './storage.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
import { syncManager } from './accounts/pocketbase.js';
|
import { syncManager } from './accounts/pocketbase.js';
|
||||||
import { showNotification, downloadTracks } from './downloads.js';
|
import { showNotification, downloadTracks } from './downloads.js';
|
||||||
import { trackSearchTabChange, trackOpenQueue } from './analytics.js';
|
|
||||||
import {
|
import {
|
||||||
SVG_CLOSE,
|
SVG_CLOSE,
|
||||||
SVG_BIN,
|
SVG_BIN,
|
||||||
|
|
@ -474,7 +473,6 @@ export function initializeUIInteractions(player, api, ui) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const openQueuePanel = () => {
|
const openQueuePanel = () => {
|
||||||
trackOpenQueue();
|
|
||||||
sidePanelManager.open('queue', 'Queue', renderQueueControls, renderQueueContent);
|
sidePanelManager.open('queue', 'Queue', renderQueueControls, renderQueueContent);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -536,9 +534,6 @@ export function initializeUIInteractions(player, api, ui) {
|
||||||
const page = tab.closest('.page');
|
const page = tab.closest('.page');
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
|
|
||||||
// Track tab change
|
|
||||||
trackSearchTabChange(tab.dataset.tab);
|
|
||||||
|
|
||||||
page.querySelectorAll('.search-tab').forEach((t) => t.classList.remove('active'));
|
page.querySelectorAll('.search-tab').forEach((t) => t.classList.remove('active'));
|
||||||
page.querySelectorAll('.search-tab-content').forEach((c) => c.classList.remove('active'));
|
page.querySelectorAll('.search-tab-content').forEach((c) => c.classList.remove('active'));
|
||||||
|
|
||||||
|
|
|
||||||
453
js/ui.js
453
js/ui.js
|
|
@ -13,7 +13,9 @@ import {
|
||||||
calculateTotalDuration,
|
calculateTotalDuration,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
|
decodeHtml,
|
||||||
getShareUrl,
|
getShareUrl,
|
||||||
|
createModal,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { openLyricsPanel, renderLyricsInFullscreen, clearFullscreenLyricsSync } from './lyrics.js';
|
import { openLyricsPanel, renderLyricsInFullscreen, clearFullscreenLyricsSync } from './lyrics.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -34,6 +36,7 @@ import { syncManager } from './accounts/pocketbase.js';
|
||||||
import { authManager } from './accounts/auth.js';
|
import { authManager } from './accounts/auth.js';
|
||||||
import { partyManager } from './listening-party.js';
|
import { partyManager } from './listening-party.js';
|
||||||
import { Visualizer } from './visualizer.js';
|
import { Visualizer } from './visualizer.js';
|
||||||
|
import { audioContextManager } from './audio-context.js';
|
||||||
import { navigate } from './router.js';
|
import { navigate } from './router.js';
|
||||||
import { sidePanelManager } from './side-panel.js';
|
import { sidePanelManager } from './side-panel.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -46,7 +49,6 @@ import {
|
||||||
createProjectCardHTML,
|
createProjectCardHTML,
|
||||||
createTrackFromSong,
|
createTrackFromSong,
|
||||||
} from './tracker.js';
|
} from './tracker.js';
|
||||||
import { trackSearch, trackChangeSort } from './analytics.js';
|
|
||||||
|
|
||||||
fontSettings.applyFont().catch(console.error);
|
fontSettings.applyFont().catch(console.error);
|
||||||
fontSettings.applyFontSize();
|
fontSettings.applyFontSize();
|
||||||
|
|
@ -93,6 +95,7 @@ const setFullscreenUIToggleIcon = (button, visualizerOnlyMode) => {
|
||||||
button.innerHTML = visualizerOnlyMode ? SVG_EYE(24) : SVG_EYE_OFF(24);
|
button.innerHTML = visualizerOnlyMode ? SVG_EYE(24) : SVG_EYE_OFF(24);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMobileFullscreenViewport = () => window.matchMedia('(max-width: 768px)').matches;
|
||||||
function sortTracks(tracks, sortType) {
|
function sortTracks(tracks, sortType) {
|
||||||
if (sortType === 'custom') return [...tracks];
|
if (sortType === 'custom') return [...tracks];
|
||||||
const sorted = [...tracks];
|
const sorted = [...tracks];
|
||||||
|
|
@ -155,6 +158,10 @@ export class UIRenderer {
|
||||||
this.renderLock = false;
|
this.renderLock = false;
|
||||||
this.lastRecommendedTracks = [];
|
this.lastRecommendedTracks = [];
|
||||||
this.currentArtistId = null;
|
this.currentArtistId = null;
|
||||||
|
this.fullscreenLyricsVisible = true;
|
||||||
|
this.fullscreenPlaybackStateCleanup = null;
|
||||||
|
this.fullscreenDismissHandleCleanup = null;
|
||||||
|
this.fullscreenLyricsToggleCleanup = null;
|
||||||
|
|
||||||
// Listen for dynamic color reset events
|
// Listen for dynamic color reset events
|
||||||
window.addEventListener('reset-dynamic-color', () => {
|
window.addEventListener('reset-dynamic-color', () => {
|
||||||
|
|
@ -1095,9 +1102,13 @@ export class UIRenderer {
|
||||||
let r = parseInt(hex.substr(0, 2), 16);
|
let r = parseInt(hex.substr(0, 2), 16);
|
||||||
let g = parseInt(hex.substr(2, 2), 16);
|
let g = parseInt(hex.substr(2, 2), 16);
|
||||||
let b = parseInt(hex.substr(4, 2), 16);
|
let b = parseInt(hex.substr(4, 2), 16);
|
||||||
|
let fullscreenR = r;
|
||||||
|
let fullscreenG = g;
|
||||||
|
let fullscreenB = b;
|
||||||
|
|
||||||
// Calculate perceived brightness
|
// Calculate perceived brightness
|
||||||
let brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
let brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
let fullscreenBrightness = brightness;
|
||||||
|
|
||||||
if (isLightMode) {
|
if (isLightMode) {
|
||||||
// In light mode, the background is white.
|
// In light mode, the background is white.
|
||||||
|
|
@ -1124,6 +1135,23 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
const adjustedColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
const adjustedColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||||
|
while (fullscreenBrightness < 105) {
|
||||||
|
fullscreenR = Math.min(255, Math.max(fullscreenR + 1, Math.floor(fullscreenR * 1.08)));
|
||||||
|
fullscreenG = Math.min(255, Math.max(fullscreenG + 1, Math.floor(fullscreenG * 1.08)));
|
||||||
|
fullscreenB = Math.min(255, Math.max(fullscreenB + 1, Math.floor(fullscreenB * 1.08)));
|
||||||
|
fullscreenBrightness = (fullscreenR * 299 + fullscreenG * 587 + fullscreenB * 114) / 1000;
|
||||||
|
if (fullscreenR >= 255 && fullscreenG >= 255 && fullscreenB >= 255) break;
|
||||||
|
}
|
||||||
|
while (fullscreenBrightness > 185) {
|
||||||
|
fullscreenR = Math.floor(fullscreenR * 0.92);
|
||||||
|
fullscreenG = Math.floor(fullscreenG * 0.92);
|
||||||
|
fullscreenB = Math.floor(fullscreenB * 0.92);
|
||||||
|
fullscreenBrightness = (fullscreenR * 299 + fullscreenG * 587 + fullscreenB * 114) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullscreenAdjustedColor = `#${fullscreenR.toString(16).padStart(2, '0')}${fullscreenG
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0')}${fullscreenB.toString(16).padStart(2, '0')}`;
|
||||||
|
|
||||||
// Calculate contrast text color for buttons (text on top of the vibrant color)
|
// Calculate contrast text color for buttons (text on top of the vibrant color)
|
||||||
const foreground = brightness > 128 ? '#000000' : '#ffffff';
|
const foreground = brightness > 128 ? '#000000' : '#ffffff';
|
||||||
|
|
@ -1135,6 +1163,8 @@ export class UIRenderer {
|
||||||
root.style.setProperty('--highlight-rgb', `${r}, ${g}, ${b}`);
|
root.style.setProperty('--highlight-rgb', `${r}, ${g}, ${b}`);
|
||||||
root.style.setProperty('--active-highlight', adjustedColor);
|
root.style.setProperty('--active-highlight', adjustedColor);
|
||||||
root.style.setProperty('--ring', adjustedColor);
|
root.style.setProperty('--ring', adjustedColor);
|
||||||
|
root.style.setProperty('--fs-accent', fullscreenAdjustedColor);
|
||||||
|
root.style.setProperty('--fs-accent-rgb', `${fullscreenR}, ${fullscreenG}, ${fullscreenB}`);
|
||||||
|
|
||||||
// Calculate a safe hover color
|
// Calculate a safe hover color
|
||||||
let hoverColor;
|
let hoverColor;
|
||||||
|
|
@ -1157,6 +1187,8 @@ export class UIRenderer {
|
||||||
root.style.removeProperty('--highlight-rgb');
|
root.style.removeProperty('--highlight-rgb');
|
||||||
root.style.removeProperty('--active-highlight');
|
root.style.removeProperty('--active-highlight');
|
||||||
root.style.removeProperty('--ring');
|
root.style.removeProperty('--ring');
|
||||||
|
root.style.removeProperty('--fs-accent');
|
||||||
|
root.style.removeProperty('--fs-accent-rgb');
|
||||||
root.style.removeProperty('--track-hover-bg');
|
root.style.removeProperty('--track-hover-bg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1270,12 +1302,10 @@ export class UIRenderer {
|
||||||
currentImage.src = coverUrl;
|
currentImage.src = coverUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
overlay.style.setProperty('--bg-image', `url('${this.api.getCoverUrl(track.album?.cover, '1280')}')`);
|
|
||||||
await this.extractAndApplyColor(this.api.getCoverUrl(track.album?.cover, '80'));
|
await this.extractAndApplyColor(this.api.getCoverUrl(track.album?.cover, '80'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const qualityBadge = this.getFullscreenQualityBadgeHTML(track);
|
this.updateFullscreenQualityBadgePlacement(track, overlay);
|
||||||
title.innerHTML = `${escapeHtml(track.title)} ${qualityBadge}`;
|
|
||||||
artist.textContent = getTrackArtists(track);
|
artist.textContent = getTrackArtists(track);
|
||||||
|
|
||||||
if (nextTrack) {
|
if (nextTrack) {
|
||||||
|
|
@ -1288,11 +1318,12 @@ export class UIRenderer {
|
||||||
|
|
||||||
async showFullscreenCover(track, nextTrack, lyricsManager, activeElement) {
|
async showFullscreenCover(track, nextTrack, lyricsManager, activeElement) {
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
this.fullscreenVisualizerSuppressed = true;
|
this.fullscreenVisualizerSuppressed = false;
|
||||||
if (window.location.hash !== '#fullscreen') {
|
if (window.location.hash !== '#fullscreen') {
|
||||||
window.history.pushState({ fullscreen: true }, '', '#fullscreen');
|
window.history.pushState({ fullscreen: true }, '', '#fullscreen');
|
||||||
}
|
}
|
||||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||||
|
const isAlreadyOpen = overlay && window.getComputedStyle(overlay).display !== 'none';
|
||||||
const nextTrackEl = document.getElementById('fullscreen-next-track');
|
const nextTrackEl = document.getElementById('fullscreen-next-track');
|
||||||
const lyricsPane = document.getElementById('fullscreen-lyrics-pane');
|
const lyricsPane = document.getElementById('fullscreen-lyrics-pane');
|
||||||
const lyricsContent = document.getElementById('fullscreen-lyrics-content');
|
const lyricsContent = document.getElementById('fullscreen-lyrics-content');
|
||||||
|
|
@ -1312,12 +1343,14 @@ export class UIRenderer {
|
||||||
lyricsManager && activeElement && lyricsPane && lyricsContent && track.type !== 'video'
|
lyricsManager && activeElement && lyricsPane && lyricsContent && track.type !== 'video'
|
||||||
);
|
);
|
||||||
if (canRenderLyrics) {
|
if (canRenderLyrics) {
|
||||||
lyricsToggleBtn.style.display = 'none';
|
this.fullscreenLyricsVisible = true;
|
||||||
|
if (lyricsToggleBtn) lyricsToggleBtn.style.removeProperty('display');
|
||||||
overlay.classList.remove('lyrics-unavailable');
|
overlay.classList.remove('lyrics-unavailable');
|
||||||
clearFullscreenLyricsSync(lyricsContent);
|
clearFullscreenLyricsSync(lyricsContent);
|
||||||
await renderLyricsInFullscreen(track, activeElement, lyricsManager, lyricsContent);
|
await renderLyricsInFullscreen(track, activeElement, lyricsManager, lyricsContent);
|
||||||
} else {
|
} else {
|
||||||
lyricsToggleBtn.style.display = 'none';
|
this.fullscreenLyricsVisible = false;
|
||||||
|
if (lyricsToggleBtn) lyricsToggleBtn.style.display = 'none';
|
||||||
overlay.classList.add('lyrics-unavailable');
|
overlay.classList.add('lyrics-unavailable');
|
||||||
if (lyricsContent) {
|
if (lyricsContent) {
|
||||||
clearFullscreenLyricsSync(lyricsContent);
|
clearFullscreenLyricsSync(lyricsContent);
|
||||||
|
|
@ -1325,6 +1358,7 @@ export class UIRenderer {
|
||||||
'<div class="fullscreen-lyrics-empty">Lyrics are not available for this track.</div>';
|
'<div class="fullscreen-lyrics-empty">Lyrics are not available for this track.</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.updateFullscreenLyricsVisibility(overlay);
|
||||||
|
|
||||||
const playerBar = document.querySelector('.now-playing-bar');
|
const playerBar = document.querySelector('.now-playing-bar');
|
||||||
if (playerBar) playerBar.style.display = 'none';
|
if (playerBar) playerBar.style.display = 'none';
|
||||||
|
|
@ -1332,9 +1366,16 @@ export class UIRenderer {
|
||||||
sidePanelManager.close();
|
sidePanelManager.close();
|
||||||
}
|
}
|
||||||
const mainContent = document.querySelector('.main-content');
|
const mainContent = document.querySelector('.main-content');
|
||||||
if (mainContent instanceof HTMLElement) {
|
if (mainContent instanceof HTMLElement && !isAlreadyOpen) {
|
||||||
this.fullscreenMainContentOverflow = mainContent.style.overflowY;
|
const computedStyles = window.getComputedStyle(mainContent);
|
||||||
mainContent.style.overflowY = 'hidden';
|
this.fullscreenMainContentOverflow = {
|
||||||
|
overflow: mainContent.style.overflow,
|
||||||
|
overflowX: mainContent.style.overflowX,
|
||||||
|
overflowY: mainContent.style.overflowY,
|
||||||
|
computedOverflowX: computedStyles.overflowX,
|
||||||
|
computedOverflowY: computedStyles.overflowY,
|
||||||
|
};
|
||||||
|
mainContent.style.overflow = 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setupFullscreenControls();
|
this.setupFullscreenControls();
|
||||||
|
|
@ -1355,9 +1396,93 @@ export class UIRenderer {
|
||||||
this.setupUIToggleButton(overlay);
|
this.setupUIToggleButton(overlay);
|
||||||
this.setupControlsAutoHide(overlay);
|
this.setupControlsAutoHide(overlay);
|
||||||
this.setupFullscreenSidePanelSync(overlay);
|
this.setupFullscreenSidePanelSync(overlay);
|
||||||
|
this.setupFullscreenDismissHandle(overlay);
|
||||||
|
this.setupFullscreenLyricsToggle(overlay);
|
||||||
await this.refreshFullscreenVisualizerState(activeElement);
|
await this.refreshFullscreenVisualizerState(activeElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateFullscreenLyricsVisibility(overlay = document.getElementById('fullscreen-cover-overlay')) {
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
const lyricsToggleButtons = [
|
||||||
|
document.getElementById('toggle-fullscreen-lyrics-btn'),
|
||||||
|
document.getElementById('toggle-fullscreen-lyrics-mobile-btn'),
|
||||||
|
].filter(Boolean);
|
||||||
|
const lyricsUnavailable = overlay.classList.contains('lyrics-unavailable');
|
||||||
|
const shouldShowLyrics = this.fullscreenLyricsVisible && !lyricsUnavailable;
|
||||||
|
|
||||||
|
overlay.classList.toggle('lyrics-hidden', !shouldShowLyrics);
|
||||||
|
this.updateFullscreenQualityBadgePlacement(this.player?.currentTrack, overlay);
|
||||||
|
|
||||||
|
lyricsToggleButtons.forEach((lyricsToggleBtn) => {
|
||||||
|
lyricsToggleBtn.classList.toggle('active', shouldShowLyrics);
|
||||||
|
lyricsToggleBtn.title = shouldShowLyrics ? 'Hide Lyrics' : 'Show Lyrics';
|
||||||
|
lyricsToggleBtn.setAttribute('aria-pressed', shouldShowLyrics ? 'true' : 'false');
|
||||||
|
if (lyricsUnavailable) {
|
||||||
|
lyricsToggleBtn.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
lyricsToggleBtn.style.removeProperty('display');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFullscreenLyrics(overlay = document.getElementById('fullscreen-cover-overlay')) {
|
||||||
|
if (!overlay || overlay.classList.contains('lyrics-unavailable')) return false;
|
||||||
|
|
||||||
|
this.fullscreenLyricsVisible = !this.fullscreenLyricsVisible;
|
||||||
|
this.updateFullscreenLyricsVisibility(overlay);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFullscreenQualityBadgePlacement(track, overlay = document.getElementById('fullscreen-cover-overlay')) {
|
||||||
|
if (!track || !overlay) return;
|
||||||
|
|
||||||
|
const title = document.getElementById('fullscreen-track-title');
|
||||||
|
const mobileQuality = document.getElementById('fullscreen-mobile-quality');
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
const qualityBadge = this.getFullscreenQualityBadgeHTML(track);
|
||||||
|
const useMobileBadgeOnly =
|
||||||
|
window.matchMedia('(max-width: 768px)').matches && overlay.classList.contains('lyrics-hidden');
|
||||||
|
|
||||||
|
title.innerHTML = useMobileBadgeOnly ? escapeHtml(track.title) : `${escapeHtml(track.title)} ${qualityBadge}`;
|
||||||
|
if (mobileQuality) {
|
||||||
|
mobileQuality.innerHTML = useMobileBadgeOnly ? qualityBadge : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async dismissFullscreenCover({ animate = true } = {}) {
|
||||||
|
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||||
|
if (!overlay || overlay.style.display === 'none') return;
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const finish = () => {
|
||||||
|
overlay.removeEventListener('transitionend', handleTransitionEnd);
|
||||||
|
overlay.classList.remove('fullscreen-dragging', 'fullscreen-dismissing');
|
||||||
|
overlay.style.removeProperty('--fullscreen-drag-offset');
|
||||||
|
overlay.style.removeProperty('--fullscreen-drag-progress');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransitionEnd = (event) => {
|
||||||
|
if (event.target !== overlay.querySelector('.fullscreen-cover-content')) return;
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
overlay.addEventListener('transitionend', handleTransitionEnd);
|
||||||
|
overlay.classList.add('fullscreen-dismissing');
|
||||||
|
window.setTimeout(finish, 280);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeFullscreenCover();
|
||||||
|
|
||||||
|
if (window.location.hash === '#fullscreen') {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
closeFullscreenCover() {
|
closeFullscreenCover() {
|
||||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||||
const coverImage = document.getElementById('fullscreen-cover-image');
|
const coverImage = document.getElementById('fullscreen-cover-image');
|
||||||
|
|
@ -1370,18 +1495,47 @@ export class UIRenderer {
|
||||||
lyricsContent.innerHTML = '<div class="fullscreen-lyrics-empty">Lyrics appear here.</div>';
|
lyricsContent.innerHTML = '<div class="fullscreen-lyrics-empty">Lyrics appear here.</div>';
|
||||||
}
|
}
|
||||||
overlay.style.display = 'none';
|
overlay.style.display = 'none';
|
||||||
overlay.classList.remove('visualizer-active', 'ui-hidden', 'fullscreen-cover-no-round', 'fullscreen-paused');
|
overlay.classList.remove(
|
||||||
|
'visualizer-active',
|
||||||
|
'ui-hidden',
|
||||||
|
'fullscreen-cover-no-round',
|
||||||
|
'fullscreen-paused',
|
||||||
|
'fullscreen-dragging',
|
||||||
|
'fullscreen-dismissing'
|
||||||
|
);
|
||||||
|
overlay.style.removeProperty('--fullscreen-drag-offset');
|
||||||
|
overlay.style.removeProperty('--fullscreen-drag-progress');
|
||||||
|
|
||||||
const playerBar = document.querySelector('.now-playing-bar');
|
const playerBar = document.querySelector('.now-playing-bar');
|
||||||
if (playerBar) playerBar.style.removeProperty('display');
|
if (playerBar) playerBar.style.removeProperty('display');
|
||||||
const mainContent = document.querySelector('.main-content');
|
const mainContent = document.querySelector('.main-content');
|
||||||
if (mainContent instanceof HTMLElement) {
|
if (mainContent instanceof HTMLElement) {
|
||||||
if (
|
const previousOverflow = this.fullscreenMainContentOverflow;
|
||||||
typeof this.fullscreenMainContentOverflow === 'string' &&
|
if (previousOverflow && typeof previousOverflow === 'object') {
|
||||||
this.fullscreenMainContentOverflow.length > 0
|
if (previousOverflow.overflow) {
|
||||||
) {
|
mainContent.style.overflow = previousOverflow.overflow;
|
||||||
mainContent.style.overflowY = this.fullscreenMainContentOverflow;
|
} else {
|
||||||
|
mainContent.style.removeProperty('overflow');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousOverflow.overflowX) {
|
||||||
|
mainContent.style.overflowX = previousOverflow.overflowX;
|
||||||
|
} else if (previousOverflow.computedOverflowX && previousOverflow.computedOverflowX !== 'visible') {
|
||||||
|
mainContent.style.overflowX = previousOverflow.computedOverflowX;
|
||||||
|
} else {
|
||||||
|
mainContent.style.removeProperty('overflow-x');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousOverflow.overflowY) {
|
||||||
|
mainContent.style.overflowY = previousOverflow.overflowY;
|
||||||
|
} else if (previousOverflow.computedOverflowY && previousOverflow.computedOverflowY !== 'visible') {
|
||||||
|
mainContent.style.overflowY = previousOverflow.computedOverflowY;
|
||||||
|
} else {
|
||||||
|
mainContent.style.removeProperty('overflow-y');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
mainContent.style.removeProperty('overflow');
|
||||||
|
mainContent.style.removeProperty('overflow-x');
|
||||||
mainContent.style.removeProperty('overflow-y');
|
mainContent.style.removeProperty('overflow-y');
|
||||||
}
|
}
|
||||||
this.fullscreenMainContentOverflow = null;
|
this.fullscreenMainContentOverflow = null;
|
||||||
|
|
@ -1434,10 +1588,30 @@ export class UIRenderer {
|
||||||
this.fullscreenSidePanelSyncCleanup();
|
this.fullscreenSidePanelSyncCleanup();
|
||||||
this.fullscreenSidePanelSyncCleanup = null;
|
this.fullscreenSidePanelSyncCleanup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.fullscreenDismissHandleCleanup) {
|
||||||
|
this.fullscreenDismissHandleCleanup();
|
||||||
|
this.fullscreenDismissHandleCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fullscreenLyricsToggleCleanup) {
|
||||||
|
this.fullscreenLyricsToggleCleanup();
|
||||||
|
this.fullscreenLyricsToggleCleanup = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async startFullscreenVisualizer(activeElement, overlay) {
|
async startFullscreenVisualizer(activeElement, overlay) {
|
||||||
if (!activeElement) return;
|
if (!activeElement || !overlay) return false;
|
||||||
|
|
||||||
|
if (audioContextManager.isReady()) {
|
||||||
|
audioContextManager.changeSource(activeElement);
|
||||||
|
await audioContextManager.resume();
|
||||||
|
} else {
|
||||||
|
audioContextManager.init(activeElement);
|
||||||
|
if (audioContextManager.isReady()) {
|
||||||
|
await audioContextManager.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.visualizer) {
|
if (!this.visualizer) {
|
||||||
const canvas = document.getElementById('visualizer-canvas');
|
const canvas = document.getElementById('visualizer-canvas');
|
||||||
|
|
@ -1445,24 +1619,28 @@ export class UIRenderer {
|
||||||
this.visualizer = new Visualizer(canvas, activeElement);
|
this.visualizer = new Visualizer(canvas, activeElement);
|
||||||
await this.visualizer.initPresets();
|
await this.visualizer.initPresets();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.visualizer.audio = activeElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.visualizer) {
|
if (this.visualizer) {
|
||||||
await this.visualizer.start();
|
const started = await this.visualizer.start();
|
||||||
overlay.classList.add('visualizer-active');
|
overlay.classList.toggle('visualizer-active', started);
|
||||||
|
return started;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
overlay.classList.remove('visualizer-active');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureVisualizerPermission(activeElement, overlay, { closeOnCancel = false } = {}) {
|
async ensureVisualizerPermission(activeElement, overlay, { closeOnCancel = false } = {}) {
|
||||||
if (localStorage.getItem('epilepsy-warning-dismissed') === 'true') {
|
if (localStorage.getItem('epilepsy-warning-dismissed') === 'true') {
|
||||||
await this.startFullscreenVisualizer(activeElement, overlay);
|
return await this.startFullscreenVisualizer(activeElement, overlay);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const modal = document.getElementById('epilepsy-warning-modal');
|
const modal = document.getElementById('epilepsy-warning-modal');
|
||||||
if (!modal) {
|
if (!modal) {
|
||||||
await this.startFullscreenVisualizer(activeElement, overlay);
|
return await this.startFullscreenVisualizer(activeElement, overlay);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
|
|
@ -1474,8 +1652,7 @@ export class UIRenderer {
|
||||||
acceptBtn.onclick = async () => {
|
acceptBtn.onclick = async () => {
|
||||||
modal.classList.remove('active');
|
modal.classList.remove('active');
|
||||||
localStorage.setItem('epilepsy-warning-dismissed', 'true');
|
localStorage.setItem('epilepsy-warning-dismissed', 'true');
|
||||||
await this.startFullscreenVisualizer(activeElement, overlay);
|
resolve(await this.startFullscreenVisualizer(activeElement, overlay));
|
||||||
resolve(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
cancelBtn.onclick = () => {
|
cancelBtn.onclick = () => {
|
||||||
|
|
@ -1483,7 +1660,7 @@ export class UIRenderer {
|
||||||
if (closeOnCancel) {
|
if (closeOnCancel) {
|
||||||
this.closeFullscreenCover();
|
this.closeFullscreenCover();
|
||||||
}
|
}
|
||||||
resolve(false);
|
resolve(null);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1493,7 +1670,7 @@ export class UIRenderer {
|
||||||
const visualizerBtn = document.getElementById('fs-visualizer-btn');
|
const visualizerBtn = document.getElementById('fs-visualizer-btn');
|
||||||
const toggleBtn = document.getElementById('toggle-ui-btn');
|
const toggleBtn = document.getElementById('toggle-ui-btn');
|
||||||
const isVideoTrack = this.player?.currentTrack?.type === 'video';
|
const isVideoTrack = this.player?.currentTrack?.type === 'video';
|
||||||
const enabled = visualizerSettings.isEnabled() && !isVideoTrack && !this.fullscreenVisualizerSuppressed;
|
const enabled = !isVideoTrack && visualizerSettings.isEnabled() && !this.fullscreenVisualizerSuppressed;
|
||||||
|
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
|
|
||||||
|
|
@ -1518,8 +1695,10 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowed = await this.ensureVisualizerPermission(activeElement, overlay, { closeOnCancel });
|
const allowed = await this.ensureVisualizerPermission(activeElement, overlay, { closeOnCancel });
|
||||||
if (!allowed) {
|
if (allowed !== true) {
|
||||||
this.fullscreenVisualizerSuppressed = true;
|
if (allowed === null) {
|
||||||
|
this.fullscreenVisualizerSuppressed = true;
|
||||||
|
}
|
||||||
overlay.classList.remove('visualizer-active');
|
overlay.classList.remove('visualizer-active');
|
||||||
if (this.visualizer) {
|
if (this.visualizer) {
|
||||||
this.visualizer.stop();
|
this.visualizer.stop();
|
||||||
|
|
@ -1578,7 +1757,6 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.fullscreenVisualizerSuppressed = false;
|
this.fullscreenVisualizerSuppressed = false;
|
||||||
visualizerSettings.setEnabled(true);
|
|
||||||
await this.refreshFullscreenVisualizerState(this.player?.activeElement);
|
await this.refreshFullscreenVisualizerState(this.player?.activeElement);
|
||||||
|
|
||||||
if (!overlay.classList.contains('visualizer-active')) {
|
if (!overlay.classList.contains('visualizer-active')) {
|
||||||
|
|
@ -1659,6 +1837,136 @@ export class UIRenderer {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupFullscreenDismissHandle(overlay) {
|
||||||
|
if (this.fullscreenDismissHandleCleanup) {
|
||||||
|
this.fullscreenDismissHandleCleanup();
|
||||||
|
this.fullscreenDismissHandleCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = document.getElementById('fullscreen-dismiss-handle');
|
||||||
|
if (!handle) return;
|
||||||
|
|
||||||
|
let activePointerId = null;
|
||||||
|
let startY = 0;
|
||||||
|
let startX = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
let lastTimestamp = 0;
|
||||||
|
let velocityY = 0;
|
||||||
|
let hasDragged = false;
|
||||||
|
|
||||||
|
const resetDragState = () => {
|
||||||
|
activePointerId = null;
|
||||||
|
hasDragged = false;
|
||||||
|
overlay.classList.remove('fullscreen-dragging');
|
||||||
|
overlay.style.removeProperty('--fullscreen-drag-offset');
|
||||||
|
overlay.style.removeProperty('--fullscreen-drag-progress');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (event) => {
|
||||||
|
if (!isMobileFullscreenViewport()) return;
|
||||||
|
|
||||||
|
activePointerId = event.pointerId;
|
||||||
|
startY = event.clientY;
|
||||||
|
startX = event.clientX;
|
||||||
|
lastY = event.clientY;
|
||||||
|
lastTimestamp = event.timeStamp;
|
||||||
|
velocityY = 0;
|
||||||
|
hasDragged = false;
|
||||||
|
overlay.classList.add('fullscreen-dragging');
|
||||||
|
handle.setPointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (event) => {
|
||||||
|
if (event.pointerId !== activePointerId) return;
|
||||||
|
|
||||||
|
const deltaY = Math.max(0, event.clientY - startY);
|
||||||
|
const deltaX = Math.abs(event.clientX - startX);
|
||||||
|
|
||||||
|
if (!hasDragged && deltaX > deltaY) {
|
||||||
|
resetDragState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDragged = true;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const elapsed = Math.max(1, event.timeStamp - lastTimestamp);
|
||||||
|
velocityY = (event.clientY - lastY) / elapsed;
|
||||||
|
lastY = event.clientY;
|
||||||
|
lastTimestamp = event.timeStamp;
|
||||||
|
|
||||||
|
const progress = Math.min(deltaY / Math.max(window.innerHeight * 0.32, 1), 1);
|
||||||
|
overlay.style.setProperty('--fullscreen-drag-offset', `${deltaY}px`);
|
||||||
|
overlay.style.setProperty('--fullscreen-drag-progress', progress.toFixed(3));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerEnd = async (event) => {
|
||||||
|
if (event.pointerId !== activePointerId) return;
|
||||||
|
|
||||||
|
const deltaY = Math.max(0, event.clientY - startY);
|
||||||
|
const shouldDismiss = hasDragged && (deltaY > 96 || velocityY > 0.55);
|
||||||
|
|
||||||
|
if (handle.hasPointerCapture(event.pointerId)) {
|
||||||
|
handle.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldDismiss) {
|
||||||
|
await this.dismissFullscreenCover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetDragState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = async (event) => {
|
||||||
|
if (!isMobileFullscreenViewport() || hasDragged) return;
|
||||||
|
event.preventDefault();
|
||||||
|
await this.dismissFullscreenCover();
|
||||||
|
};
|
||||||
|
|
||||||
|
handle.addEventListener('pointerdown', onPointerDown);
|
||||||
|
handle.addEventListener('pointermove', onPointerMove);
|
||||||
|
handle.addEventListener('pointerup', onPointerEnd);
|
||||||
|
handle.addEventListener('pointercancel', onPointerEnd);
|
||||||
|
handle.addEventListener('click', onClick);
|
||||||
|
|
||||||
|
this.fullscreenDismissHandleCleanup = () => {
|
||||||
|
handle.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
handle.removeEventListener('pointermove', onPointerMove);
|
||||||
|
handle.removeEventListener('pointerup', onPointerEnd);
|
||||||
|
handle.removeEventListener('pointercancel', onPointerEnd);
|
||||||
|
handle.removeEventListener('click', onClick);
|
||||||
|
overlay.classList.remove('fullscreen-dragging');
|
||||||
|
overlay.style.removeProperty('--fullscreen-drag-offset');
|
||||||
|
overlay.style.removeProperty('--fullscreen-drag-progress');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFullscreenLyricsToggle(overlay) {
|
||||||
|
if (this.fullscreenLyricsToggleCleanup) {
|
||||||
|
this.fullscreenLyricsToggleCleanup();
|
||||||
|
this.fullscreenLyricsToggleCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleButtons = [
|
||||||
|
document.getElementById('toggle-fullscreen-lyrics-btn'),
|
||||||
|
document.getElementById('toggle-fullscreen-lyrics-mobile-btn'),
|
||||||
|
].filter(Boolean);
|
||||||
|
if (toggleButtons.length === 0) return;
|
||||||
|
|
||||||
|
const handleToggle = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.toggleFullscreenLyrics(overlay);
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleButtons.forEach((toggleBtn) => toggleBtn.addEventListener('click', handleToggle));
|
||||||
|
this.updateFullscreenLyricsVisibility(overlay);
|
||||||
|
|
||||||
|
this.fullscreenLyricsToggleCleanup = () => {
|
||||||
|
toggleButtons.forEach((toggleBtn) => toggleBtn.removeEventListener('click', handleToggle));
|
||||||
|
};
|
||||||
|
}
|
||||||
setupFullscreenControls() {
|
setupFullscreenControls() {
|
||||||
const playBtn = document.getElementById('fs-play-pause-btn');
|
const playBtn = document.getElementById('fs-play-pause-btn');
|
||||||
const prevBtn = document.getElementById('fs-prev-btn');
|
const prevBtn = document.getElementById('fs-prev-btn');
|
||||||
|
|
@ -1716,8 +2024,8 @@ export class UIRenderer {
|
||||||
shuffleBtn.classList.toggle('active', this.player.shuffleActive);
|
shuffleBtn.classList.toggle('active', this.player.shuffleActive);
|
||||||
};
|
};
|
||||||
|
|
||||||
repeatBtn.onclick = () => {
|
repeatBtn.onclick = async () => {
|
||||||
const mode = this.player.toggleRepeat();
|
const mode = await this.player.toggleRepeat();
|
||||||
repeatBtn.classList.toggle('active', mode !== 0);
|
repeatBtn.classList.toggle('active', mode !== 0);
|
||||||
if (mode === 2) {
|
if (mode === 2) {
|
||||||
repeatBtn.innerHTML = SVG_REPEAT_ONE(24);
|
repeatBtn.innerHTML = SVG_REPEAT_ONE(24);
|
||||||
|
|
@ -1728,16 +2036,7 @@ export class UIRenderer {
|
||||||
|
|
||||||
if (visualizerBtn) {
|
if (visualizerBtn) {
|
||||||
visualizerBtn.onclick = async () => {
|
visualizerBtn.onclick = async () => {
|
||||||
if (this.fullscreenVisualizerSuppressed) {
|
this.fullscreenVisualizerSuppressed = !this.fullscreenVisualizerSuppressed;
|
||||||
this.fullscreenVisualizerSuppressed = false;
|
|
||||||
visualizerSettings.setEnabled(true);
|
|
||||||
} else if (visualizerSettings.isEnabled()) {
|
|
||||||
visualizerSettings.setEnabled(false);
|
|
||||||
this.fullscreenVisualizerSuppressed = false;
|
|
||||||
} else {
|
|
||||||
this.fullscreenVisualizerSuppressed = false;
|
|
||||||
visualizerSettings.setEnabled(true);
|
|
||||||
}
|
|
||||||
await this.refreshFullscreenVisualizerState(this.player.activeElement);
|
await this.refreshFullscreenVisualizerState(this.player.activeElement);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -3308,7 +3607,6 @@ export class UIRenderer {
|
||||||
|
|
||||||
// Track search with results
|
// Track search with results
|
||||||
const totalResults = finalTracks.length + finalArtists.length + finalAlbums.length + finalPlaylists.length;
|
const totalResults = finalTracks.length + finalArtists.length + finalAlbums.length + finalPlaylists.length;
|
||||||
trackSearch(query, totalResults);
|
|
||||||
|
|
||||||
if (finalTracks.length) {
|
if (finalTracks.length) {
|
||||||
await this.renderListWithTracks(tracksContainer, finalTracks, true, false, false, true);
|
await this.renderListWithTracks(tracksContainer, finalTracks, true, false, false, true);
|
||||||
|
|
@ -3600,11 +3898,75 @@ export class UIRenderer {
|
||||||
async function fetchAotyWorker(album, artist) {
|
async function fetchAotyWorker(album, artist) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://aoty-critics.samidy.workers.dev/?artist=${artist}&album=${album}`
|
`https://aoty-api.hnh65483.workers.dev/?artist=${artist}&album=${album}`
|
||||||
);
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
rateCriticsEl.innerHTML = `<a href="${data.url}" target="_blank" style="color: var(--muted-foreground);">Critic Score: <span style="text-decoration: underline;">${data.critic.score}</span>, Based on ${data.critic.count} reviews</a>`;
|
const critviews = data.critic.reviews || [];
|
||||||
|
|
||||||
|
rateCriticsEl.innerHTML = `<a href="javascript:void(0)" style="color: var(--muted-foreground); cursor: pointer;">Critic Score: ${data.critic.score}, Based on <span style="text-decoration: underline;">${data.critic.count} reviews</span></a>`;
|
||||||
|
|
||||||
|
if (data.critic.score == 'NR') {
|
||||||
|
rateCriticsEl.innerHTML = `<a style="color: var(--muted-foreground);">Critic Score Not Available Yet</a>`;
|
||||||
|
} else {
|
||||||
|
rateCriticsEl.querySelector('a').onclick = () => {
|
||||||
|
const con = document.createElement('div');
|
||||||
|
con.style.display = 'flex';
|
||||||
|
con.style.flexDirection = 'column';
|
||||||
|
con.style.gap = '1.5rem';
|
||||||
|
|
||||||
|
critviews.forEach((review) => {
|
||||||
|
const reviewdiv = document.createElement('div');
|
||||||
|
reviewdiv.style.display = 'flex';
|
||||||
|
reviewdiv.style.gap = '1rem';
|
||||||
|
reviewdiv.style.paddingBottom = '1rem';
|
||||||
|
reviewdiv.style.borderBottom = '1px solid var(--border)';
|
||||||
|
|
||||||
|
const publication = decodeHtml(
|
||||||
|
review.publication || review.source || 'Unknown Publication'
|
||||||
|
);
|
||||||
|
const author = decodeHtml(review.author || '');
|
||||||
|
const quote = decodeHtml(review.text || review.quote || 'No review text available.');
|
||||||
|
|
||||||
|
reviewdiv.innerHTML = `
|
||||||
|
<img src="${review.image}" width="50" height="50" style="border-radius: 8px; object-fit: cover; background: var(--highlight);"
|
||||||
|
onerror="this.src='images/monochrome-logo.svg'; this.onerror=null;"
|
||||||
|
loading="lazy"
|
||||||
|
referrerpolicy="no-referrer">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.25rem;">
|
||||||
|
<div class="pub-name" style="font-weight: 600; color: var(--foreground);"></div>
|
||||||
|
<div style="font-weight: bold; color: var(--primary-foreground); background: var(--primary); padding: 2px 10px; border-radius: 6px; font-size: 0.85rem; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${review.score}</div>
|
||||||
|
</div>
|
||||||
|
<div class="author-name" style="font-size: 0.8rem; color: var(--muted-foreground); margin-bottom: 0.5rem;"></div>
|
||||||
|
<div class="quote-text" style="font-size: 0.95rem; line-height: 1.5; color: var(--muted-foreground); font-style: italic;"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
reviewdiv.querySelector('.pub-name').textContent = publication;
|
||||||
|
if (author) {
|
||||||
|
reviewdiv.querySelector('.author-name').textContent = `By ${author}`;
|
||||||
|
} else {
|
||||||
|
reviewdiv.querySelector('.author-name').remove();
|
||||||
|
}
|
||||||
|
reviewdiv.querySelector('.quote-text').textContent = `"${quote}"`;
|
||||||
|
|
||||||
|
con.appendChild(reviewdiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (critviews.length === 0) {
|
||||||
|
con.innerHTML =
|
||||||
|
'<div style="text-align: center; padding: 2rem; color: var(--muted-foreground);">No reviews found.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
createModal({
|
||||||
|
title: 'Critics Reviews',
|
||||||
|
content: con,
|
||||||
|
className: 'extra-wide',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
rateUsersEl.innerHTML = `<a href="${data.url}" target="_blank" style="color: var(--muted-foreground);">User Score: <span style="text-decoration: underline;">${data.user.score}</span>, Based on ${data.user.count} reviews</a>`;
|
rateUsersEl.innerHTML = `<a href="${data.url}" target="_blank" style="color: var(--muted-foreground);">User Score: <span style="text-decoration: underline;">${data.user.score}</span>, Based on ${data.user.count} reviews</a>`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
rateCriticsEl.innerHTML = `<a style="color: var(--muted-foreground);">Unable to Fetch Critic Score</a>`;
|
rateCriticsEl.innerHTML = `<a style="color: var(--muted-foreground);">Unable to Fetch Critic Score</a>`;
|
||||||
|
|
@ -5310,7 +5672,6 @@ export class UIRenderer {
|
||||||
const handleSort = async (ev) => {
|
const handleSort = async (ev) => {
|
||||||
const li = ev.target.closest('li');
|
const li = ev.target.closest('li');
|
||||||
if (li && li.dataset.sort) {
|
if (li && li.dataset.sort) {
|
||||||
trackChangeSort(li.dataset.sort);
|
|
||||||
await onSort(li.dataset.sort);
|
await onSort(li.dataset.sort);
|
||||||
closeMenu();
|
closeMenu();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
js/utils.js
43
js/utils.js
|
|
@ -381,6 +381,13 @@ export const escapeHtml = (unsafe) => {
|
||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const decodeHtml = (html) => {
|
||||||
|
if (!html) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = html;
|
||||||
|
return div.textContent;
|
||||||
|
};
|
||||||
|
|
||||||
export const getTrackTitle = (track, { fallback = 'Unknown Title' } = {}) => {
|
export const getTrackTitle = (track, { fallback = 'Unknown Title' } = {}) => {
|
||||||
if (!track?.title) return fallback;
|
if (!track?.title) return fallback;
|
||||||
return track?.version ? `${track.title} (${track.version})` : track.title;
|
return track?.version ? `${track.title} (${track.version})` : track.title;
|
||||||
|
|
@ -778,3 +785,39 @@ export function replaceTokens(template, tokens) {
|
||||||
return key in tokens ? tokens[key] : match;
|
return key in tokens ? tokens[key] : match;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createModal({ title, content, className = '', onClose }) {
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal active';
|
||||||
|
modal.style.zIndex = '10000';
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content ${className}" style="display: flex; flex-direction: column;">
|
||||||
|
<div class="modal-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem;">
|
||||||
|
<h3 style="margin: 0;">${title}</h3>
|
||||||
|
<button class="btn-close" style="background: none; border: none; font-size: 2rem; cursor: pointer; color: var(--foreground); padding: 0.2rem 0.5rem; line-height: 1;">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="max-height: 70vh; overflow-y: auto; padding-right: 0.5rem;"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const body = modal.querySelector('.modal-body');
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
body.innerHTML = content;
|
||||||
|
} else if (content instanceof HTMLElement) {
|
||||||
|
body.appendChild(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
modal.remove();
|
||||||
|
if (onClose) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.querySelector('.modal-overlay').onclick = close;
|
||||||
|
modal.querySelector('.btn-close').onclick = close;
|
||||||
|
|
||||||
|
return { modal, close };
|
||||||
|
}
|
||||||
|
|
|
||||||
144
js/visualizer.js
144
js/visualizer.js
|
|
@ -37,7 +37,7 @@ export class Visualizer {
|
||||||
|
|
||||||
// Pause animation loop when the app is backgrounded so the analyser's
|
// Pause animation loop when the app is backgrounded so the analyser's
|
||||||
// FFT reads don't compete with the EQ biquad filter chain for audio
|
// FFT reads don't compete with the EQ biquad filter chain for audio
|
||||||
// thread time — the main cause of audio skipping with AutoEQ in background.
|
// thread time - the main cause of audio skipping with AutoEQ in background.
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden' && this.isActive) {
|
if (document.visibilityState === 'hidden' && this.isActive) {
|
||||||
this._backgroundPaused = true;
|
this._backgroundPaused = true;
|
||||||
|
|
@ -86,8 +86,8 @@ export class Visualizer {
|
||||||
this.audioContext = audioContextManager.getAudioContext();
|
this.audioContext = audioContextManager.getAudioContext();
|
||||||
this.analyser = audioContextManager.getAnalyser();
|
this.analyser = audioContextManager.getAnalyser();
|
||||||
|
|
||||||
if (this.analyser) {
|
this.bufferLength = this.analyser?.frequencyBinCount || 512;
|
||||||
this.bufferLength = this.analyser.frequencyBinCount;
|
if (!this.dataArray || this.dataArray.length !== this.bufferLength) {
|
||||||
this.dataArray = new Uint8Array(this.bufferLength);
|
this.dataArray = new Uint8Array(this.bufferLength);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -153,23 +153,24 @@ export class Visualizer {
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
if (this.isActive) return;
|
if (this.isActive) return true;
|
||||||
|
|
||||||
if (!this.ctx) {
|
if (!this.ctx) {
|
||||||
this.initContext();
|
this.initContext();
|
||||||
}
|
}
|
||||||
if (!this.audioContext) {
|
if (!this.audioContext && !this.analyser) {
|
||||||
await this.init();
|
await this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.analyser) {
|
const canRunWithoutAnalyser = !!this.activePreset?.managesOwnContext;
|
||||||
return;
|
if (!this.analyser && !canRunWithoutAnalyser) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isActive = true;
|
this.isActive = true;
|
||||||
|
|
||||||
if (this.audioContext.state === 'suspended') {
|
if (this.audioContext?.state === 'suspended') {
|
||||||
this.audioContext.resume();
|
await this.audioContext.resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateDimming();
|
this.updateDimming();
|
||||||
|
|
@ -182,12 +183,19 @@ export class Visualizer {
|
||||||
// Initialize presets that need lazy init (Butterchurn, Kawarp)
|
// Initialize presets that need lazy init (Butterchurn, Kawarp)
|
||||||
if (this.activePreset.lazyInit) {
|
if (this.activePreset.lazyInit) {
|
||||||
const sourceNode = audioContextManager.getSourceNode();
|
const sourceNode = audioContextManager.getSourceNode();
|
||||||
this.activePreset.lazyInit(this.canvas, this.audioContext, sourceNode).then(() => {
|
await this.activePreset.lazyInit(this.canvas, this.audioContext, sourceNode);
|
||||||
this.resize();
|
this.resize();
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (this.activePreset.managesOwnContext && this.activePreset.isInitialized === false) {
|
||||||
|
this.isActive = false;
|
||||||
|
this.canvas.style.display = 'none';
|
||||||
|
window.removeEventListener('resize', this._resizeBound);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.animate();
|
this.animate();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
|
|
@ -223,57 +231,48 @@ export class Visualizer {
|
||||||
if (!this.isActive) return;
|
if (!this.isActive) return;
|
||||||
this.animationId = requestAnimationFrame(this.animate);
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
// ===== AUDIO ANALYSIS =====
|
|
||||||
this.analyser.getByteFrequencyData(this.dataArray);
|
|
||||||
|
|
||||||
// Bass (dynamic bins based on sample rate)
|
|
||||||
const volume = 10 * Math.max(this.audio.volume, 0.1);
|
|
||||||
|
|
||||||
// Robust bass detection: sum bins up to ~250Hz
|
|
||||||
const binSize = this.audioContext.sampleRate / this.analyser.fftSize;
|
|
||||||
const startBin = 1; // Skip DC offset
|
|
||||||
// Calculate how many bins cover the bass range (up to 250Hz)
|
|
||||||
let numBins = Math.floor(250 / binSize);
|
|
||||||
if (numBins < 1) numBins = 1; // Ensure at least one bin is checked
|
|
||||||
|
|
||||||
let maxVal = 0;
|
|
||||||
for (let i = 0; i < numBins && startBin + i < this.dataArray.length; i++) {
|
|
||||||
const val = this.dataArray[startBin + i];
|
|
||||||
if (val > maxVal) maxVal = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize: (Max / 255) / Volume
|
|
||||||
let bass = maxVal / 255 / volume;
|
|
||||||
|
|
||||||
const intensity = bass * bass * 10;
|
|
||||||
const stats = this.stats;
|
const stats = this.stats;
|
||||||
|
|
||||||
stats.energyAverage = stats.energyAverage * 0.99 + intensity * 0.01;
|
if (this.analyser && this.dataArray && this.audioContext) {
|
||||||
stats.upbeatSmoother = stats.upbeatSmoother * 0.92 + intensity * 0.08;
|
this.analyser.getByteFrequencyData(this.dataArray);
|
||||||
|
|
||||||
// ===== SENSITIVITY =====
|
const volume = 10 * Math.max(this.audio.volume, 0.1);
|
||||||
let sensitivity = visualizerSettings.getSensitivity();
|
const binSize = this.audioContext.sampleRate / this.analyser.fftSize;
|
||||||
if (visualizerSettings.isSmartIntensityEnabled()) {
|
const startBin = 1;
|
||||||
if (stats.energyAverage > 0.4) {
|
let numBins = Math.floor(250 / binSize);
|
||||||
sensitivity = 0.7;
|
if (numBins < 1) numBins = 1;
|
||||||
} else if (stats.energyAverage > 0.2) {
|
|
||||||
sensitivity = 0.1 + ((stats.energyAverage - 0.2) / 0.2) * 0.6;
|
let maxVal = 0;
|
||||||
} else {
|
for (let i = 0; i < numBins && startBin + i < this.dataArray.length; i++) {
|
||||||
sensitivity = 0.1;
|
const val = this.dataArray[startBin + i];
|
||||||
|
if (val > maxVal) maxVal = val;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ===== KICK DETECTION =====
|
const bass = maxVal / 255 / volume;
|
||||||
const now = performance.now();
|
const intensity = bass * bass * 10;
|
||||||
let threshold = stats.energyAverage < 0.3 ? 0.5 + (0.3 - stats.energyAverage) * 2 : 0.5;
|
|
||||||
|
|
||||||
// Lower threshold for more responsive kick
|
stats.energyAverage = stats.energyAverage * 0.99 + intensity * 0.01;
|
||||||
if (intensity > threshold * 0.7) {
|
stats.upbeatSmoother = stats.upbeatSmoother * 0.92 + intensity * 0.08;
|
||||||
if (intensity > stats.lastIntensity + 0.03 && now - stats.lastBeatTime > 50) {
|
|
||||||
stats.kick = 1.0;
|
let sensitivity = visualizerSettings.getSensitivity();
|
||||||
stats.lastBeatTime = now;
|
if (visualizerSettings.isSmartIntensityEnabled()) {
|
||||||
} else {
|
if (stats.energyAverage > 0.4) {
|
||||||
if (stats.upbeatSmoother > 0.6 && stats.energyAverage > 0.4) {
|
sensitivity = 0.7;
|
||||||
|
} else if (stats.energyAverage > 0.2) {
|
||||||
|
sensitivity = 0.1 + ((stats.energyAverage - 0.2) / 0.2) * 0.6;
|
||||||
|
} else {
|
||||||
|
sensitivity = 0.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
const threshold = stats.energyAverage < 0.3 ? 0.5 + (0.3 - stats.energyAverage) * 2 : 0.5;
|
||||||
|
|
||||||
|
if (intensity > threshold * 0.7) {
|
||||||
|
if (intensity > stats.lastIntensity + 0.03 && now - stats.lastBeatTime > 50) {
|
||||||
|
stats.kick = 1.0;
|
||||||
|
stats.lastBeatTime = now;
|
||||||
|
} else if (stats.upbeatSmoother > 0.6 && stats.energyAverage > 0.4) {
|
||||||
const upbeatLevel = (stats.upbeatSmoother - 0.6) / 0.4;
|
const upbeatLevel = (stats.upbeatSmoother - 0.6) / 0.4;
|
||||||
if (stats.kick < upbeatLevel) {
|
if (stats.kick < upbeatLevel) {
|
||||||
stats.kick = upbeatLevel;
|
stats.kick = upbeatLevel;
|
||||||
|
|
@ -283,14 +282,21 @@ export class Visualizer {
|
||||||
} else {
|
} else {
|
||||||
stats.kick *= 0.9;
|
stats.kick *= 0.9;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
stats.kick *= 0.95;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
stats.kick *= 0.95;
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.lastIntensity = intensity;
|
stats.lastIntensity = intensity;
|
||||||
stats.intensity = intensity;
|
stats.intensity = intensity;
|
||||||
stats.sensitivity = sensitivity;
|
stats.sensitivity = sensitivity;
|
||||||
|
} else {
|
||||||
|
stats.kick *= 0.92;
|
||||||
|
stats.intensity *= 0.92;
|
||||||
|
stats.energyAverage *= 0.98;
|
||||||
|
stats.upbeatSmoother *= 0.95;
|
||||||
|
stats.sensitivity = visualizerSettings.getSensitivity();
|
||||||
|
this.dataArray?.fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== COLORS (CACHED) =====
|
// ===== COLORS (CACHED) =====
|
||||||
const color = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#ffffff';
|
const color = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#ffffff';
|
||||||
|
|
@ -337,4 +343,16 @@ export class Visualizer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyPresetOverride(key) {
|
||||||
|
if (!this.presets?.[key] || this.activePresetKey === key) return;
|
||||||
|
|
||||||
|
if (this.activePreset?.destroy) {
|
||||||
|
this.activePreset.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._currentContextType = undefined;
|
||||||
|
this.ctx = null;
|
||||||
|
this.activePresetKey = key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
legacy.html
16
legacy.html
|
|
@ -1,16 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" href="favicon.ico" type="image/x-icon" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Redirecting...</title>
|
|
||||||
<meta http-equiv="refresh" content="0; URL='https://legacy.monochrome.tf'" />
|
|
||||||
<script>
|
|
||||||
window.location.href = 'https://legacy.monochrome.tf';
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p>If you are not redirected, <a href="https://legacy.monochrome.tf">click here</a>.</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "monochrome",
|
"name": "monochrome",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.5.0",
|
"version": "2.5.1",
|
||||||
"description": "[<img src=\"https://github.com/monochrome-music/monochrome/blob/main/assets/512.png?raw=true\" alt=\"Monochrome Logo\">](https://monochrome.tf)",
|
"description": "[<img src=\"https://github.com/monochrome-music/monochrome/blob/main/assets/512.png?raw=true\" alt=\"Monochrome Logo\">](https://monochrome.tf)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,7 @@ album:509761344
|
||||||
album:15621057
|
album:15621057
|
||||||
album:103897783
|
album:103897783
|
||||||
album:151728406
|
album:151728406
|
||||||
album:199412873
|
album:199412873
|
||||||
|
album:3280432
|
||||||
|
album:37927851
|
||||||
|
album:18083938
|
||||||
|
|
@ -1,313 +1 @@
|
||||||
[
|
[]
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 324660713,
|
|
||||||
"title": "JOECHILLWORLD",
|
|
||||||
"artist": {
|
|
||||||
"id": 40978758,
|
|
||||||
"name": "Devon Hendryx"
|
|
||||||
},
|
|
||||||
"releaseDate": "2010-07-10",
|
|
||||||
"cover": "25d45544-3e82-4184-b8c2-2c2c6f0f152a",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS",
|
|
||||||
"HIRES_LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 15427733,
|
|
||||||
"title": "Mysterious Phonk: The Chronicles of SpaceGhostPurrp",
|
|
||||||
"artist": {
|
|
||||||
"id": 4611745,
|
|
||||||
"name": "Spaceghostpurrp"
|
|
||||||
},
|
|
||||||
"releaseDate": "2012-06-12",
|
|
||||||
"cover": "c78b7543-1cd8-4921-9155-e81d421353a0",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 464178301,
|
|
||||||
"title": "Never Forget",
|
|
||||||
"artist": {
|
|
||||||
"id": 5516508,
|
|
||||||
"name": "Chris Travis"
|
|
||||||
},
|
|
||||||
"releaseDate": "2014-05-14",
|
|
||||||
"cover": "4ab11f0d-0768-4cce-8de5-1894134d5994",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 75115890,
|
|
||||||
"title": "Blood Shore Season 2",
|
|
||||||
"artist": {
|
|
||||||
"id": 6332342,
|
|
||||||
"name": "Xavier Wulf"
|
|
||||||
},
|
|
||||||
"releaseDate": "2014-10-30",
|
|
||||||
"cover": "517303e5-d541-4704-b552-026427e05fcb",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 410197513,
|
|
||||||
"title": "THE PEAK",
|
|
||||||
"artist": {
|
|
||||||
"id": 33481052,
|
|
||||||
"name": "smokedope2016"
|
|
||||||
},
|
|
||||||
"releaseDate": "2025-01-17",
|
|
||||||
"cover": "ea18084d-36ec-4cea-98a7-fe4684246986",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 418729278,
|
|
||||||
"title": "I LAY DOWN MY LIFE FOR YOU: DIRECTOR'S CUT",
|
|
||||||
"artist": {
|
|
||||||
"id": 7958797,
|
|
||||||
"name": "JPEGMAFIA"
|
|
||||||
},
|
|
||||||
"releaseDate": "2025-02-03",
|
|
||||||
"cover": "9c84302b-2584-4c0a-9db7-e648542f459f",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS",
|
|
||||||
"HIRES_LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 504004321,
|
|
||||||
"title": "Half Blood (BloodLuxe)",
|
|
||||||
"artist": {
|
|
||||||
"id": 50799233,
|
|
||||||
"name": "slayr"
|
|
||||||
},
|
|
||||||
"releaseDate": "2025-11-05",
|
|
||||||
"cover": "2767cc63-7e92-4a48-aa4b-806a3ea7ec1c",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS",
|
|
||||||
"HIRES_LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 510893864,
|
|
||||||
"title": "BULLY",
|
|
||||||
"artist": {
|
|
||||||
"id": 25022,
|
|
||||||
"name": "Kanye West"
|
|
||||||
},
|
|
||||||
"releaseDate": "2026-03-28",
|
|
||||||
"cover": "cf2f2c9c-ff67-44f6-83aa-a7622f8c6b64",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS",
|
|
||||||
"HIRES_LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 325723583,
|
|
||||||
"title": "Replica",
|
|
||||||
"artist": {
|
|
||||||
"id": 3715530,
|
|
||||||
"name": "Oneohtrix Point Never"
|
|
||||||
},
|
|
||||||
"releaseDate": "2011-11-05",
|
|
||||||
"cover": "95ceeae9-cac7-42dc-ae37-7c93c223f809",
|
|
||||||
"explicit": false,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 336178142,
|
|
||||||
"title": "Pirate This Album",
|
|
||||||
"artist": {
|
|
||||||
"id": 8622751,
|
|
||||||
"name": "Shamana"
|
|
||||||
},
|
|
||||||
"releaseDate": "2023-12-25",
|
|
||||||
"cover": "a8a647be-0331-4779-9a6e-31645a9abdab",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS",
|
|
||||||
"HIRES_LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 106369871,
|
|
||||||
"title": "Organic Thoughts from the Synthetic Mind",
|
|
||||||
"artist": {
|
|
||||||
"id": 6436013,
|
|
||||||
"name": "Shinjuku Mad"
|
|
||||||
},
|
|
||||||
"releaseDate": "2009-07-01",
|
|
||||||
"cover": "3acc888e-35da-40a8-a4b7-7ffd00576cc9",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 423471869,
|
|
||||||
"title": "pain",
|
|
||||||
"artist": {
|
|
||||||
"id": 44257324,
|
|
||||||
"name": "bleood"
|
|
||||||
},
|
|
||||||
"releaseDate": "2025-03-11",
|
|
||||||
"cover": "711b23ba-c473-44e6-a2f0-010fefa9c5b8",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 250986538,
|
|
||||||
"title": "Revolutionary, Vol. 1 (Bonus Edition)",
|
|
||||||
"artist": {
|
|
||||||
"id": 3604583,
|
|
||||||
"name": "Immortal Technique"
|
|
||||||
},
|
|
||||||
"releaseDate": "2001-09-14",
|
|
||||||
"cover": "e510dd6d-dcdf-4272-9c68-f4580f2fbd14",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 509761344,
|
|
||||||
"title": "EMOTIONS",
|
|
||||||
"artist": {
|
|
||||||
"id": 49124576,
|
|
||||||
"name": "Nine Vicious"
|
|
||||||
},
|
|
||||||
"releaseDate": "2026-04-03",
|
|
||||||
"cover": "f29b18d3-b19f-45b1-968a-0ad360647130",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 15621057,
|
|
||||||
"title": "Triple F Life: Friends, Fans & Family (Deluxe Version)",
|
|
||||||
"artist": {
|
|
||||||
"id": 3654061,
|
|
||||||
"name": "Waka Flocka Flame"
|
|
||||||
},
|
|
||||||
"releaseDate": "2012-06-12",
|
|
||||||
"cover": "3199b7de-5e3d-486c-acf1-870ff4c60572",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 103897783,
|
|
||||||
"title": "Freewave 3",
|
|
||||||
"artist": {
|
|
||||||
"id": 7923685,
|
|
||||||
"name": "Lucki"
|
|
||||||
},
|
|
||||||
"releaseDate": "2019-02-15",
|
|
||||||
"cover": "1d481a33-8b20-4ee3-b04b-5ac6e0fc5e78",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"id": 151728406,
|
|
||||||
"title": "Niagara",
|
|
||||||
"artist": {
|
|
||||||
"id": 7607680,
|
|
||||||
"name": "redveil"
|
|
||||||
},
|
|
||||||
"releaseDate": "2020-08-25",
|
|
||||||
"cover": "14690142-7fc8-4557-8a61-0721b7884822",
|
|
||||||
"explicit": true,
|
|
||||||
"audioQuality": "LOSSLESS",
|
|
||||||
"mediaMetadata": {
|
|
||||||
"tags": [
|
|
||||||
"LOSSLESS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,16 @@
|
||||||
"label": "Spring 2026",
|
"label": "Spring 2026",
|
||||||
"date": "2026-04-05"
|
"date": "2026-04-05"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"file": "2026-4-5.json",
|
||||||
|
"label": "Spring 2026",
|
||||||
|
"date": "2026-04-05"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "2026-4-5.json",
|
||||||
|
"label": "Spring 2026",
|
||||||
|
"date": "2026-04-05"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "2026-4-3.json",
|
"file": "2026-4-3.json",
|
||||||
"label": "Spring 2026",
|
"label": "Spring 2026",
|
||||||
|
|
@ -44,4 +54,4 @@
|
||||||
"label": "April Fools '26",
|
"label": "April Fools '26",
|
||||||
"date": "2026-04-01"
|
"date": "2026-04-01"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1 +1,322 @@
|
||||||
[]
|
[
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 324660713,
|
||||||
|
"title": "JOECHILLWORLD",
|
||||||
|
"artist": {
|
||||||
|
"id": 40978758,
|
||||||
|
"name": "Devon Hendryx"
|
||||||
|
},
|
||||||
|
"releaseDate": "2010-07-10",
|
||||||
|
"cover": "25d45544-3e82-4184-b8c2-2c2c6f0f152a",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 15427733,
|
||||||
|
"title": "Mysterious Phonk: The Chronicles of SpaceGhostPurrp",
|
||||||
|
"artist": {
|
||||||
|
"id": 4611745,
|
||||||
|
"name": "Spaceghostpurrp"
|
||||||
|
},
|
||||||
|
"releaseDate": "2012-06-12",
|
||||||
|
"cover": "c78b7543-1cd8-4921-9155-e81d421353a0",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 464178301,
|
||||||
|
"title": "Never Forget",
|
||||||
|
"artist": {
|
||||||
|
"id": 5516508,
|
||||||
|
"name": "Chris Travis"
|
||||||
|
},
|
||||||
|
"releaseDate": "2014-05-14",
|
||||||
|
"cover": "4ab11f0d-0768-4cce-8de5-1894134d5994",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 75115890,
|
||||||
|
"title": "Blood Shore Season 2",
|
||||||
|
"artist": {
|
||||||
|
"id": 6332342,
|
||||||
|
"name": "Xavier Wulf"
|
||||||
|
},
|
||||||
|
"releaseDate": "2014-10-30",
|
||||||
|
"cover": "517303e5-d541-4704-b552-026427e05fcb",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 410197513,
|
||||||
|
"title": "THE PEAK",
|
||||||
|
"artist": {
|
||||||
|
"id": 33481052,
|
||||||
|
"name": "smokedope2016"
|
||||||
|
},
|
||||||
|
"releaseDate": "2025-01-17",
|
||||||
|
"cover": "ea18084d-36ec-4cea-98a7-fe4684246986",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 418729278,
|
||||||
|
"title": "I LAY DOWN MY LIFE FOR YOU: DIRECTOR'S CUT",
|
||||||
|
"artist": {
|
||||||
|
"id": 7958797,
|
||||||
|
"name": "JPEGMAFIA"
|
||||||
|
},
|
||||||
|
"releaseDate": "2025-02-03",
|
||||||
|
"cover": "9c84302b-2584-4c0a-9db7-e648542f459f",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 504004321,
|
||||||
|
"title": "Half Blood (BloodLuxe)",
|
||||||
|
"artist": {
|
||||||
|
"id": 50799233,
|
||||||
|
"name": "slayr"
|
||||||
|
},
|
||||||
|
"releaseDate": "2025-11-05",
|
||||||
|
"cover": "2767cc63-7e92-4a48-aa4b-806a3ea7ec1c",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 510893864,
|
||||||
|
"title": "BULLY",
|
||||||
|
"artist": {
|
||||||
|
"id": 25022,
|
||||||
|
"name": "Kanye West"
|
||||||
|
},
|
||||||
|
"releaseDate": "2026-03-28",
|
||||||
|
"cover": "cf2f2c9c-ff67-44f6-83aa-a7622f8c6b64",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 325723583,
|
||||||
|
"title": "Replica",
|
||||||
|
"artist": {
|
||||||
|
"id": 3715530,
|
||||||
|
"name": "Oneohtrix Point Never"
|
||||||
|
},
|
||||||
|
"releaseDate": "2011-11-05",
|
||||||
|
"cover": "95ceeae9-cac7-42dc-ae37-7c93c223f809",
|
||||||
|
"explicit": false,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 336178142,
|
||||||
|
"title": "Pirate This Album",
|
||||||
|
"artist": {
|
||||||
|
"id": 8622751,
|
||||||
|
"name": "Shamana"
|
||||||
|
},
|
||||||
|
"releaseDate": "2023-12-25",
|
||||||
|
"cover": "a8a647be-0331-4779-9a6e-31645a9abdab",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 106369871,
|
||||||
|
"title": "Organic Thoughts from the Synthetic Mind",
|
||||||
|
"artist": {
|
||||||
|
"id": 6436013,
|
||||||
|
"name": "Shinjuku Mad"
|
||||||
|
},
|
||||||
|
"releaseDate": "2009-07-01",
|
||||||
|
"cover": "3acc888e-35da-40a8-a4b7-7ffd00576cc9",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 423471869,
|
||||||
|
"title": "pain",
|
||||||
|
"artist": {
|
||||||
|
"id": 44257324,
|
||||||
|
"name": "bleood"
|
||||||
|
},
|
||||||
|
"releaseDate": "2025-03-11",
|
||||||
|
"cover": "711b23ba-c473-44e6-a2f0-010fefa9c5b8",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 250986538,
|
||||||
|
"title": "Revolutionary, Vol. 1 (Bonus Edition)",
|
||||||
|
"artist": {
|
||||||
|
"id": 3604583,
|
||||||
|
"name": "Immortal Technique"
|
||||||
|
},
|
||||||
|
"releaseDate": "2001-09-14",
|
||||||
|
"cover": "e510dd6d-dcdf-4272-9c68-f4580f2fbd14",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 509761344,
|
||||||
|
"title": "EMOTIONS",
|
||||||
|
"artist": {
|
||||||
|
"id": 49124576,
|
||||||
|
"name": "Nine Vicious"
|
||||||
|
},
|
||||||
|
"releaseDate": "2026-04-03",
|
||||||
|
"cover": "f29b18d3-b19f-45b1-968a-0ad360647130",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 15621057,
|
||||||
|
"title": "Triple F Life: Friends, Fans & Family (Deluxe Version)",
|
||||||
|
"artist": {
|
||||||
|
"id": 3654061,
|
||||||
|
"name": "Waka Flocka Flame"
|
||||||
|
},
|
||||||
|
"releaseDate": "2012-06-12",
|
||||||
|
"cover": "3199b7de-5e3d-486c-acf1-870ff4c60572",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 103897783,
|
||||||
|
"title": "Freewave 3",
|
||||||
|
"artist": {
|
||||||
|
"id": 7923685,
|
||||||
|
"name": "Lucki"
|
||||||
|
},
|
||||||
|
"releaseDate": "2019-02-15",
|
||||||
|
"cover": "1d481a33-8b20-4ee3-b04b-5ac6e0fc5e78",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 151728406,
|
||||||
|
"title": "Niagara",
|
||||||
|
"artist": {
|
||||||
|
"id": 7607680,
|
||||||
|
"name": "redveil"
|
||||||
|
},
|
||||||
|
"releaseDate": "2020-08-25",
|
||||||
|
"cover": "14690142-7fc8-4557-8a61-0721b7884822",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 199412873,
|
||||||
|
"title": "Tha Carter III",
|
||||||
|
"artist": {
|
||||||
|
"id": 27518,
|
||||||
|
"name": "Lil Wayne"
|
||||||
|
},
|
||||||
|
"releaseDate": "2008-06-10",
|
||||||
|
"cover": "797a90ea-3860-4d02-ac85-39b34ca8ee25",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOW",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["DOLBY_ATMOS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 3280432,
|
||||||
|
"title": "We Are Young Money",
|
||||||
|
"artist": {
|
||||||
|
"id": 3654487,
|
||||||
|
"name": "Young Money"
|
||||||
|
},
|
||||||
|
"releaseDate": "2009-12-21",
|
||||||
|
"cover": "5b1456e5-1bba-415b-8276-8bc9cd211687",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": 37927851,
|
||||||
|
"title": "The DeAndre Way (Deluxe)",
|
||||||
|
"artist": {
|
||||||
|
"id": 3820209,
|
||||||
|
"name": "Soulja Boy"
|
||||||
|
},
|
||||||
|
"releaseDate": "2010-11-30",
|
||||||
|
"cover": "6ca0217d-4f74-47d2-b449-30144d91e41f",
|
||||||
|
"explicit": false,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": {
|
||||||
|
"tags": ["LOSSLESS"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
|
||||||
866
styles.css
866
styles.css
File diff suppressed because it is too large
Load diff
|
|
@ -83,10 +83,19 @@ export default defineConfig((_options) => {
|
||||||
purgecss({
|
purgecss({
|
||||||
variables: false, // DO NOT REMOVE UNUSED VARIABLES (breaks web components like am-lyrics)
|
variables: false, // DO NOT REMOVE UNUSED VARIABLES (breaks web components like am-lyrics)
|
||||||
safelist: {
|
safelist: {
|
||||||
standard: [/^am-lyrics/, /^lyplus-/, 'sidepanel', 'side-panel', 'active', 'show', /^data-/, /^modal-/],
|
standard: [
|
||||||
|
/^am-lyrics/,
|
||||||
|
/^lyplus-/,
|
||||||
|
'sidepanel',
|
||||||
|
'side-panel',
|
||||||
|
'active',
|
||||||
|
'show',
|
||||||
|
/^data-/,
|
||||||
|
/^modal-/,
|
||||||
|
],
|
||||||
deep: [/^am-lyrics/],
|
deep: [/^am-lyrics/],
|
||||||
greedy: [/^lyplus-/, /sidepanel/, /side-panel/]
|
greedy: [/^lyplus-/, /sidepanel/, /side-panel/],
|
||||||
}
|
},
|
||||||
}),
|
}),
|
||||||
authGatePlugin(),
|
authGatePlugin(),
|
||||||
uploadPlugin(),
|
uploadPlugin(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue