Merge branch 'main' of github.com:SamidyFR/monochrome

This commit is contained in:
Samidy 2026-02-14 22:40:44 +03:00
commit 2b9c9b344c
28 changed files with 604 additions and 99 deletions

View file

@ -10,6 +10,9 @@ MONOCHROME_DEV_PORT=5173
AUTH_ENABLED=false
AUTH_SECRET=change-me-to-a-random-string
FIREBASE_PROJECT_ID=monochrome-database
# Optional: toggle login providers (defaults to true when unset)
# AUTH_GOOGLE_ENABLED=true
# AUTH_EMAIL_ENABLED=true
# Optional: override the Firebase config for the login page (JSON string)
# FIREBASE_CONFIG={"apiKey":"...","authDomain":"...","projectId":"...","storageBucket":"...","messagingSenderId":"...","appId":"..."}
# Optional: set PocketBase URL (hides the field in settings when set)

View file

@ -27,6 +27,8 @@ This document explains the optional server-side login gate and what it implies f
- `AUTH_ENABLED=true` enables the gate (default is false).
- `AUTH_SECRET` is required when the gate is enabled. It signs the session cookie.
- `AUTH_GOOGLE_ENABLED` toggles Google sign-in on `/login` (default true).
- `AUTH_EMAIL_ENABLED` toggles email/password sign-in on `/login` (default true).
- `FIREBASE_PROJECT_ID` sets the Firebase project used to verify tokens.
- `FIREBASE_CONFIG` (JSON) injects config into the login page.
- `POCKETBASE_URL` hides the custom DB setting field.

View file

@ -4,7 +4,15 @@ import prettierConfig from 'eslint-config-prettier';
export default [
{
ignores: ['**/dist/**', '**/node_modules/**', '**/legacy/**', '**/bin/**', '**/www/**'],
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/legacy/**',
'**/bin/**',
'**/www/**',
'**/public/lib/**',
'**/public/neutralino.js',
],
},
js.configs.recommended,
prettierConfig,

View file

@ -1,6 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<script>
// Spoof User-Agent to bypass Google's embedded browser check
Object.defineProperty(navigator, 'userAgent', {
get: function () {
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
},
});
</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Monochrome Music</title>
@ -11,6 +19,20 @@
<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" />
<!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://api.fonts.coollabs.io" crossorigin />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://api.monochrome.tf" 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://www.gstatic.com" 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" />
@ -751,20 +773,22 @@
<aside class="sidebar">
<div class="sidebar-content">
<div class="sidebar-logo">
<svg
class="app-logo"
xmlns="http://www.w3.org/2000/svg"
width="200"
height="200"
viewBox="14.75 14.75 70.5 70.5"
>
<g fill="currentColor">
<path
d="M38.25 14.75H85.25V61.75H61.75V38.25H38.25ZM14.75 38.25H38.25V61.75H61.75V85.25H14.75Z"
/>
</g>
</svg>
<span>Monochrome</span>
<a href="https://monochrome.tf/" class="sidebar-logo-link">
<svg
class="app-logo"
xmlns="http://www.w3.org/2000/svg"
width="200"
height="200"
viewBox="14.75 14.75 70.5 70.5"
>
<g fill="currentColor">
<path
d="M38.25 14.75H85.25V61.75H61.75V38.25H38.25ZM14.75 38.25H38.25V61.75H61.75V85.25H14.75Z"
/>
</g>
</svg>
<span>Monochrome</span>
</a>
<button
id="sidebar-toggle"
class="btn-icon desktop-only"
@ -2336,6 +2360,7 @@
<div id="font-preset-section" class="font-section">
<select id="font-preset-select">
<option value="Inter">Inter (Default)</option>
<option value="Apple Music">Apple Music</option>
<option value="IBM Plex Mono">IBM Plex Mono</option>
<option value="Roboto">Roboto</option>
<option value="Open Sans">Open Sans</option>

View file

@ -22,18 +22,29 @@ export class AuthManager {
init() {
if (!auth) return;
console.log('[Auth] Initializing. Current URL:', window.location.href);
this.unsubscribe = onAuthStateChanged(auth, (user) => {
this.user = user;
console.log('[Auth] Auth state changed:', user ? user.email : 'No user');
this.updateUI(user);
this.authListeners.forEach((listener) => listener(user));
});
// Handle redirect result (for Linux/Mobile where popup might be blocked)
getRedirectResult(auth).catch((error) => {
console.error('Redirect Login failed:', error);
alert(`Login failed: ${error.message}`);
});
getRedirectResult(auth)
.then((result) => {
if (result) {
console.log('[Auth] Redirect result received:', result.user.email);
} else {
console.log('[Auth] No redirect result found.');
}
})
.catch((error) => {
console.error('[Auth] Redirect Login failed:', error);
alert(`Login failed: ${error.message}`);
});
}
onAuthStateChanged(callback) {
@ -50,6 +61,23 @@ export class AuthManager {
return;
}
// Check for Neutralino mode
const isNeutralino =
window.NL_MODE ||
window.location.search.includes('mode=neutralino') ||
(window.Neutralino && typeof window.Neutralino === 'object');
if (isNeutralino) {
try {
await signInWithRedirect(auth, provider);
return;
} catch (error) {
console.error('Redirect Login failed:', error);
alert(`Login failed: ${error.message}`);
throw error;
}
}
try {
const result = await signInWithPopup(auth, provider);

View file

@ -1,5 +1,4 @@
//js/app.js
import { LosslessAPI } from './api.js';
import { MusicAPI } from './music-api.js';
import {
apiSettings,
@ -266,7 +265,13 @@ document.addEventListener('DOMContentLoaded', async () => {
initTracker(player);
// Initialize desktop features if in Neutralino mode
if (typeof window !== 'undefined' && (window.NL_MODE || window.location.search.includes('mode=neutralino'))) {
if (
typeof window !== 'undefined' &&
(window.NL_MODE ||
window.location.search.includes('mode=neutralino') ||
(window.Neutralino && typeof window.Neutralino === 'object'))
) {
window.NL_MODE = true;
import('./desktop/desktop.js').then((m) => m.initDesktop(player));
}

View file

@ -81,12 +81,14 @@ class AudioContextManager {
this.analyser = null;
this.filters = [];
this.outputNode = null;
this.volumeNode = null;
this.isInitialized = false;
this.isEQEnabled = false;
this.isMonoAudioEnabled = false;
this.monoMergerNode = null;
this.currentGains = new Array(16).fill(0);
this.audio = null;
this.currentVolume = 1.0;
// Callbacks for audio graph changes (for visualizers like Butterchurn)
this._graphChangeCallbacks = [];
@ -131,6 +133,8 @@ class AudioContextManager {
if (this.isInitialized) return;
if (!audioElement) return;
this.audio = audioElement;
// Detect iOS - skip Web Audio initialization on iOS to avoid lock screen audio issues
// iOS suspends AudioContext when screen locks, and MediaSession controls don't count
// as user gestures to resume it, causing audio to play silently
@ -138,13 +142,12 @@ class AudioContextManager {
const isIOS = /iphone|ipad|ipod/.test(ua) || (ua.includes('mac') && navigator.maxTouchPoints > 1);
if (isIOS) {
console.log('[AudioContext] Skipping Web Audio initialization on iOS for lock screen compatibility');
this.isInitialized = true; // Mark as initialized to prevent repeated attempts
// Don't set isInitialized - let it remain false so isReady() returns false
// This prevents other code from trying to use the non-existent audio context
return;
}
try {
this.audio = audioElement;
const AudioContext = window.AudioContext || window.webkitAudioContext;
this.audioContext = new AudioContext();
@ -170,6 +173,10 @@ class AudioContextManager {
this.outputNode = this.audioContext.createGain();
this.outputNode.gain.value = 1;
// Create volume node
this.volumeNode = this.audioContext.createGain();
this.volumeNode.gain.value = this.currentVolume;
// Create mono audio merger node
this.monoMergerNode = this.audioContext.createChannelMerger(2);
@ -199,6 +206,11 @@ class AudioContextManager {
// Disconnect everything first
this.source.disconnect();
this.outputNode.disconnect();
if (this.volumeNode) {
this.volumeNode.disconnect();
}
this.analyser.disconnect();
if (this.monoMergerNode) {
try {
this.monoMergerNode.disconnect();
@ -207,13 +219,6 @@ class AudioContextManager {
}
}
// Only disconnect destination from analyser to preserve other taps (like Butterchurn)
try {
this.analyser.disconnect(this.audioContext.destination);
} catch {
// Ignore if not connected
}
let lastNode = this.source;
// Apply mono audio if enabled
@ -234,15 +239,17 @@ class AudioContextManager {
}
if (this.isEQEnabled && this.filters.length > 0) {
// EQ enabled: lastNode -> EQ filters -> output -> analyser -> destination
// EQ enabled: lastNode -> EQ filters -> output -> analyser -> volume -> destination
lastNode.connect(this.filters[0]);
this.outputNode.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
console.log('[AudioContext] EQ connected');
} else {
// EQ disabled: lastNode -> analyser -> destination
// EQ disabled: lastNode -> analyser -> volume -> destination
lastNode.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
console.log('[AudioContext] EQ bypassed');
}
@ -307,10 +314,22 @@ class AudioContextManager {
}
/**
* Check if initialized
* Check if initialized and active
*/
isReady() {
return this.isInitialized;
return this.isInitialized && this.audioContext !== null;
}
/**
* Set the volume level (0.0 to 1.0)
* @param {number} value - Volume level
*/
setVolume(value) {
this.currentVolume = Math.max(0, Math.min(1, value));
if (this.volumeNode && this.audioContext) {
const now = this.audioContext.currentTime;
this.volumeNode.gain.setTargetAtTime(this.currentVolume, now, 0.01);
}
}
/**

View file

@ -66,11 +66,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
scrobbler.updateNowPlaying(player.currentTrack);
}
// Resume AudioContext for waveform on mobile (iOS)
if (waveformGenerator.audioContext.state === 'suspended') {
waveformGenerator.audioContext.resume();
}
updateWaveform();
}

View file

@ -107,10 +107,10 @@ export class LastFMScrobbler {
console.log('Signature string:', signatureString);
try {
const { default: md5 } = await import('https://cdn.jsdelivr.net/npm/md5@2.3.0/+esm');
const { default: md5 } = await import('./md5.js');
return md5(signatureString);
} catch {
console.error('MD5 library not available');
} catch (e) {
console.error('MD5 library not available', e);
throw new Error('MD5 library required for Last.fm');
}
}

View file

@ -84,7 +84,7 @@ export class LibreFmScrobbler {
const signatureString = sortedKeys.map((key) => `${key}${filteredParams[key]}`).join('') + this.API_SECRET;
try {
const { default: md5 } = await import('https://cdn.jsdelivr.net/npm/md5@2.3.0/+esm');
const { default: md5 } = await import('./md5.js');
return md5(signatureString);
} catch {
console.error('MD5 library not available');

267
js/md5.js Normal file
View file

@ -0,0 +1,267 @@
/*
* JavaScript MD5
* https://github.com/blueimp/JavaScript-MD5
*
* Copyright 2011, Sebastian Tschan
* https://blueimp.net
*
* Licensed under the MIT license:
* https://opensource.org/licenses/MIT
*
* Based on
* A JavaScript implementation of the RSA Data Security, Inc. MD5 Message
* Digest Algorithm, as defined in RFC 1321.
* Version 2.2 Copyright (C) Paul Johnston 1999 - 2009
* Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet
* Distributed under the BSD License
* See http://pajhome.org.uk/crypt/md5 for more info.
*/
function md5(string, key, raw) {
function safeAdd(x, y) {
var lsw = (x & 0xffff) + (y & 0xffff);
var msw = (x >> 16) + (y >> 16) + (lsw >> 16);
return (msw << 16) | (lsw & 0xffff);
}
function bitRotateLeft(num, cnt) {
return (num << cnt) | (num >>> (32 - cnt));
}
function md5cmn(q, a, b, x, s, t) {
return safeAdd(bitRotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b);
}
function md5ff(a, b, c, d, x, s, t) {
return md5cmn((b & c) | (~b & d), a, b, x, s, t);
}
function md5gg(a, b, c, d, x, s, t) {
return md5cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function md5hh(a, b, c, d, x, s, t) {
return md5cmn(b ^ c ^ d, a, b, x, s, t);
}
function md5ii(a, b, c, d, x, s, t) {
return md5cmn(c ^ (b | ~d), a, b, x, s, t);
}
function binlMD5(x, len) {
x[len >> 5] |= 0x80 << (len % 32);
x[(((len + 64) >>> 9) << 4) + 14] = len;
var i;
var olda;
var oldb;
var oldc;
var oldd;
var a = 1732584193;
var b = -271733879;
var c = -1732584194;
var d = 271733878;
for (i = 0; i < x.length; i += 16) {
olda = a;
oldb = b;
oldc = c;
oldd = d;
a = md5ff(a, b, c, d, x[i], 7, -680876936);
d = md5ff(d, a, b, c, x[i + 1], 12, -389564586);
c = md5ff(c, d, a, b, x[i + 2], 17, 606105819);
b = md5ff(b, c, d, a, x[i + 3], 22, -1044525330);
a = md5ff(a, b, c, d, x[i + 4], 7, -176418897);
d = md5ff(d, a, b, c, x[i + 5], 12, 1200080426);
c = md5ff(c, d, a, b, x[i + 6], 17, -1473231341);
b = md5ff(b, c, d, a, x[i + 7], 22, -45705983);
a = md5ff(a, b, c, d, x[i + 8], 7, 1770035416);
d = md5ff(d, a, b, c, x[i + 9], 12, -1958414417);
c = md5ff(c, d, a, b, x[i + 10], 17, -42063);
b = md5ff(b, c, d, a, x[i + 11], 22, -1990404162);
a = md5ff(a, b, c, d, x[i + 12], 7, 1804603682);
d = md5ff(d, a, b, c, x[i + 13], 12, -40341101);
c = md5ff(c, d, a, b, x[i + 14], 17, -1502002290);
b = md5ff(b, c, d, a, x[i + 15], 22, 1236535329);
a = md5gg(a, b, c, d, x[i + 1], 5, -165796510);
d = md5gg(d, a, b, c, x[i + 6], 9, -1069501632);
c = md5gg(c, d, a, b, x[i + 11], 14, 643717713);
b = md5gg(b, c, d, a, x[i], 20, -373897302);
a = md5gg(a, b, c, d, x[i + 5], 5, -701558691);
d = md5gg(d, a, b, c, x[i + 10], 9, 38016083);
c = md5gg(c, d, a, b, x[i + 15], 14, -660478335);
b = md5gg(b, c, d, a, x[i + 4], 20, -405537848);
a = md5gg(a, b, c, d, x[i + 9], 5, 568446438);
d = md5gg(d, a, b, c, x[i + 14], 9, -1019803690);
c = md5gg(c, d, a, b, x[i + 3], 14, -187363961);
b = md5gg(b, c, d, a, x[i + 8], 20, 1163531501);
a = md5gg(a, b, c, d, x[i + 13], 5, -1444681467);
d = md5gg(d, a, b, c, x[i + 2], 9, -51403784);
c = md5gg(c, d, a, b, x[i + 7], 14, 1735328473);
b = md5gg(b, c, d, a, x[i + 12], 20, -1926607734);
a = md5hh(a, b, c, d, x[i + 5], 4, -378558);
d = md5hh(d, a, b, c, x[i + 8], 11, -2022574463);
c = md5hh(c, d, a, b, x[i + 11], 16, 1839030562);
b = md5hh(b, c, d, a, x[i + 14], 23, -35309556);
a = md5hh(a, b, c, d, x[i + 1], 4, -1530992060);
d = md5hh(d, a, b, c, x[i + 4], 11, 1272893353);
c = md5hh(c, d, a, b, x[i + 7], 16, -155497632);
b = md5hh(b, c, d, a, x[i + 10], 23, -1094730640);
a = md5hh(a, b, c, d, x[i + 13], 4, 681279174);
d = md5hh(d, a, b, c, x[i], 11, -358537222);
c = md5hh(c, d, a, b, x[i + 3], 16, -722521979);
b = md5hh(b, c, d, a, x[i + 6], 23, 76029189);
a = md5hh(a, b, c, d, x[i + 9], 4, -640364487);
d = md5hh(d, a, b, c, x[i + 12], 11, -421815835);
c = md5hh(c, d, a, b, x[i + 15], 16, 530742520);
b = md5hh(b, c, d, a, x[i + 2], 23, -995338651);
a = md5ii(a, b, c, d, x[i], 6, -198630844);
d = md5ii(d, a, b, c, x[i + 7], 10, 1126891415);
c = md5ii(c, d, a, b, x[i + 14], 15, -1416354905);
b = md5ii(b, c, d, a, x[i + 5], 21, -57434055);
a = md5ii(a, b, c, d, x[i + 12], 6, 1700485571);
d = md5ii(d, a, b, c, x[i + 3], 10, -1894986606);
c = md5ii(c, d, a, b, x[i + 10], 15, -1051523);
b = md5ii(b, c, d, a, x[i + 1], 21, -2054922799);
a = md5ii(a, b, c, d, x[i + 8], 6, 1873313359);
d = md5ii(d, a, b, c, x[i + 15], 10, -30611744);
c = md5ii(c, d, a, b, x[i + 6], 15, -1560198380);
b = md5ii(b, c, d, a, x[i + 13], 21, 1309151649);
a = md5ii(a, b, c, d, x[i + 4], 6, -145523070);
d = md5ii(d, a, b, c, x[i + 11], 10, -1120210379);
c = md5ii(c, d, a, b, x[i + 2], 15, 718787259);
b = md5ii(b, c, d, a, x[i + 9], 21, -343485551);
a = safeAdd(a, olda);
b = safeAdd(b, oldb);
c = safeAdd(c, oldc);
d = safeAdd(d, oldd);
}
return [a, b, c, d];
}
function binl2rstr(input) {
var i;
var output = '';
var length32 = input.length * 32;
for (i = 0; i < length32; i += 8) {
output += String.fromCharCode((input[i >> 5] >>> (i % 32)) & 0xff);
}
return output;
}
function rstr2binl(input) {
var i;
var output = Array(input.length >> 2);
for (i = 0; i < output.length; i += 1) {
output[i] = 0;
}
var length8 = input.length * 8;
for (i = 0; i < length8; i += 8) {
output[i >> 5] |= (input.charCodeAt(i / 8) & 0xff) << (i % 32);
}
return output;
}
function rstrMD5(s) {
return binl2rstr(binlMD5(rstr2binl(s), s.length * 8));
}
function rstrHMACMD5(key, data) {
var i;
var bkey = rstr2binl(key);
if (bkey.length > 16) {
bkey = binlMD5(bkey, key.length * 8);
}
var ipad = Array(16);
var opad = Array(16);
for (i = 0; i < 16; i += 1) {
ipad[i] = bkey[i] ^ 0x36363636;
opad[i] = bkey[i] ^ 0x5c5c5c5c;
}
var hash = binlMD5(ipad.concat(rstr2binl(data)), 512 + data.length * 8);
return binl2rstr(binlMD5(opad.concat(hash), 512 + 128));
}
function rstr2hex(input) {
try {
var hexTab = '0123456789abcdef';
var output = '';
var x;
var i;
for (i = 0; i < input.length; i += 1) {
x = input.charCodeAt(i);
output += hexTab.charAt((x >>> 4) & 0x0f) + hexTab.charAt(x & 0x0f);
}
return output;
} catch (e) {
return '';
}
}
function str2rstrUTF8(input) {
var output = '';
var i = -1;
var x;
var y;
while (++i < input.length) {
x = input.charCodeAt(i);
y = i + 1 < input.length ? input.charCodeAt(i + 1) : 0;
if (0xd800 <= x && x <= 0xdbff && 0xdc00 <= y && y <= 0xdfff) {
x = 0x10000 + ((x & 0x03ff) << 10) + (y & 0x03ff);
i++;
}
if (x <= 0x7f) {
output += String.fromCharCode(x);
} else if (x <= 0x7ff) {
output += String.fromCharCode(0xc0 | ((x >>> 6) & 0x1f), 0x80 | (x & 0x3f));
} else if (x <= 0xffff) {
output += String.fromCharCode(0xe0 | ((x >>> 12) & 0x0f), 0x80 | ((x >>> 6) & 0x3f), 0x80 | (x & 0x3f));
} else if (x <= 0x1fffff) {
output += String.fromCharCode(
0xf0 | ((x >>> 18) & 0x07),
0x80 | ((x >>> 12) & 0x3f),
0x80 | ((x >>> 6) & 0x3f),
0x80 | (x & 0x3f)
);
}
}
return output;
}
function rawMD5(s) {
return rstrMD5(str2rstrUTF8(s));
}
function hexMD5(s) {
return rstr2hex(rawMD5(s));
}
function rawHMACMD5(k, d) {
return rstrHMACMD5(str2rstrUTF8(k), str2rstrUTF8(d));
}
function hexHMACMD5(k, d) {
return rstr2hex(rawHMACMD5(k, d));
}
function md5(string, key, raw) {
if (!key) {
if (!raw) {
return hexMD5(string);
}
return rawMD5(string);
}
if (!raw) {
return hexHMACMD5(key, string);
}
return rawHMACMD5(key, string);
}
return md5(string, key, raw);
}
export default md5;

View file

@ -76,12 +76,12 @@ export class MusicAPI {
return api.getArtist(cleanId);
}
async getPlaylist(id, provider = null) {
async getPlaylist(id, _provider = null) {
// Playlists are always Tidal for now
return this.tidalAPI.getPlaylist(id);
}
async getMix(id, provider = null) {
async getMix(id, _provider = null) {
// Mixes are always Tidal for now
return this.tidalAPI.getMix(id);
}
@ -173,10 +173,4 @@ export class MusicAPI {
get settings() {
return this._settings;
}
// Extract stream URL from manifest (Tidal only)
extractStreamUrlFromManifest(manifest) {
// This is only available for Tidal
return this.tidalAPI.extractStreamUrlFromManifest(manifest);
}
}

View file

@ -111,8 +111,17 @@ export class Player {
// Calculate effective volume
const effectiveVolume = curvedVolume * scale;
// Apply to audio element
this.audio.volume = Math.max(0, Math.min(1, effectiveVolume));
// Apply to audio element and/or Web Audio graph
if (audioContextManager.isReady()) {
// If Web Audio is active, we apply volume there for better compatibility
// Especially on Linux where audio.volume might not affect the Web Audio graph
// We set audio.volume to 1.0 to avoid double-reduction, or keep it synced?
// Some browsers require audio.volume to be set for system media controls to show volume
this.audio.volume = 1.0;
audioContextManager.setVolume(effectiveVolume);
} else {
this.audio.volume = Math.max(0, Math.min(1, effectiveVolume));
}
}
applyAudioEffects() {
@ -213,6 +222,7 @@ export class Player {
// Must happen before audio.play() or audio won't route through Web Audio
if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio);
this.applyReplayGain();
}
await audioContextManager.resume();
@ -233,6 +243,7 @@ export class Player {
// Ensure audio context is active for iOS lock screen controls
if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio);
this.applyReplayGain();
}
await audioContextManager.resume();
this.playPrev();
@ -242,6 +253,7 @@ export class Player {
// Ensure audio context is active for iOS lock screen controls
if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio);
this.applyReplayGain();
}
await audioContextManager.resume();
this.playNext();

View file

@ -115,7 +115,7 @@ export class QobuzAPI {
}
// Get track details
async getTrack(id) {
async getTrack(_id) {
// Qobuz doesn't have a direct track endpoint
// Track metadata comes from search/album endpoints
// For playback, use getStreamUrl directly

View file

@ -217,14 +217,20 @@ export function initializeSettings(scrobbler, player, api, ui) {
return;
}
const authWindow = window.open('', '_blank');
let authWindow = null;
if (!window.Neutralino) {
authWindow = window.open('', '_blank');
}
lastfmConnectBtn.disabled = true;
lastfmConnectBtn.textContent = 'Opening Last.fm...';
try {
const { token, url } = await scrobbler.lastfm.getAuthUrl();
if (authWindow) {
if (window.Neutralino) {
await Neutralino.os.open(url);
} else if (authWindow) {
authWindow.location.href = url;
} else {
alert('Popup blocked! Please allow popups.');
@ -563,14 +569,20 @@ export function initializeSettings(scrobbler, player, api, ui) {
return;
}
const authWindow = window.open('', '_blank');
let authWindow = null;
if (!window.Neutralino) {
authWindow = window.open('', '_blank');
}
librefmConnectBtn.disabled = true;
librefmConnectBtn.textContent = 'Opening Libre.fm...';
try {
const { token, url } = await scrobbler.librefm.getAuthUrl();
if (authWindow) {
if (window.Neutralino) {
await Neutralino.os.open(url);
} else if (authWindow) {
authWindow.location.href = url;
} else {
alert('Popup blocked! Please allow popups.');
@ -1837,7 +1849,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
for (const storeName of stores) {
try {
await db.performTransaction(storeName, 'readwrite', (store) => store.clear());
} catch (e) {
} catch {
// Store might not exist, continue
}
}
@ -1845,7 +1857,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
console.log('Could not clear IndexedDB stores:', dbError);
// Try to delete the entire database as fallback
try {
const deleteRequest = indexedDB.deleteDatabase('monochrome-music');
const deleteRequest = indexedDB.deleteDatabase('monochromeDB');
await new Promise((resolve, reject) => {
deleteRequest.onsuccess = resolve;
deleteRequest.onerror = reject;
@ -1931,6 +1943,8 @@ function initializeFontSettings() {
);
} else if (value === 'monospace') {
fontSettings.loadPresetFont('monospace', 'monospace');
} else if (value === 'Apple Music') {
fontSettings.loadAppleMusicFont();
} else {
fontSettings.loadPresetFont(value, 'sans-serif');
}

View file

@ -1659,6 +1659,41 @@ export const fontSettings = {
document.documentElement.style.setProperty('--font-family', fontValue);
},
loadAppleMusicFont() {
const APPLE_FONT_LINK_ID = 'monochrome-apple-font';
// Remove any existing dynamic font links
let existingLink = document.getElementById(this.FONT_LINK_ID);
if (existingLink) {
existingLink.remove();
}
// Remove any existing @font-face styles
let existingStyle = document.getElementById(this.FONT_FACE_ID);
if (existingStyle) {
existingStyle.remove();
}
// Load Apple font CSS
let link = document.getElementById(APPLE_FONT_LINK_ID);
if (!link) {
link = document.createElement('link');
link.id = APPLE_FONT_LINK_ID;
link.rel = 'stylesheet';
link.href = '/fonts/apple/sf-pro-display.css';
document.head.appendChild(link);
}
this.setConfig({
type: 'preset',
family: 'Apple Music',
fallback: 'sans-serif',
weights: [400, 500, 600, 700],
});
document.documentElement.style.setProperty('--font-family', "'SF Pro Display', sans-serif");
},
applyFont() {
const config = this.getConfig();
@ -1674,7 +1709,11 @@ export const fontSettings = {
break;
case 'preset':
default:
this.loadPresetFont(config.family, config.fallback);
if (config.family === 'Apple Music') {
this.loadAppleMusicFont();
} else {
this.loadPresetFont(config.family, config.fallback);
}
break;
}
},

View file

@ -1544,8 +1544,11 @@ export class UIRenderer {
if (section) section.style.display = '';
if (songsContainer) {
if (forceRefresh) songsContainer.innerHTML = this.createSkeletonTracks(5, true);
else if (songsContainer.children.length > 0 && !songsContainer.querySelector('.skeleton')) return; // Already loaded
if (forceRefresh || songsContainer.children.length === 0) {
songsContainer.innerHTML = this.createSkeletonTracks(10, true);
} else if (!songsContainer.querySelector('.skeleton')) {
return; // Already loaded
}
try {
const seeds = await this.getSeeds();
@ -1578,8 +1581,11 @@ export class UIRenderer {
if (section) section.style.display = '';
if (albumsContainer) {
if (forceRefresh) albumsContainer.innerHTML = this.createSkeletonCards(6);
else if (albumsContainer.children.length > 0 && !albumsContainer.querySelector('.skeleton')) return;
if (forceRefresh || albumsContainer.children.length === 0) {
albumsContainer.innerHTML = this.createSkeletonCards(5);
} else if (!albumsContainer.querySelector('.skeleton')) {
return;
}
try {
const seeds = await this.getSeeds();
@ -1781,8 +1787,11 @@ export class UIRenderer {
if (section) section.style.display = '';
if (artistsContainer) {
if (forceRefresh) artistsContainer.innerHTML = this.createSkeletonCards(6, true);
else if (artistsContainer.children.length > 0 && !artistsContainer.querySelector('.skeleton')) return;
if (forceRefresh || artistsContainer.children.length === 0) {
artistsContainer.innerHTML = this.createSkeletonCards(12, true);
} else if (!artistsContainer.querySelector('.skeleton')) {
return;
}
try {
const seeds = await this.getSeeds();
@ -2378,7 +2387,7 @@ export class UIRenderer {
}
}
async renderPlaylistPage(playlistId, source = null, provider = null) {
async renderPlaylistPage(playlistId, source = null, _provider = null) {
this.showPage('playlist');
// Reset search input for new playlist

View file

@ -2,7 +2,9 @@
export class WaveformGenerator {
constructor() {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Use OfflineAudioContext to prevent creating unnecessary OS audio streams
// decodeAudioData doesn't require a real-time AudioContext
this.audioContext = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 1, 44100);
this.cache = new Map();
}

View file

@ -5,10 +5,13 @@
"description": "[<img src=\"https://github.com/SamidyFR/monochrome/blob/main/assets/512.png?raw=true\" alt=\"Monochrome Logo\">](https://monochrome.samidy.com)",
"main": "sw.js",
"scripts": {
"preview": "vite preview",
"start": "vite preview",
"dev": "vite",
"dev:desktop": "start npm run dev & node scripts/dev-runner.js",
"build": "vite build --mode neutralino && bun x neu build",
"postbuild": "node -e \"const fs = require('fs'); const path = require('path'); const src = 'extensions'; const dest = path.join('dist', 'Monochrome', 'extensions'); if (fs.existsSync(src)) { fs.mkdirSync(dest, { recursive: true }); fs.cpSync(src, dest, { recursive: true }); console.log('Extensions manually copied to ' + dest); }\"",
"preview": "vite preview",
"lint:js": "eslint .",
"lint:css": "stylelint \"**/*.css\"",
"lint:html": "htmlhint \"**/*.html\" --ignore=\"dist/**,legacy/**,node_modules/**\"",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,36 @@
/**
* Apple Fonts - SF Pro Display
* Fonts sourced from Apple Music design system
*/
@font-face {
font-family: 'SF Pro Display';
src: url('SFPRODISPLAYREGULAR.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'SF Pro Display';
src: url('SFPRODISPLAYMEDIUM.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'SF Pro Display';
src: url('SFPRODISPLAYBOLD.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'SF Pro Display';
src: url('SFPRODISPLAYSEMIBOLDITALIC.woff2') format('woff2');
font-weight: 600;
font-style: italic;
font-display: swap;
}

View file

@ -183,7 +183,7 @@
<div id="error" class="error-msg"></div>
<button id="google-btn" class="btn" onclick="googleSignIn()">
<button id="google-btn" class="btn" onclick="googleSignIn()" style="display: none">
<svg class="google-icon" viewBox="0 0 24 24">
<path
fill="#4285F4"
@ -205,9 +205,9 @@
Sign in with Google
</button>
<div class="divider">or</div>
<div id="divider" class="divider" style="display: none">or</div>
<form id="email-form" onsubmit="emailAuth(event)">
<form id="email-form" onsubmit="emailAuth(event)" style="display: none">
<div class="form-group">
<input type="email" id="email" placeholder="Email" required autocomplete="email" />
</div>
@ -287,6 +287,30 @@
document.getElementById('error').style.display = 'none';
}
const googleBtn = document.getElementById('google-btn');
const emailForm = document.getElementById('email-form');
const divider = document.getElementById('divider');
const providerState = {
google: true,
password: true,
};
const providerConfig = window.__AUTH_PROVIDERS__ || {};
if (typeof providerConfig.google === 'boolean') providerState.google = providerConfig.google;
if (typeof providerConfig.password === 'boolean') providerState.password = providerConfig.password;
const NO_PROVIDER_MESSAGE = 'No sign-in providers are enabled for this Firebase project.';
function renderProviders() {
if (googleBtn) googleBtn.style.display = providerState.google ? '' : 'none';
if (emailForm) emailForm.style.display = providerState.password ? '' : 'none';
if (divider) divider.style.display = providerState.google && providerState.password ? '' : 'none';
if (!providerState.google && !providerState.password) {
showError(NO_PROVIDER_MESSAGE);
}
}
renderProviders();
function setLoading(loading) {
document.getElementById('google-btn').disabled = loading;
document.getElementById('email-btn').disabled = loading;

View file

@ -1,6 +1,5 @@
import fs from 'fs';
import { spawn } from 'child_process';
import path from 'path';
const CONFIG_FILE = 'neutralino.config.json';
const DEV_CONFIG_FILE = 'neutralino.config.dev.json';

View file

@ -455,6 +455,14 @@ kbd {
margin-bottom: 1rem;
}
.sidebar-logo-link {
display: flex;
align-items: center;
gap: 6px;
color: inherit;
text-decoration: none;
}
.sidebar-logo .app-logo {
width: 15px;
height: 15px;
@ -1301,7 +1309,7 @@ input[type='search']::-webkit-search-cancel-button {
}
#playlist-detail-recommended .track-item {
grid-template-columns: 40px 1fr 80px 90px;
grid-template-columns: 40px 1fr 32px 64px;
}
@media (max-width: 1100px) {
@ -2190,7 +2198,7 @@ input:checked + .slider::before {
.player-controls.waveform-loaded {
gap: 0.1rem;
margin-bottom: -0.4rem;
margin-bottom: 0.3rem;
}
.player-controls .buttons {
@ -3236,6 +3244,26 @@ input:checked + .slider::before {
width: 100%;
}
/* Skeleton grids for recommended sections */
#home-recommended-albums .skeleton-container,
#home-recommended-artists .skeleton-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--spacing-lg);
}
#home-recommended-songs .skeleton-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(480px, 1fr));
gap: 2px var(--spacing-xl);
}
@media (max-width: 1100px) {
#home-recommended-songs .skeleton-container {
grid-template-columns: 1fr;
}
}
#api-instance-list {
list-style: none;
margin-bottom: 1rem;
@ -5321,6 +5349,7 @@ body:has(#fullscreen-cover-overlay:not([style*='display: none'])) .now-playing-b
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
will-change: transform, opacity;
}
/* Genius i love genius brah!! */
@ -6058,27 +6087,6 @@ textarea:focus {
font-size: 0.95rem;
}
/* Custom Tooltip */
#custom-tooltip {
position: fixed;
background: var(--card);
color: var(--card-foreground);
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
font-size: 0.85rem;
pointer-events: none;
z-index: 10000;
opacity: 0;
transition: opacity 0.1s ease;
white-space: nowrap;
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
will-change: transform, opacity;
}
#custom-tooltip.visible {
opacity: 1;
}

View file

@ -33,6 +33,8 @@ export default function authGatePlugin() {
const AUTH_ENABLED = (env.AUTH_ENABLED ?? 'false') !== 'false';
const FIREBASE_CONFIG = env.FIREBASE_CONFIG;
const POCKETBASE_URL = env.POCKETBASE_URL;
const AUTH_GOOGLE_ENABLED = env.AUTH_GOOGLE_ENABLED;
const AUTH_EMAIL_ENABLED = env.AUTH_EMAIL_ENABLED;
// Parse Firebase config once (used for injection + auth verification)
let parsedFirebaseConfig = null;
@ -51,6 +53,17 @@ export default function authGatePlugin() {
const flags = [];
if (AUTH_ENABLED) flags.push('window.__AUTH_GATE__=true');
const authProviderOverrides = {};
if (AUTH_GOOGLE_ENABLED !== undefined) {
authProviderOverrides.google = AUTH_GOOGLE_ENABLED !== 'false';
}
if (AUTH_EMAIL_ENABLED !== undefined) {
// Firebase calls it "password" provider; env uses "EMAIL" for clarity
authProviderOverrides.password = AUTH_EMAIL_ENABLED !== 'false';
}
if (Object.keys(authProviderOverrides).length > 0) {
flags.push(`window.__AUTH_PROVIDERS__=${JSON.stringify(authProviderOverrides)}`);
}
if (parsedFirebaseConfig) flags.push(`window.__FIREBASE_CONFIG__=${JSON.stringify(parsedFirebaseConfig)}`);
if (POCKETBASE_URL) flags.push(`window.__POCKETBASE_URL__=${JSON.stringify(POCKETBASE_URL)}`);
const configScript = flags.length > 0 ? `<script>${flags.join(';')};</script>` : null;