diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml
index e7acda1..ee3d1d8 100644
--- a/.github/workflows/copilot-setup-steps.yml
+++ b/.github/workflows/copilot-setup-steps.yml
@@ -1,39 +1,38 @@
-name: "Copilot Setup Steps"
+name: 'Copilot Setup Steps'
# Automatically run the setup steps when they are changed to allow for easy validation, and
# allow manual testing through the repository's "Actions" tab
on:
- workflow_dispatch:
- push:
- paths:
- - .github/workflows/copilot-setup-steps.yml
- pull_request:
- paths:
- - .github/workflows/copilot-setup-steps.yml
+ workflow_dispatch:
+ push:
+ paths:
+ - .github/workflows/copilot-setup-steps.yml
+ pull_request:
+ paths:
+ - .github/workflows/copilot-setup-steps.yml
jobs:
- # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
- copilot-setup-steps:
- runs-on: ubuntu-latest
+ # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
+ copilot-setup-steps:
+ runs-on: ubuntu-latest
- # Set the permissions to the lowest permissions possible needed for your steps.
- # Copilot will be given its own token for its operations.
- permissions:
- # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission.
- # If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
- contents: read
+ # Set the permissions to the lowest permissions possible needed for your steps.
+ # Copilot will be given its own token for its operations.
+ permissions:
+ contents: read
+ workflows: write
- steps:
- - name: Checkout code
- uses: actions/checkout@v6
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
- - name: Setup Bun
- uses: oven-sh/setup-bun@v1
- with:
- bun-version: latest
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v1
+ with:
+ bun-version: latest
- - name: Install dependencies
- run: bun install
+ - name: Install dependencies
+ run: bun install
- - name: Install playwright dependencies
- run: bun run install:playwright
+ - name: Install playwright dependencies
+ run: bun run install:playwright
diff --git a/.github/workflows/editors-picks.yml b/.github/workflows/editors-picks.yml
index 3745ac8..0ce8f3f 100644
--- a/.github/workflows/editors-picks.yml
+++ b/.github/workflows/editors-picks.yml
@@ -25,7 +25,7 @@ jobs:
echo "Files changed in this commit:"
echo "$CHANGED"
if echo "$CHANGED" | grep -qE '^public/editors-picks\.json$|^public/editors-picks-old/'; then
- echo "Detected changes to generated files in this commit — backing off to avoid overwriting manual edits."
+ echo "Detected changes to generated files in this commit - backing off to avoid overwriting manual edits."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
@@ -37,6 +37,10 @@ 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: |
@@ -60,9 +64,19 @@ jobs:
label = m.group(1).strip()
break
- # Copy current picks to archive
+ # Copy current picks to archive, replacing monochrome URLs with UUIDs
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$')
+ 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)
+
with open(archive_path, "w") as f:
json.dump(current, f, indent=4)
@@ -89,7 +103,7 @@ 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/
+ git add public/editors-picks.json public/editors-picks-old/ public/editors-picks-images/
git diff --staged --quiet && echo "No changes to commit." && exit 0
git commit -m "chore: update editors picks"
git push
diff --git a/editors-picks-input.txt b/editors-picks-input.txt
index 6d8a390..b365994 100644
--- a/editors-picks-input.txt
+++ b/editors-picks-input.txt
@@ -20,4 +20,5 @@ album:423471869
album:250986538
album:509761344
album:15621057
-album:103897783
\ No newline at end of file
+album:103897783
+album:151728406
\ No newline at end of file
diff --git a/gen-editors-picks.py b/gen-editors-picks.py
index 95e102e..e6404e0 100644
--- a/gen-editors-picks.py
+++ b/gen-editors-picks.py
@@ -7,12 +7,17 @@ import re
import sys
import hashlib
import time
+import os
+import subprocess
+import shutil
+import tempfile
INPUT_FILE = "editors-picks-input.txt"
+IMAGES_DIR = "public/editors-picks-images"
COUNTRY = "US"
-# Tidal internal token — replace when expired
-TIDAL_TOKEN = "eyJraWQiOiJ2OU1GbFhqWSIsImFsZyI6IkVTMjU2In0.eyJ0eXBlIjoibzJfYWNjZXNzIiwic2NvcGUiOiIiLCJnVmVyIjowLCJzVmVyIjowLCJjaWQiOjEzNTU3LCJhdCI6IklOVEVSTkFMIiwiZXhwIjoxNzc1MjQ2NzQ2LCJpc3MiOiJodHRwczovL2F1dGgudGlkYWwuY29tL3YxIn0.ksUE4yhQ39IG7oHWk8DyJ91dwIoDVWGzvTAnpeDJ5p-_Gp0F_yO858xDO11AINBaahQCq0jlbqWqIaTqCTOjqg"
+# Tidal internal token replace when expired
+TIDAL_TOKEN = "eyJraWQiOiJ2OU1GbFhqWSIsImFsZyI6IkVTMjU2In0.eyJ0eXBlIjoibzJfYWNjZXNzIiwic2NvcGUiOiIiLCJnVmVyIjowLCJzVmVyIjowLCJjaWQiOjEzNTU3LCJhdCI6IklOVEVSTkFMIiwiZXhwIjoxNzc1MzY0MTQwLCJpc3MiOiJodHRwczovL2F1dGgudGlkYWwuY29tL3YxIn0.6ui6itHVQ-OXPF0F9mbf5KcKz1fKYJNsa1vBAj60upXpcN-DQG8JPKBlqJN6RuBEH8yhwYj2wh4YJ-TOOuO8DA"
TIDAL_HEADERS = {
"accept": "*/*",
@@ -83,6 +88,61 @@ def fetch_podcast(feed_id):
return podcast_get(f"/podcasts/byfeedid?id={feed_id}&pretty")
+# ── 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):
@@ -95,7 +155,7 @@ def transform_album(d):
"name": d.get("artist", {}).get("name"),
},
"releaseDate": d.get("releaseDate"),
- "cover": d.get("cover"),
+ "cover": process_cover(d.get("cover")),
"explicit": d.get("explicit"),
"audioQuality": d.get("audioQuality"),
"mediaMetadata": d.get("mediaMetadata"),
@@ -107,7 +167,7 @@ def transform_artist(d):
"type": "artist",
"id": d.get("id"),
"name": d.get("name"),
- "picture": d.get("picture"),
+ "picture": process_cover(d.get("picture")),
}
@@ -124,7 +184,7 @@ def transform_track(d):
"album": {
"id": album.get("id"),
"title": album.get("title"),
- "cover": album.get("cover"),
+ "cover": process_cover(album.get("cover")),
},
"duration": d.get("duration"),
"explicit": d.get("explicit"),
@@ -140,7 +200,7 @@ def transform_playlist(d):
"type": "playlist",
"id": d.get("uuid"),
"title": d.get("title"),
- "cover": cover,
+ "cover": process_cover(cover),
"numberOfTracks": d.get("numberOfTracks", 0),
}
@@ -153,7 +213,7 @@ def transform_userplaylist(d):
"type": "user-playlist",
"id": d.get("uuid"),
"name": d.get("title"),
- "cover": cover,
+ "cover": process_cover(cover),
"numberOfTracks": d.get("numberOfTracks", 0),
"username": creator.get("name"),
}
@@ -198,6 +258,8 @@ def read_items(path):
# ── Main ──────────────────────────────────────────────────────────────────────
+clear_images_dir()
+
FETCHERS = {
"album": (fetch_album, transform_album),
"artist": (fetch_artist, transform_artist),
@@ -212,7 +274,7 @@ picks = []
for item_type, item_id in items:
if item_type not in FETCHERS:
- print(f"Unknown type '{item_type}' for id {item_id!r} — skipping", file=sys.stderr)
+ print(f"Unknown type '{item_type}' for id {item_id!r} - skipping", file=sys.stderr)
continue
fetch_fn, transform_fn = FETCHERS[item_type]
data = fetch_fn(item_id)
diff --git a/index.html b/index.html
index 8ba694e..d6b1c6e 100644
--- a/index.html
+++ b/index.html
@@ -3,24 +3,79 @@
- Monochrome Music
+ Monochrome
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
diff --git a/js/app.js b/js/app.js
index 93022c1..74db58a 100644
--- a/js/app.js
+++ b/js/app.js
@@ -127,32 +127,36 @@ async function loadDownloadsModule() {
}
async function fetchcontributors() {
- const response = await fetch('https://api.samidy.com/api/contributors');
- const data1 = await response.json();
+ try {
+ const response = await fetch('https://api.samidy.com/api/contributors');
+ if (!response.ok) return;
+ const data1 = await response.json();
- const data = data1.filter(
- (user) => user.type !== 'Bot' && user.login !== 'edidealt' && user.login !== 'satanyahoo'
- );
+ const data = data1.filter(
+ (user) => user.type !== 'Bot' && user.login !== 'edidealt' && user.login !== 'satanyahoo'
+ );
- const edideaur = data.find((user) => user.login === 'edideaur');
- if (edideaur) {
- edideaur.contributions += data1.find((u) => u.login === 'edidealt')?.contributions || 0;
- edideaur.contributions += data1.find((u) => u.login === 'satanyahoo')?.contributions || 0;
- }
+ const edideaur = data.find((user) => user.login === 'edideaur');
+ if (edideaur) {
+ edideaur.contributions += data1.find((u) => u.login === 'edidealt')?.contributions || 0;
+ edideaur.contributions += data1.find((u) => u.login === 'satanyahoo')?.contributions || 0;
+ }
- const con = document.querySelector('.about-contributors');
+ const con = document.querySelector('.about-contributors');
+ if (!con) return;
- data.forEach((user) => {
- const userDIV = document.createElement('div');
- userDIV.innerHTML = `
-
-
- ${user.login}
- Contributions: ${user.contributions}
-
- `;
- con.appendChild(userDIV);
- });
+ data.forEach((user) => {
+ const userDIV = document.createElement('div');
+ userDIV.innerHTML = `
+
+
+ ${user.login}
+ Contributions: ${user.contributions}
+
+ `;
+ con.appendChild(userDIV);
+ });
+ } catch (e) {}
}
async function loadMetadataModule() {
@@ -500,7 +504,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize tracker
initTracker().catch(console.error);
- fetchcontributors();
+ await fetchcontributors();
const castBtn = document.getElementById('cast-btn');
initializeCasting(audioPlayer, castBtn);
diff --git a/js/lyrics.js b/js/lyrics.js
index e59ffae..484c5c1 100644
--- a/js/lyrics.js
+++ b/js/lyrics.js
@@ -278,13 +278,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'
);
}
diff --git a/js/player.js b/js/player.js
index f39bc67..5b779a0 100644
--- a/js/player.js
+++ b/js/player.js
@@ -487,7 +487,7 @@ export class Player {
const timeRemaining = duration - currentTime;
// Preload if we are in last 30 seconds of song
- const shouldPreload = (duration > 0 && timeRemaining <= 30);
+ const shouldPreload = duration > 0 && timeRemaining <= 30;
if (shouldPreload) {
this._pendingPreload = false;
@@ -537,7 +537,7 @@ export class Player {
albumPeakAmplitude: trackData.info.albumPeakAmplitude,
};
}
- } catch(e) {} // Fail silently
+ } catch (_e) {} // Fail silently
}
this.preloadCache.set(track.id, streamInfo);
@@ -546,30 +546,45 @@ export class Player {
// Warm connection and pre-fetch
if (!streamUrl.startsWith('blob:')) {
if (streamUrl.includes('.mpd') || streamUrl.includes('.m3u8')) {
- if (this.shakaInitialized && this.shakaPlayer && typeof this.shakaPlayer.preload === 'function') {
+ if (
+ this.shakaInitialized &&
+ this.shakaPlayer &&
+ typeof this.shakaPlayer.preload === 'function'
+ ) {
try {
let preloadConfig = undefined;
if (typeof this.shakaPlayer.getConfiguration === 'function') {
preloadConfig = this.shakaPlayer.getConfiguration();
- const stats = typeof this.shakaPlayer.getStats === 'function' ? this.shakaPlayer.getStats() : null;
+ const stats =
+ typeof this.shakaPlayer.getStats === 'function'
+ ? this.shakaPlayer.getStats()
+ : null;
if (stats && stats.estimatedBandwidth) {
preloadConfig.abr.defaultBandwidthEstimate = stats.estimatedBandwidth;
}
-
+
// Lock the preload to the exact current audio codec to prevent ABR mismatch,
// which forces the player to discard and re-fetch chunks on slow connections.
preloadConfig.abr.enabled = false;
try {
- const variants = typeof this.shakaPlayer.getVariantTracks === 'function' ? this.shakaPlayer.getVariantTracks() : [];
- const activeVariant = variants.find(v => v.active);
+ const variants =
+ typeof this.shakaPlayer.getVariantTracks === 'function'
+ ? this.shakaPlayer.getVariantTracks()
+ : [];
+ const activeVariant = variants.find((v) => v.active);
if (activeVariant && activeVariant.audioCodec) {
preloadConfig.preferredAudioCodecs = [activeVariant.audioCodec];
}
- } catch (e) {}
+ } catch (_e) {}
}
- const preloadManager = await this.shakaPlayer.preload(streamUrl, null, null, preloadConfig);
+ const preloadManager = await this.shakaPlayer.preload(
+ streamUrl,
+ null,
+ null,
+ preloadConfig
+ );
streamInfo.preloadManager = preloadManager;
- } catch (e) {
+ } catch (_e) {
// Ignore preload errors, will just load fresh
}
} else {
@@ -801,7 +816,7 @@ export class Player {
this.hls.destroy();
this.hls = null;
}
-
+
// Retain the initialized Shaka player if we are remaining on the same HTMLMediaElement
if (this.shakaInitialized && this.shakaPlayer) {
if (this.shakaPlayer.getMediaElement() !== activeElement) {
@@ -1015,14 +1030,17 @@ export class Player {
} else if (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) {
await this.shakaPlayer.attach(activeElement);
- const loadTarget = track.type == 'video' && this.preloadCache.has(track.id) ?
- (this.preloadCache.get(track.id).preloadManager || streamUrl) : streamUrl;
+ const loadTarget =
+ track.type == 'video' && this.preloadCache.has(track.id)
+ ? this.preloadCache.get(track.id).preloadManager || streamUrl
+ : streamUrl;
try {
await this.shakaPlayer.load(loadTarget);
} catch (e) {
- console.error("PreloadManager load Error:", e); if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl);
- else throw e;
+ console.error('PreloadManager load Error:', e);
+ if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl);
+ else throw e;
}
this.shakaInitialized = true;
@@ -1097,8 +1115,9 @@ export class Player {
await this.shakaPlayer.load(loadTarget);
}
} catch (e) {
- console.error("PreloadManager load Error:", e); if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl);
- else throw e;
+ console.error('PreloadManager load Error:', e);
+ if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl);
+ else throw e;
}
this.shakaInitialized = true;
@@ -1109,7 +1128,7 @@ export class Player {
this.updateAdaptiveQualityBadge();
- // Instantly trigger playback rather than explicitly waiting for 'canplay'
+ // Instantly trigger playback rather than explicitly waiting for 'canplay'
// which delays the event loop and natively adds gap/latency
await this.safePlay(activeElement);
} else {
diff --git a/js/taglib.worker.ts b/js/taglib.worker.ts
index 71a0183..cd1644d 100644
--- a/js/taglib.worker.ts
+++ b/js/taglib.worker.ts
@@ -145,7 +145,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
if (explicit !== undefined) {
if (isMp4) {
- // rtng is a byte item — must be set directly on the Mp4Tag
+ // rtng is a byte item - must be set directly on the Mp4Tag
const mp4Tag = underlying.tag();
mp4Tag.setItem('rtng', Mp4Item.fromByte(explicit ? 1 : 0));
} else {
diff --git a/public/assets/banner-twitter.jpg b/public/assets/banner-twitter.jpg
new file mode 100644
index 0000000..83a9ec3
Binary files /dev/null and b/public/assets/banner-twitter.jpg differ
diff --git a/public/assets/banner.jpg b/public/assets/banner.jpg
new file mode 100644
index 0000000..95f786d
Binary files /dev/null and b/public/assets/banner.jpg differ
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..e81a557
--- /dev/null
+++ b/public/robots.txt
@@ -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
diff --git a/public/sitemap.xml b/public/sitemap.xml
new file mode 100644
index 0000000..dcfdecf
--- /dev/null
+++ b/public/sitemap.xml
@@ -0,0 +1,43 @@
+
+
+
+ https://monochrome.tf/
+ daily
+ 1.0
+
+
+ https://monochrome.tf/search
+ daily
+ 0.8
+
+
+ https://monochrome.tf/library
+ daily
+ 0.8
+
+
+ https://monochrome.tf/recent
+ daily
+ 0.7
+
+
+ https://monochrome.tf/podcasts
+ daily
+ 0.7
+
+
+ https://monochrome.tf/unreleased
+ daily
+ 0.7
+
+
+ https://monochrome.tf/parties
+ weekly
+ 0.6
+
+
+ https://monochrome.tf/donate
+ monthly
+ 0.5
+
+
diff --git a/styles.css b/styles.css
index ce49775..2d3d2e4 100644
--- a/styles.css
+++ b/styles.css
@@ -1763,6 +1763,10 @@ input[type='search']::-webkit-search-cancel-button {
.settings-tab-content {
display: none;
+ max-width: 100%;
+ min-width: 0;
+ overflow-x: auto;
+ overflow-y: visible;
}
.settings-tab-content.active {
@@ -1770,14 +1774,6 @@ input[type='search']::-webkit-search-cancel-button {
animation: fade-in-slide-up 0.4s var(--ease-out-back);
}
-/* Prevent settings tab content from overflowing on small displays (fixes #313) */
-.settings-tab-content {
- max-width: 100%;
- min-width: 0;
- overflow-x: auto;
- overflow-y: visible;
-}
-
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -4301,6 +4297,7 @@ input:checked + .slider::before {
justify-content: center;
gap: 1rem;
margin-top: 1rem;
+ position: relative;
}
.fs-volume-btn {
@@ -5910,10 +5907,6 @@ img[src=''] {
color: white;
}
-#fullscreen-cover-overlay.is-video-mode .fullscreen-volume-container {
- margin-top: 0.5rem;
-}
-
#fullscreen-cover-overlay.ui-hidden .fullscreen-main-view,
#fullscreen-cover-overlay.ui-hidden .fullscreen-controls,
#fullscreen-cover-overlay.ui-hidden #fullscreen-next-track,
@@ -9881,10 +9874,10 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
.chat-msg {
margin-bottom: 0.5rem;
- animation: fadeIn 0.2s ease-out;
+ animation: fade-in 0.2s ease-out;
}
-@keyframes fadeIn {
+@keyframes fade-in {
from {
opacity: 0;
transform: translateY(5px);