Merge upstream/main into fullscreen-apple

This commit is contained in:
Alan Brooks 2026-04-05 22:47:56 -04:00
commit 8a567ab816
54 changed files with 3808 additions and 454 deletions

View file

@ -20,7 +20,6 @@ jobs:
# Copilot will be given its own token for its operations.
permissions:
contents: read
workflows: write
steps:
- name: Checkout code

View file

@ -37,10 +37,6 @@ jobs:
with:
python-version: '3.x'
- name: Install webp
if: steps.backoff.outputs.skip == 'false'
run: sudo apt-get update && sudo apt-get install -y webp
- name: Archive current editors picks
if: steps.backoff.outputs.skip == 'false'
run: |
@ -68,14 +64,19 @@ jobs:
with open("public/editors-picks.json") as f:
current = json.load(f)
# Replace cover URLs with original UUIDs
url_pattern = re.compile(r'^https://monochrome\.tf/editors-picks-images/([a-f0-9-]+)\.webp$')
# Replace cover URLs with original UUIDs (handles both legacy and intermediate formats)
url_pattern1 = re.compile(r'^https://monochrome\.tf/editors-picks-images/([a-f0-9-]+)\.webp$')
url_pattern2 = re.compile(r'^https://wsrv\.nl/\?url=https://resources\.tidal\.com/images/([a-f0-9/]+)/320x320\.jpg&w=250&h=250&output=webp$')
for item in current:
for field in ['cover', 'picture', 'image']:
if field in item and item[field]:
match = url_pattern.match(item[field])
if match:
item[field] = match.group(1)
m1 = url_pattern1.match(item[field])
if m1:
item[field] = m1.group(1)
continue
m2 = url_pattern2.match(item[field])
if m2:
item[field] = m2.group(1).replace("/", "-")
with open(archive_path, "w") as f:
json.dump(current, f, indent=4)
@ -103,7 +104,8 @@ jobs:
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add public/editors-picks.json public/editors-picks-old/ public/editors-picks-images/
git pull --rebase origin main
git add public/editors-picks.json public/editors-picks-old/
git diff --staged --quiet && echo "No changes to commit." && exit 0
git commit -m "chore: update editors picks"
git push

43
.github/workflows/lighthouse.yml vendored Normal file
View 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/

View file

@ -20,6 +20,11 @@
</intent-filter>
</activity>
<service
android:name=".AudioPlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@ -32,4 +37,7 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
</manifest>

View file

@ -0,0 +1,129 @@
package tf.monochrome.music;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.os.PowerManager;
import androidx.core.app.NotificationCompat;
/**
* Foreground service that keeps the app process alive while audio is playing
* in the background. Without this, Android will throttle the WebView and
* suspend Web Audio API processing, causing audible skips and dropouts.
*/
public class AudioPlaybackService extends Service {
private static final String CHANNEL_ID = "audio_playback";
private static final int NOTIFICATION_ID = 1;
private PowerManager.WakeLock wakeLock;
@Override
public void onCreate() {
super.onCreate();
createNotificationChannel();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && "STOP".equals(intent.getAction())) {
stopSelf();
return START_NOT_STICKY;
}
Notification notification = buildNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
startForeground(NOTIFICATION_ID, notification);
}
acquireWakeLock();
// If the system kills this service, don't restart it automatically
// MainActivity will re-start it when audio resumes.
return START_NOT_STICKY;
}
@Override
public void onDestroy() {
releaseWakeLock();
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"Audio Playback",
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("Keeps audio playing in the background");
channel.setSound(null, null);
channel.setShowBadge(false);
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager != null) {
manager.createNotificationChannel(channel);
}
}
private Notification buildNotification() {
Intent launchIntent = new Intent(this, MainActivity.class);
launchIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
PendingIntent pendingIntent = PendingIntent.getActivity(
this, 0, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Monochrome")
.setContentText("Playing audio")
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSilent(true)
.build();
}
private void acquireWakeLock() {
if (wakeLock != null && !wakeLock.isHeld()) {
wakeLock = null;
}
if (wakeLock == null) {
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
if (pm != null) {
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"monochrome:audio_playback"
);
// 4-hour timeout as a safety net to prevent battery drain
// if the service is accidentally left running
wakeLock.acquire(4 * 60 * 60 * 1000L);
}
}
}
private void releaseWakeLock() {
if (wakeLock != null && wakeLock.isHeld()) {
wakeLock.release();
wakeLock = null;
}
}
}

View file

@ -0,0 +1,47 @@
package tf.monochrome.music;
import android.content.Intent;
import android.os.Build;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/**
* Capacitor plugin that exposes start/stop controls for the foreground
* AudioPlaybackService. Called from JS when audio playback begins or ends
* so Android keeps the process alive in the background.
*/
@CapacitorPlugin(name = "BackgroundAudio")
public class BackgroundAudioPlugin extends Plugin {
@PluginMethod
public void start(PluginCall call) {
try {
Intent intent = new Intent(getContext(), AudioPlaybackService.class);
intent.setAction("START");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getContext().startForegroundService(intent);
} else {
getContext().startService(intent);
}
call.resolve();
} catch (Exception e) {
call.reject("Failed to start audio service: " + e.getMessage(), e);
}
}
`@PluginMethod`
public void stop(PluginCall call) {
try {
Intent intent = new Intent(getContext(), AudioPlaybackService.class);
intent.setAction("STOP");
// Use startService so onStartCommand receives the STOP action
getContext().startService(intent);
call.resolve();
} catch (Exception e) {
call.reject("Failed to stop audio service: " + e.getMessage(), e);
}
}
}

View file

@ -1,5 +1,14 @@
package tf.monochrome.music;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(BackgroundAudioPlugin.class);
super.onCreate(savedInstanceState);
}
}

629
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -21,4 +21,7 @@ album:250986538
album:509761344
album:15621057
album:103897783
album:151728406
album:151728406
album:199412873
album:3280432
album:37927851

View file

@ -14,9 +14,9 @@ export default defineConfig(
tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parser: tsParser, // 👈 REQUIRED
parser: tsParser,
parserOptions: {
project: './tsconfig-eslint.json', // 👈 REQUIRED
project: './tsconfig-eslint.json',
},
ecmaVersion: 2022,
sourceType: 'module',

21
fix-gen.py Normal file
View file

@ -0,0 +1,21 @@
import re
with open("gen-editors-picks.py", "r") as f: content = f.read()
content = re.sub(r"IMAGES_DIR = \"public/editors-picks-images\"\n+", "", content)
# Remove clear_images_dir definition
content = re.sub(r"def clear_images_dir\(\):[\s\S]*?os\.makedirs[^\n]*\n+", "", content)
content = re.sub(r"clear_images_dir\(\)\n+", "", content)
# Remove the import line for subprocess, shutil if present
content = re.sub(r"import subprocess\n", "", content)
content = re.sub(r"import shutil\n", "", content)
# Replace download_and_process_cover
new_func = """def download_and_process_cover(cover_uuid):
url = f"https://resources.tidal.com/images/{uuid_to_path_segments(cover_uuid)}/320x320.jpg"
return f"https://wsrv.nl/?url={url}&w=250&h=250&output=webp"
"""
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)

View file

@ -8,36 +8,59 @@ import sys
import hashlib
import time
import os
import subprocess
import shutil
import tempfile
import base64
INPUT_FILE = "editors-picks-input.txt"
IMAGES_DIR = "public/editors-picks-images"
COUNTRY = "US"
# Tidal internal token replace when expired
TIDAL_TOKEN = "eyJraWQiOiJ2OU1GbFhqWSIsImFsZyI6IkVTMjU2In0.eyJ0eXBlIjoibzJfYWNjZXNzIiwic2NvcGUiOiIiLCJnVmVyIjowLCJzVmVyIjowLCJjaWQiOjEzNTU3LCJhdCI6IklOVEVSTkFMIiwiZXhwIjoxNzc1MzY0MTQwLCJpc3MiOiJodHRwczovL2F1dGgudGlkYWwuY29tL3YxIn0.6ui6itHVQ-OXPF0F9mbf5KcKz1fKYJNsa1vBAj60upXpcN-DQG8JPKBlqJN6RuBEH8yhwYj2wh4YJ-TOOuO8DA"
TIDAL_CLIENT_ID = "txNoH4kkV41MfH25"
TIDAL_CLIENT_SECRET = "dQjy0MinCEvxi1O4UmxvxWnDjt4cgHBPw8ll6nYBk98="
TIDAL_HEADERS = {
"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_token = None
# ── 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):
if params is None:
params = {}
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)}"
req = urllib.request.Request(url, headers=TIDAL_HEADERS)
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
@ -90,59 +113,6 @@ def fetch_podcast(feed_id):
# ── Image processing ───────────────────────────────────────────────────────────
def clear_images_dir():
if os.path.exists(IMAGES_DIR):
shutil.rmtree(IMAGES_DIR)
os.makedirs(IMAGES_DIR, exist_ok=True)
def is_uuid_cover(cover_value):
if not cover_value:
return False
return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', cover_value))
def uuid_to_path_segments(uuid):
return uuid.replace('-', '/')
def download_and_process_cover(cover_uuid):
url = f"https://resources.tidal.com/images/{uuid_to_path_segments(cover_uuid)}/320x320.jpg"
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
tmp_path = tmp.name
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req) as resp:
with open(tmp_path, 'wb') as f:
shutil.copyfileobj(resp, f)
output_path = os.path.join(IMAGES_DIR, f"{cover_uuid}.webp")
subprocess.run(
['cwebp', '-q', '50', tmp_path, '-o', output_path],
check=True,
capture_output=True
)
return f"https://monochrome.tf/editors-picks-images/{cover_uuid}.webp"
except Exception as e:
print(f"Error processing cover {cover_uuid}: {e}", file=sys.stderr)
return None
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
def process_cover(cover_value):
if not cover_value:
return cover_value
if is_uuid_cover(cover_value):
return download_and_process_cover(cover_value)
return cover_value
# ── Transformers ──────────────────────────────────────────────────────────────
def transform_album(d):
@ -155,7 +125,7 @@ def transform_album(d):
"name": d.get("artist", {}).get("name"),
},
"releaseDate": d.get("releaseDate"),
"cover": process_cover(d.get("cover")),
"cover": d.get("cover"),
"explicit": d.get("explicit"),
"audioQuality": d.get("audioQuality"),
"mediaMetadata": d.get("mediaMetadata"),
@ -167,7 +137,7 @@ def transform_artist(d):
"type": "artist",
"id": d.get("id"),
"name": d.get("name"),
"picture": process_cover(d.get("picture")),
"picture": d.get("picture"),
}
@ -184,7 +154,7 @@ def transform_track(d):
"album": {
"id": album.get("id"),
"title": album.get("title"),
"cover": process_cover(album.get("cover")),
"cover": album.get("cover"),
},
"duration": d.get("duration"),
"explicit": d.get("explicit"),
@ -200,7 +170,7 @@ def transform_playlist(d):
"type": "playlist",
"id": d.get("uuid"),
"title": d.get("title"),
"cover": process_cover(cover),
"cover": cover,
"numberOfTracks": d.get("numberOfTracks", 0),
}
@ -213,7 +183,7 @@ def transform_userplaylist(d):
"type": "user-playlist",
"id": d.get("uuid"),
"name": d.get("title"),
"cover": process_cover(cover),
"cover": cover,
"numberOfTracks": d.get("numberOfTracks", 0),
"username": creator.get("name"),
}
@ -258,8 +228,6 @@ def read_items(path):
# ── Main ──────────────────────────────────────────────────────────────────────
clear_images_dir()
FETCHERS = {
"album": (fetch_album, transform_album),
"artist": (fetch_artist, transform_artist),

View file

@ -3,32 +3,99 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Monochrome Music</title>
<title>Monochrome</title>
<link rel="canonical" href="https://monochrome.tf/" />
<link rel="alternate" hreflang="en" href="https://monochrome.tf/" />
<meta name="theme-color" content="#000000" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<base href="/" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Monochrome" />
<meta name="description" content="A minimalist music streaming application" />
<meta
name="description"
content="Stream and download millions of Hi-Res FLACs, unreleased songs and music videos, all for free on Monochrome."
/>
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<meta name="googlebot" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="Monochrome" />
<meta property="og:title" content="Monochrome" />
<meta
property="og:description"
content="Stream and download millions of Hi-Res FLACs, unreleased songs and music videos, all for free on Monochrome."
/>
<meta property="og:url" content="https://monochrome.tf/" />
<meta property="og:image" content="https://monochrome.tf/assets/banner.jpg" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:alt" content="Monochrome banner" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://monochrome.tf/" />
<meta name="twitter:title" content="Monochrome" />
<meta
name="twitter:description"
content="Stream and download millions of Hi-Res FLACs, unreleased songs and music videos, all for free on Monochrome."
/>
<meta name="twitter:image" content="https://monochrome.tf/assets/banner-twitter.jpg" />
<meta name="twitter:image:alt" content="Monochrome banner" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Monochrome",
"url": "https://monochrome.tf/",
"description": "Stream and download millions of Hi-Res FLACs, unreleased songs and music videos, all for free on Monochrome.",
"potentialAction": {
"@type": "SearchAction",
"target": "https://monochrome.tf/search/{search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Monochrome",
"url": "https://monochrome.tf/",
"applicationCategory": "MusicApplication",
"operatingSystem": "Web Browser",
"image": "https://monochrome.tf/assets/logo.svg",
"description": "Stream and download millions of Hi-Res FLACs, unreleased songs and music videos, all for free on Monochrome."
}
</script>
<!-- bini's seo thing -->
<meta
name="ahrefs-site-verification"
content="889c6a5c1584832256b87bb36beaa665b40bb6e201582d416f04d074794044ef"
/>
<meta name="google-site-verification" content="ChAPlKFJ5Dk2YbWhiUfnH5sHgCC8SnNdCBtjVcahArY" />
<!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://api.fonts.coollabs.io" crossorigin />
<link rel="preconnect" href="https://ws.audioscrobbler.com" crossorigin />
<link rel="preconnect" href="https://libre.fm" crossorigin />
<link rel="preconnect" href="https://api.listenbrainz.org" crossorigin />
<link rel="preconnect" href="https://resources.tidal.com" crossorigin />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://unpkg.com" crossorigin />
<link rel="apple-touch-icon" href="/assets/logo.svg" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/assets/logo.svg" type="image/svg+xml" />
<link
rel="preload"
as="style"
href="https://api.fonts.coollabs.io/css2?family=Inter:wght@400;500;600;700;800&display=swap"
/>
<link
href="https://api.fonts.coollabs.io/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
media="print"
onload="this.media = 'all'"
/>
<noscript>
<link
href="https://api.fonts.coollabs.io/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
</noscript>
<link rel="stylesheet" href="/styles.css" />
</head>
@ -214,6 +281,7 @@
<button id="fs-repeat-btn" title="Repeat">
<use svg="!lucide/repeat.svg" size="24" />
</button>
<button id="fs-quality-btn" class="fs-quality-btn" title="Quality" style="display: none">
<button id="fs-quality-btn" class="fs-quality-btn" title="Quality" style="display: none">
<use svg="!lucide/pencil-line.svg" size="20" />
<span class="fs-quality-label">Auto</span>
@ -1719,7 +1787,11 @@
</p>
</div>
<section class="content-section" id="home-editors-picks-section-empty" style="margin-top: 0">
<section
class="content-section"
id="home-editors-picks-section-empty"
style="margin-top: 0; display: none"
>
<div
style="
display: flex;
@ -2269,6 +2341,9 @@
<h1 class="title" id="album-detail-title"></h1>
<div class="meta" id="album-detail-meta"></div>
<div class="meta" id="album-detail-producer"></div>
<br />
<div class="ratings" id="album-detail-ratings-critics"></div>
<div class="ratings" id="album-detail-ratings-users"></div>
<div class="detail-header-actions">
<button id="play-album-btn" class="btn-primary" title="Play Album">
<use svg="./images/play.svg" size="20" />
@ -4067,6 +4142,7 @@
<!-- Mode Toggle + How To -->
<div class="autoeq-mode-row">
<div class="autoeq-mode-toggle">
<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">
Parametric EQ
@ -4203,6 +4279,66 @@
</div>
</div>
<!-- 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-preset-row">
<label for="legacy-graphic-eq-preset-select" class="graphic-eq-preset-label"
>Preset</label
>
<select
id="legacy-graphic-eq-preset-select"
class="graphic-eq-preset-select"
>
<option value="">Custom</option>
<option value="flat">Flat</option>
<option value="bass_boost">Bass Boost</option>
<option value="bass_reducer">Bass Reducer</option>
<option value="treble_boost">Treble Boost</option>
<option value="treble_reducer">Treble Reducer</option>
<option value="vocal_boost">Vocal Boost</option>
<option value="loudness">Loudness</option>
<option value="rock">Rock</option>
<option value="pop">Pop</option>
<option value="classical">Classical</option>
<option value="jazz">Jazz</option>
<option value="electronic">Electronic</option>
<option value="hip_hop">Hip-Hop</option>
<option value="r_and_b">R&amp;B</option>
<option value="acoustic">Acoustic</option>
<option value="podcast">Speech</option>
</select>
</div>
<div class="graphic-eq-bands" id="legacy-graphic-eq-bands">
<!-- 16 vertical sliders generated by JS -->
</div>
<div class="graphic-eq-bottom-row">
<div class="graphic-eq-preamp">
<label
for="legacy-graphic-eq-preamp-slider"
class="graphic-eq-preamp-label"
>Preamp</label
>
<input
type="range"
id="legacy-graphic-eq-preamp-slider"
class="graphic-eq-preamp-slider"
min="-20"
max="20"
step="0.1"
value="0"
/>
<span
class="graphic-eq-preamp-value"
id="legacy-graphic-eq-preamp-value"
>0 dB</span
>
</div>
<button id="legacy-graphic-eq-reset-btn" class="btn-secondary">
Reset
</button>
</div>
</div>
<!-- Frequency Response Graph -->
<div class="autoeq-graph-section">
<div class="autoeq-graph-header">
@ -4249,29 +4385,42 @@
<!-- Database Browser -->
<div class="autoeq-database-section" id="autoeq-database-section">
<div class="autoeq-database-header">
<div class="autoeq-database-header" id="autoeq-database-toggle">
<div>
<h4 class="autoeq-database-title">Database</h4>
<span class="autoeq-database-subtitle">AutoEq Repo</span>
</div>
<span class="autoeq-database-count" id="autoeq-database-count"></span>
</div>
<div class="autoeq-database-search">
<use svg="!lucide/search.svg" size="16" />
<input
type="text"
id="autoeq-headphone-search"
placeholder="Search model (e.g. HD 600)..."
autocomplete="off"
aria-label="Search headphone model"
/>
</div>
<div class="autoeq-database-content">
<div class="autoeq-database-list" id="autoeq-database-list">
<!-- Dynamically populated -->
<div class="autoeq-database-header-right">
<span class="autoeq-database-count" id="autoeq-database-count"></span>
<button
class="autoeq-collapse-btn"
id="autoeq-database-collapse"
aria-label="Collapse database"
aria-expanded="true"
aria-controls="autoeq-database-body"
>
<use svg="!lucide/chevron-up.svg" size="18" />
</button>
</div>
<div class="autoeq-database-alpha-index" id="autoeq-alpha-index">
<!-- A-Z generated by JS -->
</div>
<div class="autoeq-database-body" id="autoeq-database-body">
<div class="autoeq-database-search">
<use svg="!lucide/search.svg" size="16" />
<input
type="text"
id="autoeq-headphone-search"
placeholder="Search model (e.g. HD 600)..."
autocomplete="off"
aria-label="Search headphone model"
/>
</div>
<div class="autoeq-database-content">
<div class="autoeq-database-list" id="autoeq-database-list">
<!-- Dynamically populated -->
</div>
<div class="autoeq-database-alpha-index" id="autoeq-alpha-index">
<!-- A-Z generated by JS -->
</div>
</div>
</div>
</div>
@ -5379,6 +5528,7 @@
<h3 style="text-align: center">Contributors List:</h3>
<br />
<div class="about-contributors"></div>
<div class="about-contributors-failed"></div>
<div class="about-footer">
<p class="version">Version 2.5.0</p>
<p class="version" id="about-commit-info"></p>
@ -5498,7 +5648,7 @@
<footer class="now-playing-bar">
<div class="track-info">
<img src="/assets/appicon.png" alt="Current Track Cover" class="cover" />
<img src="/assets/appicon.png" alt="Current Track Cover" class="cover" fetchpriority="high" />
<div class="details">
<div class="title">Select a song</div>
<div class="album"></div>
@ -5597,8 +5747,6 @@
</div>
</footer>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vanilla-tilt/1.8.1/vanilla-tilt.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.21.3/dist/pocketbase.umd.js"></script>
<script type="module" src="/js/app.js"></script>
</body>
</html>

View file

@ -1,4 +1,5 @@
import UIKit
import AVFoundation
import Capacitor
@UIApplicationMain
@ -7,10 +8,82 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
configureAudioSession()
return true
}
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
// .playback keeps audio alive when the app is backgrounded or the screen locks
try session.setCategory(.playback, mode: .default, options: [])
try session.setActive(true)
} catch {
print("[AudioSession] Failed to configure: \(error.localizedDescription)")
}
// Handle audio interruptions (phone calls, Siri, alarms, etc.)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioInterruption),
name: AVAudioSession.interruptionNotification,
object: session
)
// Handle route changes (headphones unplugged, Bluetooth disconnect, etc.)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange),
name: AVAudioSession.routeChangeNotification,
object: session
)
}
@objc private func handleAudioInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Interruption began system pauses audio automatically
break
case .ended:
// Interruption ended reactivate session so playback can resume
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("[AudioSession] Failed to reactivate after interruption: \(error.localizedDescription)")
}
}
}
@unknown default:
break
}
}
@objc private func handleRouteChange(notification: Notification) {
guard let userInfo = notification.userInfo,
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
return
}
if reason == .oldDeviceUnavailable {
// Headphones/Bluetooth disconnected reactivate session to keep background alive
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("[AudioSession] Failed to reactivate after route change: \(error.localizedDescription)")
}
}
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.

View file

@ -47,5 +47,9 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict>
</plist>

1613
js/HiFi.ts

File diff suppressed because it is too large Load diff

View file

@ -1073,18 +1073,20 @@ export class LosslessAPI {
entries.forEach((entry) => scan(entry, visited));
scan(primaryData, visited);
const matchesArtistId = (item) => {
const candidateIds = [
item.artist?.id,
...(Array.isArray(item.artists) ? item.artists.map((a) => a.id) : []),
].filter((id) => id != null);
return candidateIds.some((id) => Number(id) === Number(artistId));
};
if (!options.lightweight) {
try {
const videoSearch = await this.searchVideos(artist.name);
if (videoSearch && videoSearch.items) {
const numericArtistId = Number(artistId);
for (const item of videoSearch.items) {
const itemArtistId = item.artist?.id;
const matchesArtist =
itemArtistId === numericArtistId ||
(Array.isArray(item.artists) && item.artists.some((a) => a.id === numericArtistId));
if (matchesArtist && !videoMap.has(item.id)) {
if (matchesArtistId(item) && !videoMap.has(item.id)) {
videoMap.set(item.id, item);
}
}
@ -1094,7 +1096,7 @@ export class LosslessAPI {
}
}
const rawReleases = Array.from(albumMap.values());
const rawReleases = Array.from(albumMap.values()).filter(matchesArtistId);
const allReleases = this.deduplicateAlbums(rawReleases).sort(
(a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
);
@ -1103,6 +1105,7 @@ export class LosslessAPI {
const albums = allReleases.filter((a) => !eps.includes(a));
const topTracks = Array.from(trackMap.values())
.filter(matchesArtistId)
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
.slice(0, 15);
@ -1952,6 +1955,19 @@ export class LosslessAPI {
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
getCoverSrcset(id) {
if (
!id ||
(typeof id === 'string' && (id.startsWith('http') || id.startsWith('blob:') || id.startsWith('assets/')))
) {
return '';
}
const formattedId = String(id).replace(/-/g, '/');
const baseUrl = `https://resources.tidal.com/images/${formattedId}`;
return `${baseUrl}/160x160.jpg 160w, ${baseUrl}/320x320.jpg 320w, ${baseUrl}/640x640.jpg 640w`;
}
getArtistPictureUrl(id, size = '320') {
if (!id) {
return `https://picsum.photos/seed/${Math.random()}/${size}`;
@ -1965,6 +1981,16 @@ export class LosslessAPI {
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
getArtistPictureSrcset(id) {
if (!id || (typeof id === 'string' && (id.startsWith('blob:') || id.startsWith('assets/')))) {
return '';
}
const formattedId = String(id).replace(/-/g, '/');
const baseUrl = `https://resources.tidal.com/images/${formattedId}`;
return `${baseUrl}/160x160.jpg 160w, ${baseUrl}/320x320.jpg 320w, ${baseUrl}/640x640.jpg 640w`;
}
getVideoCoverUrl(imageId, size = '1280') {
if (!imageId) {
return null;

View file

@ -131,8 +131,9 @@ async function fetchcontributors() {
const response = await fetch('https://api.samidy.com/api/contributors');
if (!response.ok) return;
const data1 = await response.json();
if (!Array.isArray(data1)) return;
const data = data1.filter(
let data = data1.filter(
(user) => user.type !== 'Bot' && user.login !== 'edidealt' && user.login !== 'satanyahoo'
);
@ -142,6 +143,8 @@ async function fetchcontributors() {
edideaur.contributions += data1.find((u) => u.login === 'satanyahoo')?.contributions || 0;
}
data.sort((a, b) => b.contributions - a.contributions);
const con = document.querySelector('.about-contributors');
if (!con) return;
@ -149,14 +152,22 @@ async function fetchcontributors() {
const userDIV = document.createElement('div');
userDIV.innerHTML = `
<a href="${user.html_url}" target="_blank">
<img src="${user.avatar_url}" alt="${user.login}" width="50" style="border-radius: 50%;">
<img src="${user.avatar_url}&s=50" alt="${user.login}" width="50" height="50" style="border-radius: 50%;" loading="lazy">
<span>${user.login}</span>
<span class="contrib">Contributions: ${user.contributions}</span>
</a>
`;
con.appendChild(userDIV);
});
} catch (e) {}
} catch (e) {
const con = document.querySelector('.about-contributors-failed');
if (!con) return;
const userDIV = document.createElement('div');
userDIV.innerHTML = `
<h4 style="text-align: center; color: var(--muted-foreground);">Failed to Fetch Contributor List</h4>
`;
con.appendChild(userDIV);
}
}
async function loadMetadataModule() {
@ -2894,7 +2905,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (user) {
const data = await syncManager.getUserData();
if (data && data.profile && data.profile.avatar_url) {
headerAccountImg.src = data.profile.avatar_url;
headerAccountImg.src = data.profile.avatar_url + '&s=100';
headerAccountImg.style.display = 'block';
headerAccountIcon.style.display = 'none';
return;

View file

@ -113,6 +113,15 @@ class AudioContextManager {
// Callbacks for audio graph changes (for visualizers like Butterchurn)
this._graphChangeCallbacks = [];
// --- Graphic EQ (16-band, separate chain) ---
this.geqFilters = [];
this.geqPreampNode = null;
this.geqOutputNode = null;
this.isGraphicEQEnabled = equalizerSettings.isGraphicEqEnabled();
this.geqFrequencies = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000];
this.geqGains = equalizerSettings.getGraphicEqGains();
this.geqPreamp = equalizerSettings.getGraphicEqPreamp();
// Load saved settings
this._loadSettings();
}
@ -308,17 +317,12 @@ class AudioContextManager {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
const highResOptions = { sampleRate: 192000, latencyHint: 'playback' };
try {
this.audioContext = new AudioContext(highResOptions);
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
this.audioContext = new AudioContext({ latencyHint: 'playback' });
console.log(`[AudioContext] Created: ${this.audioContext.sampleRate}Hz`);
} catch {
try {
this.audioContext = new AudioContext({ latencyHint: 'playback' });
} catch {
this.audioContext = new AudioContext();
}
this.audioContext = new AudioContext();
}
if (!this.sources.has(audioElement)) {
@ -331,6 +335,7 @@ class AudioContextManager {
this.analyser.smoothingTimeConstant = 0.7;
this._createEQ();
this._createGraphicEQ();
this.outputNode = this.audioContext.createGain();
this.outputNode.gain.value = 1;
@ -342,6 +347,21 @@ class AudioContextManager {
this._connectGraph();
// Auto-recover from unexpected suspensions (e.g. background throttling)
this.audioContext.addEventListener('statechange', () => {
if (this.audioContext.state === 'interrupted' || this.audioContext.state === 'suspended') {
console.log(`[AudioContext] State changed to ${this.audioContext.state}, attempting resume`);
// Use a short delay to let the system settle before resuming
setTimeout(() => {
if (this.audioContext && this.audioContext.state !== 'running' && this.source) {
this.audioContext.resume().catch((e) => {
console.warn('[AudioContext] Auto-resume failed:', e);
});
}
}, 100);
}
});
this.isInitialized = true;
} catch (e) {
console.warn('[AudioContext] Init failed:', e);
@ -380,65 +400,76 @@ class AudioContextManager {
}
/**
* Connect the audio graph based on EQ and mono audio state
* Connect the audio graph based on EQ and mono audio state.
* Uses connect-before-disconnect ordering to avoid audio dropouts:
* the new chain is wired up first, then the old connections are torn down.
*/
_connectGraph() {
if (!this.isInitialized || !this.source || !this.audioContext) return;
try {
// Disconnect everything first
try {
this.source.disconnect();
} catch {
// node may already be disconnected
}
this.outputNode.disconnect();
if (this.volumeNode) {
this.volumeNode.disconnect();
}
this.analyser.disconnect();
// Ensure graphic EQ nodes exist
if (this.geqFilters.length === 0 && this.isGraphicEQEnabled) {
this._createGraphicEQ();
}
if (this.monoMergerNode) {
try {
this.monoMergerNode.disconnect();
} catch {
// Ignore if not connected
// Helper: connect a chain segment from lastNode through graphic EQ (if enabled) to analyser -> volume -> dest
const connectTail = (lastNode) => {
if (this.isGraphicEQEnabled && this.geqFilters.length > 0) {
lastNode.connect(this.geqPreampNode);
this.geqPreampNode.connect(this.geqFilters[0]);
for (let i = 0; i < this.geqFilters.length - 1; i++) {
this.geqFilters[i].connect(this.geqFilters[i + 1]);
}
this.geqFilters[this.geqFilters.length - 1].connect(this.geqOutputNode);
this.geqOutputNode.connect(this.analyser);
} else {
lastNode.connect(this.analyser);
}
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
};
try {
// Ensure mono gain node exists if needed
if (this.isMonoAudioEnabled && this.monoMergerNode && !this.monoGainNode) {
this.monoGainNode = this.audioContext.createGain();
this.monoGainNode.gain.value = 0.5;
}
// --- 1. Disconnect all existing connections ---
const safeDisconnect = (node) => {
try {
node?.disconnect();
} catch {
/* */
}
};
safeDisconnect(this.source);
safeDisconnect(this.monoGainNode);
safeDisconnect(this.monoMergerNode);
safeDisconnect(this.preampNode);
this.filters.forEach(safeDisconnect);
safeDisconnect(this.outputNode);
safeDisconnect(this.geqPreampNode);
this.geqFilters.forEach(safeDisconnect);
safeDisconnect(this.geqOutputNode);
safeDisconnect(this.analyser);
safeDisconnect(this.volumeNode);
// --- 2. Reconnect the graph ---
let lastNode = this.source;
// Apply mono audio if enabled
if (this.isMonoAudioEnabled && this.monoMergerNode) {
// Reuse persistent gain node to avoid leaking AudioNodes
if (!this.monoGainNode) {
this.monoGainNode = this.audioContext.createGain();
this.monoGainNode.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
}
try {
this.monoGainNode.disconnect();
} catch {
/* not connected */
}
// Connect source to mono gain
this.source.connect(this.monoGainNode);
// Connect mono gain to both inputs of the merger
this.monoGainNode.connect(this.monoMergerNode, 0, 0);
this.monoGainNode.connect(this.monoMergerNode, 0, 1);
lastNode = this.monoMergerNode;
console.log('[AudioContext] Mono audio enabled');
}
if (this.isEQEnabled && this.filters.length > 0) {
// EQ enabled: lastNode -> preamp -> EQ filters -> output -> analyser -> volume -> destination
// Connect filter chain
for (let i = 0; i < this.filters.length - 1; i++) {
this.filters[i].connect(this.filters[i + 1]);
}
// Connect preamp to first filter
if (this.preampNode) {
lastNode.connect(this.preampNode);
this.preampNode.connect(this.filters[0]);
@ -446,22 +477,15 @@ class AudioContextManager {
lastNode.connect(this.filters[0]);
}
this.filters[this.filters.length - 1].connect(this.outputNode);
this.outputNode.connect(this.analyser);
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
console.log('[AudioContext] EQ connected');
connectTail(this.outputNode);
} else {
// EQ disabled: lastNode -> analyser -> volume -> destination
lastNode.connect(this.analyser);
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
connectTail(lastNode);
}
// Notify visualizers that graph has been reconnected
this._notifyGraphChange();
} catch (e) {
console.warn('[AudioContext] Failed to connect graph:', e);
// Fallback: direct connection
try {
this.source.connect(this.audioContext.destination);
} catch {
@ -815,11 +839,22 @@ class AudioContextManager {
this.currentQs = newQs;
this.currentGains = newGains;
// Rebuild EQ so _createEQ picks up the new types/Qs
if (this.isInitialized && this.audioContext) {
this._destroyEQ();
this._createEQ();
this._connectGraph();
// If filter count matches, update params in-place (no graph rebuild)
if (this.filters.length === count) {
const now = this.audioContext.currentTime;
this.filters.forEach((filter, i) => {
filter.type = newTypes[i] || 'peaking';
filter.frequency.setTargetAtTime(newFrequencies[i], now, 0.005);
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._createEQ();
this._connectGraph();
}
}
// Apply preamp (skip if caller manages preamp externally)
@ -955,6 +990,97 @@ class AudioContextManager {
return false;
}
}
// ========================================
// Graphic EQ (16-band, independent chain)
// ========================================
_createGraphicEQ() {
if (!this.audioContext) return;
this.geqPreampNode = this.audioContext.createGain();
const gainValue = Math.pow(10, (this.geqPreamp || 0) / 20);
this.geqPreampNode.gain.value = gainValue;
this.geqOutputNode = this.audioContext.createGain();
this.geqOutputNode.gain.value = 1;
this.geqFilters = this.geqFrequencies.map((freq, i) => {
const filter = this.audioContext.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
filter.Q.value = 2.5; // constant Q for 16-band
filter.gain.value = this.geqGains[i] || 0;
return filter;
});
}
_destroyGraphicEQ() {
this.geqFilters.forEach((f) => {
try {
f.disconnect();
} catch {
/* */
}
});
this.geqFilters = [];
if (this.geqPreampNode) {
try {
this.geqPreampNode.disconnect();
} catch {
/* */
}
this.geqPreampNode = null;
}
if (this.geqOutputNode) {
try {
this.geqOutputNode.disconnect();
} catch {
/* */
}
this.geqOutputNode = null;
}
}
toggleGraphicEQ(enabled) {
this.isGraphicEQEnabled = enabled;
equalizerSettings.setGraphicEqEnabled(enabled);
if (this.isInitialized) {
this._connectGraph();
}
}
setGraphicEqBandGain(bandIndex, gainDb) {
if (bandIndex < 0 || bandIndex >= 16) return;
this.geqGains[bandIndex] = Math.max(-30, Math.min(30, gainDb));
if (this.geqFilters[bandIndex] && this.audioContext) {
const now = this.audioContext.currentTime;
this.geqFilters[bandIndex].gain.setTargetAtTime(this.geqGains[bandIndex], now, 0.01);
}
equalizerSettings.setGraphicEqGains([...this.geqGains]);
}
setGraphicEqAllGains(gains) {
if (!Array.isArray(gains)) return;
const now = this.audioContext?.currentTime || 0;
gains.forEach((g, i) => {
if (i >= 16) return;
this.geqGains[i] = Math.max(-30, Math.min(30, g));
if (this.geqFilters[i]) {
this.geqFilters[i].gain.setTargetAtTime(this.geqGains[i], now, 0.01);
}
});
equalizerSettings.setGraphicEqGains([...this.geqGains]);
}
setGraphicEqPreamp(db) {
this.geqPreamp = Math.max(-20, Math.min(20, parseFloat(db) || 0));
if (this.geqPreampNode && this.audioContext) {
const gainValue = Math.pow(10, this.geqPreamp / 20);
const now = this.audioContext.currentTime;
this.geqPreampNode.gain.setTargetAtTime(gainValue, now, 0.01);
}
equalizerSettings.setGraphicEqPreamp(this.geqPreamp);
}
}
// Export singleton instance

View file

@ -10,7 +10,28 @@ import {
SVG_GLOBE,
} from './icons.js';
import { sidePanelManager } from './side-panel.js';
import('@uimaxbai/am-lyrics/am-lyrics.js').catch(console.error);
const loadAmLyrics = () => {
const images = Array.from(document.images).filter((img) => !img.complete);
if (images.length === 0) {
import('@uimaxbai/am-lyrics/am-lyrics.js').catch(console.error);
} else {
Promise.all(
images.map(
(img) =>
new Promise((res) => {
img.onload = img.onerror = res;
})
)
).then(() => import('@uimaxbai/am-lyrics/am-lyrics.js').catch(console.error));
}
};
if (document.readyState === 'complete') {
loadAmLyrics();
} else {
window.addEventListener('load', loadAmLyrics);
}
// Check if text contains Japanese, Chinese, or Korean characters
function containsAsianText(text) {
@ -278,13 +299,13 @@ export class LyricsManager {
// Load Kuroshiro from CDN
if (!window.Kuroshiro) {
await this.loadScript('https://unpkg.com/kuroshiro@1.2.0/dist/kuroshiro.min.js');
await this.loadScript('https://cdn.jsdelivr.net/npm/kuroshiro@1.2.0/dist/kuroshiro.min.js');
}
// Load Kuromoji analyzer from CDN
if (!window.KuromojiAnalyzer) {
await this.loadScript(
'https://unpkg.com/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js'
'https://cdn.jsdelivr.net/npm/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js'
);
}
@ -1002,7 +1023,6 @@ function applyFullscreenLyricsShadowTweaks(amLyrics, container) {
.lyrics-line:not(.active):not(.pre-active) {
opacity: 0.44;
}
.lyrics-line-container {
transition:
transform 0.72s cubic-bezier(0.22, 1, 0.36, 1),

View file

@ -223,6 +223,13 @@ export class MusicAPI {
return this.tidalAPI.getCoverUrl(this.stripProviderPrefix(id), size);
}
getCoverSrcset(id) {
if (typeof id === 'string' && id.startsWith('blob:')) {
return '';
}
return this.tidalAPI.getCoverSrcset(this.stripProviderPrefix(id));
}
getVideoCoverUrl(imageId, size = '1280') {
if (!imageId) {
return null;
@ -260,6 +267,10 @@ export class MusicAPI {
return this.tidalAPI.getArtistPictureUrl(this.stripProviderPrefix(id), size);
}
getArtistPictureSrcset(id) {
return this.tidalAPI.getArtistPictureSrcset(this.stripProviderPrefix(id));
}
extractStreamUrlFromManifest(manifest) {
return this.tidalAPI.extractStreamUrlFromManifest(manifest);
}

View file

@ -98,6 +98,24 @@ export class Player {
});
}
const waitForImagesLoading = () => {
const images = Array.from(document.images).filter((img) => !img.complete);
if (images.length === 0) return Promise.resolve();
return Promise.all(
images.map(
(img) =>
new Promise((res) => {
img.onload = img.onerror = res;
})
)
);
};
if (document.readyState !== 'complete') {
await new Promise((resolve) => window.addEventListener('load', resolve));
}
await waitForImagesLoading();
// Initialize Shaka player
const shaka = await import('shaka-player');
shaka.polyfill.installAll();
@ -148,9 +166,13 @@ export class Player {
await this.saveQueueState();
});
// Handle visibility change for iOS - AudioContext gets suspended when screen locks
// Handle visibility change - AudioContext can be suspended when backgrounded
document.addEventListener('visibilitychange', async () => {
const el = this.activeElement;
if (document.visibilityState === 'hidden' && !el.paused) {
// Proactively resume context when going to background to prevent suspension
void audioContextManager.resume();
}
if (document.visibilityState === 'visible' && !el.paused) {
// Ensure audio context is resumed when user returns to the app
if (!audioContextManager.isReady()) {
@ -307,8 +329,9 @@ export class Player {
if (coverEl) {
const videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null;
const coverUrl =
videoCoverUrl || this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
const coverId = track.image || track.cover || track.album?.cover;
const coverUrl = videoCoverUrl || this.api.getCoverUrl(coverId);
const coverSrcset = videoCoverUrl ? null : this.api.getCoverSrcset(coverId);
if (videoCoverUrl) {
if (coverEl.tagName === 'IMG') {
@ -326,14 +349,24 @@ export class Player {
coverEl.src = videoCoverUrl;
}
} else {
const setImgSrcset = (img) => {
if (img.getAttribute('src') !== coverUrl) img.src = coverUrl;
if (coverSrcset) {
img.setAttribute('srcset', coverSrcset);
img.setAttribute('sizes', '(max-width: 640px) 160px, (max-width: 1024px) 320px, 640px');
} else {
img.removeAttribute('srcset');
img.removeAttribute('sizes');
}
};
if (coverEl.tagName === 'VIDEO') {
const img = document.createElement('img');
img.src = coverUrl;
img.className = coverEl.className;
img.id = coverEl.id;
setImgSrcset(img);
coverEl.replaceWith(img);
} else {
coverEl.src = coverUrl;
setImgSrcset(coverEl);
}
}
}
@ -870,8 +903,19 @@ export class Player {
} else {
if (coverEl) {
coverEl.style.display = 'block';
const coverUrl = this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
if (coverEl.src !== coverUrl) coverEl.src = coverUrl;
const coverId = track.image || track.cover || track.album?.cover;
const coverUrl = this.api.getCoverUrl(coverId);
const coverSrcset = this.api.getCoverSrcset(coverId);
if (coverEl.getAttribute('src') !== coverUrl) {
coverEl.src = coverUrl;
if (coverSrcset) {
coverEl.setAttribute('srcset', coverSrcset);
coverEl.setAttribute('sizes', '(max-width: 640px) 160px, (max-width: 1024px) 320px, 640px');
} else {
coverEl.removeAttribute('srcset');
coverEl.removeAttribute('sizes');
}
}
}
if (this.audio) {
const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex';
@ -2054,7 +2098,41 @@ export class Player {
updateMediaSessionPlaybackState() {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = this.activeElement.paused ? 'paused' : 'playing';
const isPlaying = !this.activeElement.paused;
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
// Start/stop Android foreground service to prevent background audio throttling
this._updateBackgroundAudioService(isPlaying);
}
/**
* On Android (Capacitor), start or stop the foreground service that keeps
* the WebView alive so Web Audio EQ processing isn't throttled.
*/
_updateBackgroundAudioService(isPlaying) {
if (this._bgAudioPending) return;
this._bgAudioPending = true;
// Lazy-load Capacitor core; no-op on web/iOS
void (async () => {
try {
const { Capacitor } = await import('@capacitor/core');
if (Capacitor.getPlatform() !== 'android') return;
const { registerPlugin } = await import('@capacitor/core');
if (!this._bgAudioPlugin) {
this._bgAudioPlugin = registerPlugin('BackgroundAudio');
}
if (isPlaying) {
await this._bgAudioPlugin.start();
} else {
await this._bgAudioPlugin.stop();
}
} catch {
// Not running in Capacitor or plugin unavailable — ignore
} finally {
this._bgAudioPending = false;
}
})();
}
updateMediaSessionPositionState() {

View file

@ -57,6 +57,8 @@ async function getButterchurnPresets(...args) {
// Module-level state for AutoEQ (persists across re-initializations)
let _autoeqIndex = [];
let _graphAbortController = null;
let _graphResizeObserver = null;
export async function initializeSettings(scrobbler, player, api, ui) {
// Restore last active settings tab
@ -1231,6 +1233,154 @@ export async function initializeSettings(scrobbler, player, api, ui) {
});
}
// ========================================
// 16-Band Graphic Equalizer (Legacy EQ mode)
// ========================================
const GEQ_LABELS = [
'25',
'40',
'63',
'100',
'160',
'250',
'400',
'630',
'1K',
'1.6K',
'2.5K',
'4K',
'6.3K',
'10K',
'16K',
'20K',
];
const geqBandsContainer = document.getElementById('graphic-eq-bands');
const geqPreampSlider = document.getElementById('graphic-eq-preamp-slider');
const geqPreampValue = document.getElementById('graphic-eq-preamp-value');
const geqPresetSelect = document.getElementById('graphic-eq-preset-select');
const geqResetBtn = document.getElementById('graphic-eq-reset-btn');
const legacyGeqBandsContainer = document.getElementById('legacy-graphic-eq-bands');
const legacyGeqPreampSlider = document.getElementById('legacy-graphic-eq-preamp-slider');
const legacyGeqPreampValue = document.getElementById('legacy-graphic-eq-preamp-value');
const legacyGeqPresetSelect = document.getElementById('legacy-graphic-eq-preset-select');
const legacyGeqResetBtn = document.getElementById('legacy-graphic-eq-reset-btn');
const geqPreampSliders = [geqPreampSlider, legacyGeqPreampSlider].filter(Boolean);
const geqPreampValues = [geqPreampValue, legacyGeqPreampValue].filter(Boolean);
const geqPresetSelects = [geqPresetSelect, legacyGeqPresetSelect].filter(Boolean);
let geqGains = equalizerSettings.getGraphicEqGains() || new Array(16).fill(0);
let geqPreamp = equalizerSettings.getGraphicEqPreamp() || 0;
const geqRange = equalizerSettings.getRange();
// Sync all slider UIs across both containers
const geqSyncAllSliders = () => {
geqGains.forEach((g, i) => {
['geq', 'legacy-geq'].forEach((prefix) => {
const sl = document.getElementById(`${prefix}-slider-${i}`);
const vl = document.getElementById(`${prefix}-value-${i}`);
if (sl) sl.value = g;
if (vl) vl.textContent = `${g > 0 ? '+' : ''}${g.toFixed(1)}`;
});
});
};
// Build 16 vertical slider bands into a container
const buildGeqBands = (container, idPrefix) => {
if (!container) return;
container.innerHTML = '';
GEQ_LABELS.forEach((_label, i) => {
const band = document.createElement('div');
band.className = 'graphic-eq-band';
const valueLabel = document.createElement('span');
valueLabel.className = 'graphic-eq-band-value';
valueLabel.textContent = `${geqGains[i] > 0 ? '+' : ''}${geqGains[i].toFixed(1)}`;
valueLabel.id = `${idPrefix}-value-${i}`;
const sliderWrap = document.createElement('div');
sliderWrap.className = 'graphic-eq-band-slider-wrap';
const slider = document.createElement('input');
slider.type = 'range';
slider.min = geqRange.min;
slider.max = geqRange.max;
slider.step = '0.1';
slider.value = geqGains[i];
slider.id = `${idPrefix}-slider-${i}`;
slider.setAttribute('aria-label', `${GEQ_LABELS[i]} Hz`);
slider.addEventListener('input', () => {
const gain = parseFloat(slider.value);
geqGains[i] = gain;
equalizerSettings.setGraphicEqGains(geqGains);
audioContextManager.setGraphicEqBandGain(i, gain);
geqSyncAllSliders();
geqPresetSelects.forEach((s) => (s.value = ''));
});
sliderWrap.appendChild(slider);
const freqLabel = document.createElement('span');
freqLabel.className = 'graphic-eq-band-label';
freqLabel.textContent = GEQ_LABELS[i];
band.appendChild(valueLabel);
band.appendChild(sliderWrap);
band.appendChild(freqLabel);
container.appendChild(band);
});
};
buildGeqBands(geqBandsContainer, 'geq');
buildGeqBands(legacyGeqBandsContainer, 'legacy-geq');
// Wire up preamp sliders
geqPreampSliders.forEach((slider) => {
slider.value = geqPreamp;
slider.addEventListener('input', () => {
geqPreamp = parseFloat(slider.value);
const text = `${geqPreamp.toFixed(1)} dB`;
geqPreampValues.forEach((v) => (v.textContent = text));
geqPreampSliders.forEach((s) => {
if (s !== slider) s.value = geqPreamp;
});
equalizerSettings.setGraphicEqPreamp(geqPreamp);
audioContextManager.setGraphicEqPreamp(geqPreamp);
});
});
geqPreampValues.forEach((v) => (v.textContent = `${geqPreamp} dB`));
// Wire up preset selects
geqPresetSelects.forEach((select) => {
select.addEventListener('change', () => {
const key = select.value;
if (!key) return;
const presets = getPresetsForBandCount(16);
const preset = presets[key];
if (!preset) return;
geqGains = [...preset.gains];
equalizerSettings.setGraphicEqGains(geqGains);
audioContextManager.setGraphicEqAllGains(geqGains);
geqSyncAllSliders();
geqPresetSelects.forEach((s) => {
if (s !== select) s.value = key;
});
});
});
// Wire up reset buttons
[geqResetBtn, legacyGeqResetBtn].filter(Boolean).forEach((btn) => {
btn.addEventListener('click', () => {
geqGains = new Array(16).fill(0);
equalizerSettings.setGraphicEqGains(geqGains);
audioContextManager.setGraphicEqAllGains(geqGains);
geqSyncAllSliders();
geqPresetSelects.forEach((s) => (s.value = 'flat'));
});
});
// ========================================
// Precision AutoEQ - Redesigned Equalizer
// ========================================
@ -1362,6 +1512,15 @@ export async function initializeSettings(scrobbler, player, api, ui) {
/**
* Draw the frequency response graph with Original, Target, and Corrected curves
*/
let _drawGraphRafId = null;
const scheduleDrawAutoEQGraph = () => {
if (_drawGraphRafId) return;
_drawGraphRafId = requestAnimationFrame(() => {
_drawGraphRafId = null;
drawAutoEQGraph();
});
};
const drawAutoEQGraph = () => {
if (!autoeqCanvas) return;
const activeBands = getActiveBands();
@ -1859,18 +2018,82 @@ export async function initializeSettings(scrobbler, player, api, ui) {
if (autoeqCanvas) {
autoeqCanvas.addEventListener('mousedown', (e) => {
const coords = getCanvasCoords(e);
const nodeIdx = findClosestNode(coords.x, coords.y, 18);
let nodeIdx = findClosestNode(coords.x, coords.y, 18);
if (nodeIdx >= 0) {
// Clicked directly on a node - start dragging
draggedNode = nodeIdx;
autoeqCanvas.style.cursor = 'grabbing';
e.preventDefault();
} else {
// Clicked empty space - find nearest node (no threshold) and snap it
nodeIdx = findClosestNode(coords.x, coords.y, Infinity);
if (nodeIdx >= 0) {
const bands = getActiveBands();
if (bands && bands[nodeIdx]) {
const rect = autoeqCanvas.getBoundingClientRect();
const padLeft = 40,
padRight = 10,
padTop = 10,
padBottom = 30;
const w = rect.width - padLeft - padRight;
const h = rect.height - padTop - padBottom;
const isParam = currentMode === 'parametric';
const dbCenter = isParam ? 0 : 75;
const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
const dbMin = dbCenter - dbHalf;
const dbMax = dbCenter + dbHalf;
// Snap frequency to click position
const freq = xToFreq(coords.x - padLeft, w);
bands[nodeIdx].freq = Math.max(20, Math.min(20000, freq));
// Snap gain to click position
if (isParam) {
const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax);
bands[nodeIdx].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
} else {
const corrGain = interpolate(bands[nodeIdx].freq, autoeqCorrectedCurve || []);
const newDb = yToDb(coords.y - padTop, h, dbMin, dbMax);
const gainDelta = newDb - corrGain;
bands[nodeIdx].gain = Math.max(-30, Math.min(30, bands[nodeIdx].gain + gainDelta * 0.3));
}
draggedNode = nodeIdx;
autoeqCanvas.style.cursor = 'grabbing';
computeCorrectedCurve();
applyBandsToAudio(bands);
drawAutoEQGraph();
renderBandControls(bands);
e.preventDefault();
}
}
}
});
autoeqCanvas.addEventListener('mousemove', (e) => {
const coords = getCanvasCoords(e);
const bands = getActiveBands();
if (draggedNode !== null && bands) {
// Helper to compute canvas-relative coords from any mouse event (even outside the canvas)
const getCanvasCoordsFromEvent = (e) => {
const rect = autoeqCanvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
// Clean up previous document-level listeners and observer on re-initialization
if (_graphAbortController) _graphAbortController.abort();
_graphAbortController = new AbortController();
const graphSignal = _graphAbortController.signal;
if (_graphResizeObserver) {
_graphResizeObserver.disconnect();
_graphResizeObserver = null;
}
// Document-level mousemove so dragging continues outside the canvas
document.addEventListener(
'mousemove',
(e) => {
if (draggedNode === null) return;
const bands = getActiveBands();
if (!bands) return;
const coords = getCanvasCoordsFromEvent(e);
const rect = autoeqCanvas.getBoundingClientRect();
const padLeft = 40,
padRight = 10,
@ -1907,34 +2130,49 @@ export async function initializeSettings(scrobbler, player, api, ui) {
graphAnimFrame = null;
});
}
},
{ signal: graphSignal }
);
// Canvas-only mousemove for hover cursor changes (when not dragging)
autoeqCanvas.addEventListener('mousemove', (e) => {
if (draggedNode !== null) return; // dragging is handled by document listener
const coords = getCanvasCoords(e);
const padLeft = 40;
if (coords.x <= padLeft + 10) {
autoeqCanvas.style.cursor = 'ns-resize';
if (hoveredNode !== null) {
hoveredNode = null;
drawAutoEQGraph();
}
} else {
const padLeft = 40;
if (coords.x <= padLeft + 10) {
autoeqCanvas.style.cursor = 'ns-resize';
if (hoveredNode !== null) {
hoveredNode = null;
drawAutoEQGraph();
}
} else {
const newHovered = findClosestNode(coords.x, coords.y, 18);
if (newHovered !== hoveredNode) {
hoveredNode = newHovered;
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
drawAutoEQGraph();
}
const newHovered = findClosestNode(coords.x, coords.y, 18);
if (newHovered !== hoveredNode) {
hoveredNode = newHovered;
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
drawAutoEQGraph();
}
}
});
autoeqCanvas.addEventListener('mouseup', () => {
draggedNode = null;
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
});
// Document-level mouseup so drag ends even if cursor is outside the canvas
document.addEventListener(
'mouseup',
() => {
if (draggedNode !== null) {
draggedNode = null;
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
}
},
{ signal: graphSignal }
);
autoeqCanvas.addEventListener('mouseleave', () => {
draggedNode = null;
// Only reset hover state, NOT drag state (drag continues outside canvas)
hoveredNode = null;
autoeqCanvas.style.cursor = 'crosshair';
if (draggedNode === null) {
autoeqCanvas.style.cursor = 'crosshair';
}
drawAutoEQGraph();
});
@ -2038,74 +2276,131 @@ export async function initializeSettings(scrobbler, player, api, ui) {
{ passive: false }
);
// Touch support
// Touch support - snap nearest node on empty space touch, continue drag outside canvas
let touchNodeIdx = -1;
autoeqCanvas.addEventListener(
'touchstart',
(e) => {
const touch = e.touches[0];
const coords = {
x: touch.clientX - autoeqCanvas.getBoundingClientRect().left,
y: touch.clientY - autoeqCanvas.getBoundingClientRect().top,
};
const rect = autoeqCanvas.getBoundingClientRect();
const coords = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
touchNodeIdx = findClosestNode(coords.x, coords.y, 25);
if (touchNodeIdx >= 0) {
draggedNode = touchNodeIdx;
e.preventDefault();
} else {
// Snap nearest node to touch position
touchNodeIdx = findClosestNode(coords.x, coords.y, Infinity);
if (touchNodeIdx >= 0) {
const bands = getActiveBands();
if (bands && bands[touchNodeIdx]) {
const padLeft = 40,
padRight = 10,
padTop = 10,
padBottom = 30;
const w = rect.width - padLeft - padRight;
const h = rect.height - padTop - padBottom;
const isParam = currentMode === 'parametric';
const dbCenter = isParam ? 0 : 75;
const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
const dbMin = dbCenter - dbHalf;
const dbMax = dbCenter + dbHalf;
const freq = xToFreq(coords.x - padLeft, w);
bands[touchNodeIdx].freq = Math.max(20, Math.min(20000, freq));
if (isParam) {
const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax);
bands[touchNodeIdx].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
} else {
const corrGain = interpolate(bands[touchNodeIdx].freq, autoeqCorrectedCurve || []);
const newDb = yToDb(coords.y - padTop, h, dbMin, dbMax);
const gainDelta = newDb - corrGain;
bands[touchNodeIdx].gain = Math.max(
-30,
Math.min(30, bands[touchNodeIdx].gain + gainDelta * 0.3)
);
}
draggedNode = touchNodeIdx;
computeCorrectedCurve();
applyBandsToAudio(bands);
drawAutoEQGraph();
renderBandControls(bands);
e.preventDefault();
}
}
}
},
{ passive: false }
);
autoeqCanvas.addEventListener(
// Document-level touchmove so dragging continues outside canvas
document.addEventListener(
'touchmove',
(e) => {
if (draggedNode === null) return;
const tBands = getActiveBands();
if (draggedNode !== null && tBands) {
const touch = e.touches[0];
const rect = autoeqCanvas.getBoundingClientRect();
const coords = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
const padLeft = 40,
padRight = 10,
padTop = 10,
padBottom = 30;
const w = rect.width - padLeft - padRight;
const h = rect.height - padTop - padBottom;
if (!tBands) return;
const freq = xToFreq(coords.x - padLeft, w);
tBands[draggedNode].freq = Math.max(20, Math.min(20000, freq));
const touch = e.touches[0];
const rect = autoeqCanvas.getBoundingClientRect();
const coords = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
const padLeft = 40,
padRight = 10,
padTop = 10,
padBottom = 30;
const w = rect.width - padLeft - padRight;
const h = rect.height - padTop - padBottom;
if (currentMode === 'parametric') {
const newGain = yToDb(coords.y - padTop, h, -graphDbHalfParametric, graphDbHalfParametric);
tBands[draggedNode].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
}
const isParam = currentMode === 'parametric';
const dbCenter = isParam ? 0 : 75;
const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
const dbMin = dbCenter - dbHalf;
const dbMax = dbCenter + dbHalf;
computeCorrectedCurve();
applyBandsToAudio(tBands);
if (!graphAnimFrame) {
graphAnimFrame = requestAnimationFrame(() => {
drawAutoEQGraph();
renderBandControls(tBands);
graphAnimFrame = null;
});
}
e.preventDefault();
const freq = xToFreq(coords.x - padLeft, w);
tBands[draggedNode].freq = Math.max(20, Math.min(20000, freq));
if (isParam) {
const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax);
tBands[draggedNode].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
} else {
const corrGain = interpolate(tBands[draggedNode].freq, autoeqCorrectedCurve || []);
const newDb = yToDb(coords.y - padTop, h, dbMin, dbMax);
const gainDelta = newDb - corrGain;
tBands[draggedNode].gain = Math.max(-30, Math.min(30, tBands[draggedNode].gain + gainDelta * 0.3));
}
computeCorrectedCurve();
applyBandsToAudio(tBands);
if (!graphAnimFrame) {
graphAnimFrame = requestAnimationFrame(() => {
drawAutoEQGraph();
renderBandControls(tBands);
graphAnimFrame = null;
});
}
e.preventDefault();
},
{ passive: false }
{ passive: false, signal: graphSignal }
);
autoeqCanvas.addEventListener('touchend', () => {
draggedNode = null;
touchNodeIdx = -1;
});
document.addEventListener(
'touchend',
() => {
if (draggedNode !== null) {
draggedNode = null;
touchNodeIdx = -1;
}
},
{ signal: graphSignal }
);
// Resize observer for graph
if (autoeqGraphWrapper) {
const ro = new ResizeObserver(() => {
drawAutoEQGraph();
_graphResizeObserver = new ResizeObserver(() => {
scheduleDrawAutoEQGraph();
});
ro.observe(autoeqGraphWrapper);
_graphResizeObserver.observe(autoeqGraphWrapper);
}
}
@ -2166,7 +2461,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
freqVal.textContent = `${formatFreq(bands[i].freq)} Hz`;
computeCorrectedCurve();
applyBandsToAudio(bands);
drawAutoEQGraph();
scheduleDrawAutoEQGraph();
});
gainSlider.addEventListener('input', () => {
@ -2176,7 +2471,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
gainVal.textContent = `${bands[i].gain > 0 ? '+' : ''}${bands[i].gain.toFixed(1)} dB`;
computeCorrectedCurve();
applyBandsToAudio(bands);
drawAutoEQGraph();
scheduleDrawAutoEQGraph();
});
qSlider.addEventListener('input', () => {
@ -2186,7 +2481,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
qVal.textContent = bands[i].q.toFixed(2);
computeCorrectedCurve();
applyBandsToAudio(bands);
drawAutoEQGraph();
scheduleDrawAutoEQGraph();
});
const typeSelect = control.querySelector('.autoeq-type-select');
@ -2196,7 +2491,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
bands[i].type = typeSelect.value;
computeCorrectedCurve();
applyBandsToAudio(bands);
drawAutoEQGraph();
scheduleDrawAutoEQGraph();
});
});
};
@ -2257,6 +2552,22 @@ export async function initializeSettings(scrobbler, player, api, ui) {
});
}
// Database section collapse
const autoeqDatabaseToggle = document.getElementById('autoeq-database-toggle');
const autoeqDatabaseCollapse = document.getElementById('autoeq-database-collapse');
const autoeqDatabaseBody = document.getElementById('autoeq-database-body');
if (autoeqDatabaseToggle) {
autoeqDatabaseToggle.addEventListener('click', () => {
if (autoeqDatabaseCollapse) autoeqDatabaseCollapse.classList.toggle('collapsed');
if (autoeqDatabaseBody)
autoeqDatabaseBody.style.display = autoeqDatabaseBody.style.display === 'none' ? '' : 'none';
if (autoeqDatabaseCollapse) {
const isExpanded = !autoeqDatabaseCollapse.classList.contains('collapsed');
autoeqDatabaseCollapse.setAttribute('aria-expanded', String(isExpanded));
}
});
}
// ========================================
// Set Status Message
// ========================================
@ -3113,13 +3424,14 @@ export async function initializeSettings(scrobbler, player, api, ui) {
const presetRow = document.getElementById('autoeq-preset-row');
const parametricProfiles = document.getElementById('autoeq-parametric-profiles');
const speakerSavedSection = document.getElementById('speaker-saved-section');
const legacySection = document.getElementById('graphic-eq-section');
// Reset interactive state on switch
draggedNode = null;
hoveredNode = null;
// Graph always visible in all modes
if (graphSection) graphSection.style.display = '';
// Graph visible in all modes except legacy
if (graphSection) graphSection.style.display = mode === 'legacy' ? 'none' : '';
// Only show shared AutoEq button in AutoEQ mode
if (autoeqRunBtn) autoeqRunBtn.style.display = mode === 'autoeq' ? '' : 'none';
@ -3132,6 +3444,20 @@ export async function initializeSettings(scrobbler, player, api, ui) {
if (parametricProfiles) parametricProfiles.style.display = 'none';
if (speakerSection) speakerSection.style.display = 'none';
if (speakerSavedSection) speakerSavedSection.style.display = 'none';
if (legacySection) legacySection.style.display = 'none';
if (mode === 'legacy') {
if (legacySection) legacySection.style.display = '';
// Disable parametric EQ entirely - only graphic EQ active to save resources
audioContextManager.isEQEnabled = false;
audioContextManager.toggleGraphicEQ(equalizerSettings.isEnabled());
equalizerSettings.setGraphicEqEnabled(true);
} else {
// Disable graphic EQ entirely - only parametric EQ active to save resources
audioContextManager.isEQEnabled = equalizerSettings.isEnabled();
audioContextManager.toggleGraphicEQ(false);
equalizerSettings.setGraphicEqEnabled(false);
}
if (mode === 'autoeq') {
if (controlsSection) controlsSection.style.display = '';
@ -4400,7 +4726,15 @@ export async function initializeSettings(scrobbler, player, api, ui) {
equalizerSettings.setEnabled(enabled);
updateEQContainerVisibility(enabled);
audioContextManager.toggleEQ(enabled);
if (currentMode === 'legacy') {
// Legacy mode uses graphic EQ chain
audioContextManager.isEQEnabled = false;
audioContextManager.toggleGraphicEQ(enabled);
} else {
// AutoEQ/Parametric/Speaker modes use parametric EQ chain
audioContextManager.toggleEQ(enabled);
audioContextManager.toggleGraphicEQ(false);
}
});
}
@ -4515,7 +4849,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
// Restore EQ mode on startup
const savedEQMode = localStorage.getItem(EQ_MODE_KEY);
if (savedEQMode && ['autoeq', 'parametric', 'speaker'].includes(savedEQMode)) {
if (savedEQMode && ['autoeq', 'parametric', 'speaker', 'legacy'].includes(savedEQMode)) {
setEQMode(savedEQMode);
}

View file

@ -1538,6 +1538,71 @@ export const equalizerSettings = {
clearLastHeadphone() {
localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
},
// --- Graphic EQ (16-band) separate storage ---
GEQ_ENABLED_KEY: 'graphic-eq-enabled',
GEQ_GAINS_KEY: 'graphic-eq-gains',
GEQ_PREAMP_KEY: 'graphic-eq-preamp',
isGraphicEqEnabled() {
try {
return localStorage.getItem(this.GEQ_ENABLED_KEY) === 'true';
} catch {
return false;
}
},
setGraphicEqEnabled(enabled) {
try {
localStorage.setItem(this.GEQ_ENABLED_KEY, String(!!enabled));
} catch {
/* ignore */
}
},
getGraphicEqGains() {
try {
const stored = localStorage.getItem(this.GEQ_GAINS_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length === 16) {
return parsed.map((v) => (Number.isFinite(v) ? v : 0));
}
}
} catch {
/* ignore */
}
return new Array(16).fill(0);
},
setGraphicEqGains(gains) {
try {
localStorage.setItem(this.GEQ_GAINS_KEY, JSON.stringify(gains));
} catch {
/* ignore */
}
},
getGraphicEqPreamp() {
try {
const val = localStorage.getItem(this.GEQ_PREAMP_KEY);
if (val !== null) {
const num = parseFloat(val);
return Number.isFinite(num) ? num : 0;
}
return 0;
} catch {
return 0;
}
},
setGraphicEqPreamp(db) {
try {
localStorage.setItem(this.GEQ_PREAMP_KEY, String(db));
} catch {
/* ignore */
}
},
};
export const monoAudioSettings = {

View file

@ -304,7 +304,7 @@ export function createProjectCardHTML(era, _artist, sheetId, trackCount) {
${playBtnHTML}
</div>
<div class="card-info">
<h4 class="card-title">${escapeHtml(era.name)}</h4>
<h3 class="card-title">${escapeHtml(era.name)}</h3>
<p class="card-subtitle">${era.timeline || 'Unreleased'} ${trackCount} tracks</p>
</div>
</div>
@ -784,7 +784,7 @@ export async function renderUnreleasedPage(container) {
<img class="card-image" src="${coverImage}" alt="${artist.name}" loading="lazy" onerror="this.src='assets/logo.svg'">
</div>
<div class="card-info">
<h4 class="card-title">${artist.name}</h4>
<h3 class="card-title">${artist.name}</h3>
<p class="card-subtitle">Unreleased Music</p>
</div>
`;

111
js/ui.js
View file

@ -94,7 +94,6 @@ const setFullscreenUIToggleIcon = (button, visualizerOnlyMode) => {
};
const isMobileFullscreenViewport = () => window.matchMedia('(max-width: 768px)').matches;
function sortTracks(tracks, sortType) {
if (sortType === 'custom') return [...tracks];
const sorted = [...tracks];
@ -541,11 +540,44 @@ export class UIRenderer {
`;
}
getCoverHTML(cover, alt, className = 'card-image', loading = 'lazy', videoCoverUrl = null) {
const imageUrl = this.api.getCoverUrl(cover);
getCoverHTML(
cover,
alt,
className = 'card-image',
loading = 'lazy',
videoCoverUrl = null,
isEditorsPick = false,
type = 'album'
) {
let size = '320';
if (className === 'track-item-cover') {
size = '80';
} else if (type === 'artist') {
size = '160';
}
const imageUrl =
type === 'artist' ? this.api.getArtistPictureUrl(cover, size) : this.api.getCoverUrl(cover, size);
if (videoCoverUrl) {
return `<video src="${videoCoverUrl}" poster="${imageUrl}" class="${className}" alt="${alt}" preload="metadata" playsinline muted></video>`;
}
if (
isEditorsPick &&
cover &&
typeof cover === 'string' &&
!cover.startsWith('http') &&
!cover.startsWith('blob:') &&
!cover.startsWith('assets/')
) {
const formattedId = String(cover).replace(/-/g, '/');
const tidalUrl = `https://resources.tidal.com/images/${formattedId}/320x320.jpg`;
const wsrvUrl = `https://wsrv.nl/?url=${encodeURIComponent(tidalUrl)}&w=250&h=250&output=webp`;
const fetchPriorityAttr = loading === 'eager' ? ' fetchpriority="high"' : '';
return `<img src="${wsrvUrl}" class="${className}" alt="${alt}" loading="${loading}"${fetchPriorityAttr}>`;
}
return `<img src="${imageUrl}" class="${className}" alt="${alt}" loading="${loading}">`;
}
@ -575,7 +607,7 @@ export class UIRenderer {
const cardContent = `
<div class="card-info">
<h4 class="card-title">${title}</h4>
<h3 class="card-title">${title}</h3>
${subtitle ? `<p class="card-subtitle">${subtitle}</p>` : ''}
</div>`;
@ -656,7 +688,15 @@ export class UIRenderer {
createUserPlaylistCardHTML(playlist, customSubtitle = null) {
let imageHTML = '';
if (playlist.cover) {
imageHTML = `<img src="${playlist.cover}" alt="${playlist.name}" class="card-image" loading="lazy">`;
imageHTML = this.getCoverHTML(
playlist.cover,
escapeHtml(playlist.name),
'card-image',
playlist._lazy === false ? 'eager' : 'lazy',
null,
playlist._isEditorsPick || false,
'album'
);
} else {
const tracks = playlist.tracks || [];
let uniqueCovers = playlist.images || [];
@ -747,8 +787,10 @@ export class UIRenderer {
album.cover,
escapeHtml(album.title),
'card-image',
'lazy',
album.videoCoverUrl
album._lazy === false ? 'eager' : 'lazy',
album.videoCoverUrl,
album._isEditorsPick || false,
'album'
),
actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="album" title="Add to Liked">
@ -819,7 +861,15 @@ export class UIRenderer {
href: `/artist/${artist.id}`,
title: escapeHtml(artist.name),
subtitle: '',
imageHTML: `<img src="${this.api.getArtistPictureUrl(artist.picture)}" alt="${escapeHtml(artist.name)}" class="card-image" loading="lazy">`,
imageHTML: this.getCoverHTML(
artist.picture,
escapeHtml(artist.name),
'card-image',
artist._lazy === false ? 'eager' : 'lazy',
null,
artist._isEditorsPick || false,
'artist'
),
actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="artist" title="Add to Liked">
${this.createHeartIcon(false)}
@ -1337,6 +1387,7 @@ export class UIRenderer {
this.setupFullscreenDismissHandle(overlay);
this.setupFullscreenLyricsToggle(overlay);
await this.refreshFullscreenVisualizerState(activeElement);
await this.refreshFullscreenVisualizerState(activeElement);
}
updateFullscreenLyricsVisibility(overlay = document.getElementById('fullscreen-cover-overlay')) {
@ -1544,8 +1595,7 @@ export class UIRenderer {
const visualizerBtn = document.getElementById('fs-visualizer-btn');
const toggleBtn = document.getElementById('toggle-ui-btn');
const isVideoTrack = this.player?.currentTrack?.type === 'video';
const enabled =
!isVideoTrack && !this.fullscreenVisualizerSuppressed && !isMobileFullscreenViewport();
const enabled = !isVideoTrack && !this.fullscreenVisualizerSuppressed && !isMobileFullscreenViewport();
if (!overlay) return;
@ -1630,7 +1680,6 @@ export class UIRenderer {
}
this.fullscreenVisualizerSuppressed = false;
visualizerSettings.setEnabled(true);
await this.refreshFullscreenVisualizerState(this.player?.activeElement);
if (!overlay.classList.contains('visualizer-active')) {
@ -1840,7 +1889,6 @@ export class UIRenderer {
toggleBtn.removeEventListener('click', handleToggle);
};
}
setupFullscreenControls() {
const playBtn = document.getElementById('fs-play-pause-btn');
const prevBtn = document.getElementById('fs-prev-btn');
@ -3012,6 +3060,8 @@ export class UIRenderer {
audioQuality: item.audioQuality,
mediaMetadata: item.mediaMetadata,
type: 'ALBUM',
_lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
};
cardsHTML.push(this.createAlbumCardHTML(album));
itemsToStore.push({ el: null, data: album, type: 'album' });
@ -3019,6 +3069,8 @@ export class UIRenderer {
// Fall back to API call for legacy format
const result = await this.api.getAlbum(item.id);
if (result && result.album) {
result.album._lazy = cardsHTML.length >= 6;
result.album._isEditorsPick = true;
cardsHTML.push(this.createAlbumCardHTML(result.album));
itemsToStore.push({ el: null, data: result.album, type: 'album' });
}
@ -3041,6 +3093,8 @@ export class UIRenderer {
releaseDate: item.releaseDate,
type: 'ALBUM',
_href: `/userplaylist/${item.id}`,
_lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
})
);
itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' });
@ -3052,6 +3106,8 @@ export class UIRenderer {
id: item.id,
name: item.name,
picture: item.picture,
_lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
};
cardsHTML.push(this.createArtistCardHTML(artist));
itemsToStore.push({ el: null, data: artist, type: 'artist' });
@ -3059,6 +3115,8 @@ export class UIRenderer {
// Fall back to API call
const artist = await this.api.getArtist(item.id);
if (artist) {
artist._lazy = cardsHTML.length >= 6;
artist._isEditorsPick = true;
cardsHTML.push(this.createArtistCardHTML(artist));
itemsToStore.push({ el: null, data: artist, type: 'artist' });
}
@ -3075,6 +3133,8 @@ export class UIRenderer {
audioQuality: item.audioQuality,
mediaMetadata: item.mediaMetadata,
duration: item.duration,
_lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
};
cardsHTML.push(this.createTrackCardHTML(track));
itemsToStore.push({ el: null, data: track, type: 'track' });
@ -3082,6 +3142,8 @@ export class UIRenderer {
// Fall back to API call
const track = await this.api.getTrackMetadata(item.id);
if (track) {
track._lazy = cardsHTML.length >= 6;
track._isEditorsPick = true;
cardsHTML.push(this.createTrackCardHTML(track));
itemsToStore.push({ el: null, data: track, type: 'track' });
}
@ -3094,6 +3156,8 @@ export class UIRenderer {
cover: item.cover,
tracks: item.tracks || [],
numberOfTracks: item.numberOfTracks || (item.tracks ? item.tracks.length : 0),
_lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
};
const subtitle = item.username ? `by ${item.username}` : null;
cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle));
@ -3101,6 +3165,8 @@ export class UIRenderer {
} else {
const playlist = await syncManager.getPublicPlaylist(item.id);
if (playlist) {
playlist._lazy = cardsHTML.length >= 6;
playlist._isEditorsPick = true;
const subtitle = item.username ? `by ${item.username}` : null;
cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle));
itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' });
@ -3625,6 +3691,8 @@ export class UIRenderer {
const titleEl = document.getElementById('album-detail-title');
const metaEl = document.getElementById('album-detail-meta');
const prodEl = document.getElementById('album-detail-producer');
const rateCriticsEl = document.getElementById('album-detail-ratings-critics');
const rateUsersEl = document.getElementById('album-detail-ratings-users');
const tracklistContainer = document.getElementById('album-detail-tracklist');
const playBtn = document.getElementById('play-album-btn');
if (playBtn) playBtn.innerHTML = `${SVG_PLAY(20)}<span>Play Album</span>`;
@ -3638,6 +3706,8 @@ export class UIRenderer {
titleEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
metaEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 200px; max-width: 80%;"></div>';
prodEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 200px; max-width: 80%;"></div>';
rateCriticsEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 200px; max-width: 80%;"></div>';
rateUsersEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 200px; max-width: 80%;"></div>';
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
@ -3748,6 +3818,23 @@ export class UIRenderer {
`By <a href="/artist/${album.artist.id}">${album.artist.name}</a>` +
(firstCopyright ? `${firstCopyright}` : '');
async function fetchAotyWorker(album, artist) {
try {
const response = await fetch(
`https://aoty-critics.samidy.workers.dev/?artist=${artist}&album=${album}`
);
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>`;
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) {
rateCriticsEl.innerHTML = `<a style="color: var(--muted-foreground);">Unable to Fetch Critic Score</a>`;
rateUsersEl.innerHTML = `<a style="color: var(--muted-foreground);">Unable to Fetch User Score</a>`;
}
}
fetchAotyWorker(album.title, album.artist.name);
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>

View file

@ -33,6 +33,25 @@ export class Visualizer {
// ---- CACHED STATE ----
this._lastPrimaryColor = '';
this._resizeBound = () => this.resize();
this._backgroundPaused = false;
// 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
// thread time — the main cause of audio skipping with AutoEQ in background.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && this.isActive) {
this._backgroundPaused = true;
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
} else if (document.visibilityState === 'visible' && this._backgroundPaused) {
this._backgroundPaused = false;
if (this.isActive && !this.animationId) {
this.animate();
}
}
});
}
/**

27
lhci.yml Normal file
View file

@ -0,0 +1,27 @@
target: static
assert:
assertions:
# Performance
- first-contentful-paint:warn < 3000
- interactive:warn < 7000
- lcp-lazy-loaded:off
- speed-index:warn < 5000
# Accessibility (warn if below 85)
- accessibility:warn < 85
# Best Practices
- best-practices:warn < 85
# SEO
- seo:warn < 85
upload:
target: temporary-public-storage
collect:
numberOfRuns: 3
settings:
preset: desktop
# Headless Chrome for CI
chromeFlags: '--no-sandbox --disable-gpu'

View file

@ -17,7 +17,9 @@
"lint:css": "stylelint \"**/*.css\"",
"lint:html": "htmlhint \"**/*.html\" --ignore=\"dist/**,legacy/**,node_modules/**\"",
"lint": "bun run lint:js && bun run lint:css && bun run lint:html",
"format": "prettier --write ."
"format": "prettier --write .",
"lhci": "lhci",
"lhci:autorun": "npm run build && lhci autorun"
},
"repository": {
"type": "git",
@ -33,6 +35,7 @@
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.2.0",
"@lhci/cli": "^0.14.0",
"@testing-library/dom": "^10.4.1",
"@types/node": "^25.3.5",
"@vitest/browser-playwright": "^4.1.2",
@ -47,9 +50,11 @@
"stylelint": "^17.6.0",
"stylelint-config-standard": "^39.0.1",
"stylelint-config-standard-scss": "^16.0.0",
"terser": "^5.46.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-purgecss": "^0.2.13",
"vite-plugin-pwa": "^1.2.0"
},
"overrides": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
public/assets/banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1 @@
[]

View file

@ -1,4 +1,29 @@
[
{
"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-5.json",
"label": "Spring 2026",
"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",
"label": "Spring 2026",

View file

@ -254,5 +254,69 @@
"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"]
}
}
]

11
public/robots.txt Normal file
View file

@ -0,0 +1,11 @@
# robots.txt for https://monochrome.tf/
User-agent: *
Allow: /
# Avoid indexing internal endpoints and auth flows.
Disallow: /functions/
Disallow: /api/
Disallow: /auth/
Sitemap: https://monochrome.tf/sitemap.xml

43
public/sitemap.xml Normal file
View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://monochrome.tf/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://monochrome.tf/search</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://monochrome.tf/library</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://monochrome.tf/recent</loc>
<changefreq>daily</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://monochrome.tf/podcasts</loc>
<changefreq>daily</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://monochrome.tf/unreleased</loc>
<changefreq>daily</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://monochrome.tf/parties</loc>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://monochrome.tf/donate</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>

View file

@ -5,6 +5,8 @@ import path from 'path';
import uploadPlugin from './vite-plugin-upload.js';
import blobAssetPlugin from './vite-plugin-blob.js';
import svgUse from './vite-plugin-svg-use.js';
// import purgecss from 'vite-plugin-purgecss';
import purgecss from 'vite-plugin-purgecss';
import { execSync } from 'child_process';
import { playwright } from '@vitest/browser-playwright';
@ -66,8 +68,35 @@ export default defineConfig((_options) => {
outDir: 'dist',
emptyOutDir: true,
sourcemap: true,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
rollupOptions: {
treeshake: true,
},
},
plugins: [
purgecss({
variables: false, // DO NOT REMOVE UNUSED VARIABLES (breaks web components like am-lyrics)
safelist: {
standard: [
/^am-lyrics/,
/^lyplus-/,
'sidepanel',
'side-panel',
'active',
'show',
/^data-/,
/^modal-/,
],
deep: [/^am-lyrics/],
greedy: [/^lyplus-/, /sidepanel/, /side-panel/],
},
}),
authGatePlugin(),
uploadPlugin(),
blobAssetPlugin(),