Merge branch 'main' of github.com:monochrome-music/monochrome

This commit is contained in:
Samidy 2026-04-23 00:34:11 +03:00
commit 4ef66d8da9
30 changed files with 317 additions and 92 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/extension-help-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
assets/extension-help-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
bun.lockb

Binary file not shown.

16
extension/README.md Normal file
View file

@ -0,0 +1,16 @@
# Monochrome Tidal Origin
While the website works without the extension with the use of proxies, it is recommended to install it to prevent various annoying issues. The website works best with the extension on.
## Installation
1. Click on the **Code** button on the [home page](https://github.com/monochrome-music/monochrome) of the repo and click **Download ZIP**. (or download the [latest main branch ZIP](https://github.com/monochrome-music/monochrome/archive/refs/heads/main.zip))
2. Unpack the **.zip** archive with your archive program (like WinRar or 7Zip on Windows)
3. Open extensions page in your Chromium-based browser (Or `chrome://extensions/`) and make sure **Developer mode** is turned on.
4. Click on the **Load unpacked** and navigate to the directory you just unpacked.
![Extension Help - Enabling Dev Mode](/assets/extension-help-1.png)
5. Inside of it, there's a directory **extension**. **SELECT THAT DIRECTORY INSTEAD OF THE MAIN ONE!** It should contain file "manifest.json".
![Extension Help - Locating Correct Directory](/assets/extension-help-2.png)

4
extension/content.js Normal file
View file

@ -0,0 +1,4 @@
const s = document.createElement('script');
s.src = chrome.runtime.getURL('inject.js');
(document.head || document.documentElement).appendChild(s);
s.remove();

BIN
extension/icons/128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
extension/icons/16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
extension/icons/48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

1
extension/inject.js Normal file
View file

@ -0,0 +1 @@
window.__tidalOriginExtension = true;

51
extension/manifest.json Normal file
View file

@ -0,0 +1,51 @@
{
"manifest_version": 3,
"name": "Monochrome Tidal Bypass",
"version": "1.0.2",
"description": "Adds Origin: https://listen.tidal.com to Tidal CDN requests so audio plays without a proxy",
"browser_specific_settings": {
"gecko": {
"id": "monochrome-tidal-bypass@binimum.org",
"strict_min_version": "111.0",
"data_collection_permissions": {
"required": ["none"]
}
}
},
"permissions": ["declarativeNetRequest", "scripting", "declarativeNetRequestWithHostAccess"],
"host_permissions": [
"*://*.tidal.com/*",
"*://monochrome.tf/*",
"*://monochrome.samidy.com/*",
"*://lossless.wtf/*",
"*://localhost/*"
],
"declarative_net_request": {
"rule_resources": [
{
"id": "rules",
"enabled": true,
"path": "rules.json"
}
]
},
"content_scripts": [
{
"matches": ["*://monochrome.samidy.com/*", "*://monochrome.tf/*", "*://lossless.wtf/*", "*://localhost/*"],
"js": ["content.js"],
"run_at": "document_start",
"all_frames": true
}
],
"icons": {
"16": "icons/16.png",
"48": "icons/48.png",
"128": "icons/128.png"
},
"web_accessible_resources": [
{
"resources": ["inject.js"],
"matches": ["*://monochrome.samidy.com/*", "*://monochrome.tf/*", "*://lossless.wtf/*", "*://localhost/*"]
}
]
}

55
extension/rules.json Normal file
View file

@ -0,0 +1,55 @@
[
{
"id": 1,
"priority": 1,
"action": {
"type": "modifyHeaders",
"responseHeaders": [
{
"header": "Access-Control-Allow-Origin",
"operation": "set",
"value": "*"
},
{
"header": "Access-Control-Allow-Methods",
"operation": "set",
"value": "GET, POST, PUT, DELETE, PATCH, OPTIONS"
},
{
"header": "Access-Control-Allow-Headers",
"operation": "set",
"value": "*"
}
]
},
"condition": {
"urlFilter": "||tidal.com*",
"initiatorDomains": ["monochrome.tf", "monochrome.samidy.com"],
"resourceTypes": ["xmlhttprequest", "media"]
}
},
{
"id": 2,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{
"header": "Origin",
"operation": "set",
"value": "https://listen.tidal.com"
},
{
"header": "Referer",
"operation": "set",
"value": "https://listen.tidal.com/"
}
]
},
"condition": {
"urlFilter": "||tidal.com*",
"initiatorDomains": ["monochrome.tf", "monochrome.samidy.com"],
"resourceTypes": ["xmlhttprequest", "media"]
}
}
]

View file

@ -117,6 +117,12 @@
<body>
<audio id="audio-player" style="display: none"></audio>
<video id="video-player" style="display: none"></video>
<script>
if (window.__tidalOriginExtension) {
document.getElementById('audio-player').crossOrigin = 'anonymous';
document.getElementById('video-player').crossOrigin = 'anonymous';
}
</script>
<div id="context-menu">
<ul>
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
@ -1732,8 +1738,11 @@
<div id="server-disruption-banner" class="server-disruption-sidebar" style="display: none">
<span class="disruption-icon">&#9888;</span>
<span
>Services are currently unstable. <br /><br />For Hi-Res streaming, use Chrome or
Safari.</span
>Services are currently unstable. <br /><br />Use the extension
<a href="https://github.com/monochrome-music/monochrome/tree/main/extension"
>here</a
>
for the best experience.</span
>
<button id="dismiss-disruption-btn" class="disruption-dismiss" title="Dismiss">
&times;
@ -4063,6 +4072,7 @@
</div>
<select id="streaming-quality-setting">
<option value="auto">Auto (Adaptive)</option>
<option value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit)</option>
<option value="LOSSLESS">Lossless (16-bit)</option>
<option value="HIGH">AAC 320kbps</option>
<option value="LOW">AAC 96kbps</option>

View file

@ -1590,9 +1590,12 @@ class HiFiClient {
const attr = inc.attributes ?? ({} as JsonApiIncludeAttributes);
let pic_id: string | null = null;
const art_data = inc.relationships?.profileArt?.data;
if (Array.isArray(art_data) && art_data.length > 0) {
const artwork = artworks_map[art_data[0].id];
const art_refs_artist = (() => {
const d = inc.relationships?.profileArt?.data;
return Array.isArray(d) ? d : d ? [d as JsonApiRef] : [];
})();
if (art_refs_artist.length > 0) {
const artwork = artworks_map[art_refs_artist[0].id];
const files = artwork?.attributes?.files;
if (Array.isArray(files) && files[0]?.href) {
pic_id = HiFiClient.#extractUuidFromTidalUrl(files[0].href);

View file

@ -677,7 +677,35 @@ const syncManager = {
user_folders: Object.values(userFolders).filter((f) => f && typeof f === 'object'),
};
await database.importData(convertedData);
// Safety check: if we had local data but merged result is completely empty, something went wrong.
// Do NOT call importData as it would wipe the user's local stores.
const hadLocalData =
localData.tracks.length > 0 ||
localData.albums.length > 0 ||
localData.artists.length > 0 ||
localData.playlists.length > 0 ||
localData.mixes.length > 0 ||
localData.history.length > 0 ||
localData.userPlaylists.length > 0 ||
localData.userFolders.length > 0;
const isConvertedEmpty =
convertedData.favorites_tracks.length === 0 &&
convertedData.favorites_albums.length === 0 &&
convertedData.favorites_artists.length === 0 &&
convertedData.favorites_playlists.length === 0 &&
convertedData.favorites_mixes.length === 0 &&
convertedData.history_tracks.length === 0 &&
convertedData.user_playlists.length === 0 &&
convertedData.user_folders.length === 0;
if (hadLocalData && isConvertedEmpty) {
console.warn(
'[PocketBase] Sync aborted: local data exists but merged result is empty. Preserving local data to prevent accidental wipe.'
);
} else {
await database.importData(convertedData, true);
}
await new Promise((resolve) => setTimeout(resolve, 300));
window.dispatchEvent(new CustomEvent('library-changed'));

View file

@ -11,6 +11,7 @@ import { preferDolbyAtmosSettings, trackDateSettings, devModeSettings } from './
import { APICache } from './cache.js';
import { DashDownloader } from './dash-downloader.ts';
import { HlsDownloader } from './hls-downloader.js';
import { getProxyUrl } from './proxy-utils.js';
import { loadFfmpeg, FfmpegError, ffmpeg } from './ffmpeg.js';
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
import { isCustomFormat } from './ffmpegFormats.ts';
@ -1768,7 +1769,7 @@ export class LosslessAPI {
if (streamUrl.startsWith('blob:')) {
try {
const downloader = new DashDownloader();
blob = await downloader.downloadDashStream(streamUrl, {
blob = await downloader.downloadDashStream(getProxyUrl(streamUrl), {
signal: options.signal,
onProgress,
calculateDashBytes: calculateDashBytes ?? true,
@ -1787,7 +1788,7 @@ export class LosslessAPI {
} else if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
try {
const downloader = new HlsDownloader();
blob = await downloader.downloadHlsStream(streamUrl, {
blob = await downloader.downloadHlsStream(getProxyUrl(streamUrl), {
signal: options.signal,
onProgress,
});
@ -1812,7 +1813,7 @@ export class LosslessAPI {
/* ignore HEAD failure; proceed with GET */
}
const response = await fetch(streamUrl, {
const response = await fetch(getProxyUrl(streamUrl), {
cache: 'no-store',
signal: options.signal,
});

View file

@ -412,6 +412,13 @@ async function uploadCoverImage(file) {
document.addEventListener('DOMContentLoaded', async () => {
await modernSettings.waitPending();
// Request persistent storage to reduce risk of browser wiping data on updates or cleanup
if (navigator.storage && navigator.storage.persist) {
navigator.storage.persist().catch(() => {
// Ignore errors; persistence is a best-effort request
});
}
if (import.meta.env.DEV) {
window.monochrome = {
HiFiClient,
@ -467,31 +474,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const audioPlayer = document.getElementById('audio-player');
// i love ios and macos!!!! webkit fucking SUCKS BULLSHIT sorry ios/macos heads yall getting lossless only playback
// Use isIos from platform-detection (set before UA spoof in index.html) so detection works on real iOS.
if (isIos || isSafari) {
const qualitySelect = document.getElementById('streaming-quality-setting');
const downloadQualitySelect = document.getElementById('download-quality-setting');
const removeHiRes = (select) => {
if (!select) return;
const option = select.querySelector('option[value="HI_RES_LOSSLESS"]');
if (option) option.remove();
};
removeHiRes(qualitySelect);
removeHiRes(downloadQualitySelect);
if (isIos) {
document.querySelector('#hi-res-download-warning').style.display = '';
}
const currentQualitySetting = localStorage.getItem('playback-quality');
if (!currentQualitySetting || currentQualitySetting === 'HI_RES_LOSSLESS') {
localStorage.setItem('playback-quality', 'LOSSLESS');
}
}
const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
const currentQuality = localStorage.getItem('playback-quality') || 'HI_RES_LOSSLESS';
await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality);
// Initialize tracker

View file

@ -483,6 +483,25 @@ class AudioContextManager {
this.audioContext = new AudioContext();
}
if (window.__tidalOriginExtension) {
if (!this.sources.has(audioElement)) {
const src = this.audioContext.createMediaElementSource(audioElement);
this.sources.set(audioElement, src);
}
this.source = this.sources.get(audioElement);
try {
this.audioContext.destination.channelCount = Math.min(this.audioContext.destination.maxChannelCount, 8);
this.audioContext.destination.channelCountMode = 'explicit';
this.audioContext.destination.channelInterpretation = 'discrete';
} catch {
// Some browsers may not support changing destination channel count
}
this.binauralDsp = new BinauralDSP(this.audioContext);
void this._loadBinauralSettings();
}
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.7;
@ -502,12 +521,16 @@ class AudioContextManager {
this.monoMergerNode = this.audioContext.createChannelMerger(2);
if (window.__tidalOriginExtension) {
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`);
setTimeout(() => {
if (this.audioContext && this.audioContext.state !== 'running') {
if (this.audioContext && this.audioContext.state !== 'running' && (window.__tidalOriginExtension ? this.source : true)) {
this.audioContext.resume().catch((e) => {
console.warn('[AudioContext] Auto-resume failed:', e);
});
@ -529,7 +552,32 @@ class AudioContextManager {
}
if (this.audio === audioElement) return;
this.audio = audioElement;
if (window.__tidalOriginExtension) {
try {
if (this.source) {
try {
this.source.disconnect();
} catch {
// node may already be disconnected
}
}
this.audio = audioElement;
if (!this.sources.has(audioElement)) {
this.sources.set(audioElement, this.audioContext.createMediaElementSource(audioElement));
}
this.source = this.sources.get(audioElement);
if (this.isInitialized) {
this._connectGraph();
}
} catch (e) {
console.warn('changeSource failed:', e);
}
} else {
this.audio = audioElement;
}
}
/**

View file

@ -30,7 +30,7 @@ export class DashDownloader {
await Promise.all(
urls.map(async (url) => {
const result = await fetch(url, { method: 'HEAD', signal });
const result = await fetch(getProxyUrl(url), { method: 'HEAD', signal });
if (result.ok) {
const contentLength = result.headers.get('Content-Length');
@ -75,7 +75,7 @@ export class DashDownloader {
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, totalSize ?? undefined, i, totalSegments));
const url = urls[i];
const url = getProxyUrl(urls[i]);
const segmentResponse = await fetch(url, { signal });
if (!segmentResponse.ok) {

View file

@ -85,8 +85,15 @@ export class MusicDatabase {
const store = transaction.objectStore(storeName);
const request = callback(store);
let result;
if (request) {
request.onsuccess = () => {
result = request.result;
};
}
transaction.oncomplete = () => {
resolve(request?.result);
resolve(result);
};
transaction.onerror = (event) => {
reject(event.target.error);
@ -448,6 +455,25 @@ export class MusicDatabase {
async importData(data, clear = false) {
const db = await this.open();
// Safety check: if clear=true but all data is empty, skip to avoid wiping existing data
if (clear) {
const allEmpty = [
data.favorites_tracks,
data.favorites_albums,
data.favorites_artists,
data.favorites_playlists,
data.favorites_mixes,
data.history_tracks,
data.user_playlists,
data.user_folders,
].every((arr) => !arr || (Array.isArray(arr) ? arr.length === 0 : Object.keys(arr).length === 0));
if (allEmpty) {
console.warn('[importData] Aborting: clear=true but all import data is empty. Existing data preserved.');
return false;
}
}
const importStore = async (storeName, items) => {
if (items === undefined) return false;
@ -481,9 +507,10 @@ export class MusicDatabase {
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
// force clear on first sync
console.log(`Clearing ${storeName} to Make Sure Everythings Good`);
store.clear();
if (clear) {
console.log(`[importData] Clearing ${storeName} before import`);
store.clear();
}
itemsArray.forEach((item) => {
if (item.id && typeof item.id === 'string' && !isNaN(item.id)) {

View file

@ -1,4 +1,5 @@
import { SegmentedDownloadProgress } from './progressEvents';
import { getProxyUrl } from './proxy-utils';
export class HlsDownloader {
constructor() {}
@ -6,12 +7,12 @@ export class HlsDownloader {
async downloadHlsStream(masterUrl, options = {}) {
const { onProgress, signal } = options;
const response = await fetch(masterUrl, { signal });
const response = await fetch(getProxyUrl(masterUrl), { signal });
const masterText = await response.text();
const variantUrl = this.getBestVariantUrl(masterUrl, masterText);
const mediaResponse = await fetch(variantUrl, { signal });
const mediaResponse = await fetch(getProxyUrl(variantUrl), { signal });
const mediaText = await mediaResponse.text();
const segments = this.parseMediaPlaylist(variantUrl, mediaText);
@ -29,7 +30,7 @@ export class HlsDownloader {
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, undefined, i, totalSegments));
const segmentUrl = segments[i];
const segmentResponse = await fetch(segmentUrl, { signal });
const segmentResponse = await fetch(getProxyUrl(segmentUrl), { signal });
if (!segmentResponse.ok) {
throw new Error(`Failed to fetch segment ${i}: ${segmentResponse.status}`);

View file

@ -22,6 +22,7 @@ import {
import { audioContextManager } from './audio-context.js';
import { isIos, isSafari } from './platform-detection.js';
import { db } from './db.js';
import { getProxyUrl } from './proxy-utils.js';
import { SVG_CLOCK, SVG_ATMOS } from './icons.js';
import { UIRenderer } from './ui.js';
@ -133,17 +134,25 @@ export class Player {
},
abr: {
enabled: true,
// Start with a low bandwidth estimate (200kbps) so it plays instantly
// on slow connections and smoothly scales UP to Hi-Fi if the connection allows.
defaultBandwidthEstimate: 100000,
switchInterval: 1, // Check more frequently
bandwidthDowngradeTarget: 0.8, // Downgrade more aggressively if bandwidth drops
switchInterval: 1,
bandwidthDowngradeTarget: 0.8,
restrictToElementSize: false,
},
mediaSource: {
codecSwitchingStrategy: 'smooth',
},
});
this.shakaPlayer.getNetworkingEngine().registerRequestFilter((type, request) => {
if (type === shaka.net.NetworkingEngine.RequestType.SEGMENT) {
const uris = request.uris;
for (let i = 0; i < uris.length; i++) {
if (uris[i].includes('tidal.com')) {
uris[i] = getProxyUrl(uris[i]);
}
}
}
});
this.shakaPlayer.addEventListener('adaptation', this.updateAdaptiveQualityBadge.bind(this));
this.shakaPlayer.addEventListener('variantchanged', this.updateAdaptiveQualityBadge.bind(this));
@ -1266,6 +1275,13 @@ export class Player {
// which delays the event loop and natively adds gap/latency
await this.safePlay(activeElement);
} else {
if (this.shakaInitialized) {
try {
this.shakaPlayer.unload();
this.shakaPlayer.detach();
} catch {}
this.shakaInitialized = false;
}
activeElement.src = streamUrl;
this.applyAudioEffects();
this.updateAdaptiveQualityBadge();

4
js/proxy-utils.js Normal file
View file

@ -0,0 +1,4 @@
export const getProxyUrl = (url) => {
if (window.__tidalOriginExtension) return url;
return `https://audio-proxy.binimum.org/proxy-audio?url=${url}`;
};

View file

@ -6573,7 +6573,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
reader.onload = async (event) => {
try {
const data = JSON.parse(event.target.result);
await db.importData(data);
await db.importData(data, true);
alert('Library imported successfully!');
window.location.reload(); // Simple way to refresh all state
} catch (err) {

View file

@ -79,7 +79,7 @@
"@svta/common-media-library": "^0.18.1",
"@types/wicg-file-system-access": "^2023.10.7",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@uimaxbai/am-lyrics": "^1.2.8",
"@uimaxbai/am-lyrics": "^1.2.9",
"@vitest/web-worker": "^4.1.2",
"appwrite": "^23.0.0",
"butterchurn": "^2.6.7",

View file

@ -1,33 +0,0 @@
import { HiFiClient } from './js/HiFi.ts';
import { LosslessAPI } from './js/api.js';
// mock out modules to make LosslessAPI load in bun
import { mock } from 'bun:test';
mock.module('./js/icons.ts', () => ({}));
mock.module('./js/settings.js', () => ({
devModeSettings: { isEnabled: () => false },
syncManager: {},
musicProviderSettings: {},
audioSettings: {},
apiSettings: {},
}));
globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
globalThis.window = { matchMedia: () => ({ matches: false }) };
async function test() {
await HiFiClient.initialize();
const api = new LosslessAPI({ getInstances: () => [] });
// mock cache
api.cache = { get: () => null, set: () => {} };
api.fetchWithRetry = async function (relativePath, options) {
console.log('fetchWithRetry called:', relativePath);
return HiFiClient.instance.query(relativePath);
};
const res = await api.search('coldplay');
console.log('Returned tracks:', res.tracks?.items?.length);
}
test().catch(console.error);

View file

@ -7,17 +7,17 @@
"types": ["vite/client", "node", "@types/wicg-file-system-access"],
"baseUrl": ".",
"paths": {
"!/*": ["node_modules/*"]
"!/*": ["node_modules/*"],
},
"allowJs": true,
"checkJs": false,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"ignoreDeprecations": "6.0",
"ignoreDeprecations": "5.0",
"skipLibCheck": true,
"noEmit": true
"noEmit": true,
},
"include": ["**/*.ts", "*.ts", "**/*.js", "*.js"],
"exclude": ["**/node_modules/*"]
"exclude": ["**/node_modules/*"],
}

View file

@ -1,14 +1,23 @@
import path from 'path';
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import authGatePlugin from './vite-plugin-auth-gate.js';
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 uploadPlugin from './vite-plugin-upload.js';
// import purgecss from 'vite-plugin-purgecss';
import purgecss from 'vite-plugin-purgecss';
import { execSync } from 'child_process';
import { playwright } from '@vitest/browser-playwright';
import { execSync } from 'child_process';
import purgecss from 'vite-plugin-purgecss';
function proxyAudioPlugin() {
return {
name: 'proxy-audio-dev',
configureServer(server) {
// No longer needed: local proxy-audio middleware replaced by remote proxy
},
};
}
function getGitCommitHash() {
try {
@ -80,6 +89,7 @@ export default defineConfig((_options) => {
},
},
plugins: [
proxyAudioPlugin(),
purgecss({
variables: false, // DO NOT REMOVE UNUSED VARIABLES (breaks web components like am-lyrics)
safelist: {