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_ENABLED=false
AUTH_SECRET=change-me-to-a-random-string AUTH_SECRET=change-me-to-a-random-string
FIREBASE_PROJECT_ID=monochrome-database 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) # Optional: override the Firebase config for the login page (JSON string)
# FIREBASE_CONFIG={"apiKey":"...","authDomain":"...","projectId":"...","storageBucket":"...","messagingSenderId":"...","appId":"..."} # FIREBASE_CONFIG={"apiKey":"...","authDomain":"...","projectId":"...","storageBucket":"...","messagingSenderId":"...","appId":"..."}
# Optional: set PocketBase URL (hides the field in settings when set) # 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_ENABLED=true` enables the gate (default is false).
- `AUTH_SECRET` is required when the gate is enabled. It signs the session cookie. - `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_PROJECT_ID` sets the Firebase project used to verify tokens.
- `FIREBASE_CONFIG` (JSON) injects config into the login page. - `FIREBASE_CONFIG` (JSON) injects config into the login page.
- `POCKETBASE_URL` hides the custom DB setting field. - `POCKETBASE_URL` hides the custom DB setting field.

View file

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

View file

@ -1,6 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <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 charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Monochrome Music</title> <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-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Monochrome" /> <meta name="apple-mobile-web-app-title" content="Monochrome" />
<meta name="description" content="A minimalist music streaming application" /> <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="apple-touch-icon" href="/assets/logo.svg" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/assets/logo.svg" type="image/svg+xml" /> <link rel="icon" href="/assets/logo.svg" type="image/svg+xml" />
@ -751,20 +773,22 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-content"> <div class="sidebar-content">
<div class="sidebar-logo"> <div class="sidebar-logo">
<svg <a href="https://monochrome.tf/" class="sidebar-logo-link">
class="app-logo" <svg
xmlns="http://www.w3.org/2000/svg" class="app-logo"
width="200" xmlns="http://www.w3.org/2000/svg"
height="200" width="200"
viewBox="14.75 14.75 70.5 70.5" height="200"
> viewBox="14.75 14.75 70.5 70.5"
<g fill="currentColor"> >
<path <g fill="currentColor">
d="M38.25 14.75H85.25V61.75H61.75V38.25H38.25ZM14.75 38.25H38.25V61.75H61.75V85.25H14.75Z" <path
/> d="M38.25 14.75H85.25V61.75H61.75V38.25H38.25ZM14.75 38.25H38.25V61.75H61.75V85.25H14.75Z"
</g> />
</svg> </g>
<span>Monochrome</span> </svg>
<span>Monochrome</span>
</a>
<button <button
id="sidebar-toggle" id="sidebar-toggle"
class="btn-icon desktop-only" class="btn-icon desktop-only"
@ -2336,6 +2360,7 @@
<div id="font-preset-section" class="font-section"> <div id="font-preset-section" class="font-section">
<select id="font-preset-select"> <select id="font-preset-select">
<option value="Inter">Inter (Default)</option> <option value="Inter">Inter (Default)</option>
<option value="Apple Music">Apple Music</option>
<option value="IBM Plex Mono">IBM Plex Mono</option> <option value="IBM Plex Mono">IBM Plex Mono</option>
<option value="Roboto">Roboto</option> <option value="Roboto">Roboto</option>
<option value="Open Sans">Open Sans</option> <option value="Open Sans">Open Sans</option>

View file

@ -22,18 +22,29 @@ export class AuthManager {
init() { init() {
if (!auth) return; if (!auth) return;
console.log('[Auth] Initializing. Current URL:', window.location.href);
this.unsubscribe = onAuthStateChanged(auth, (user) => { this.unsubscribe = onAuthStateChanged(auth, (user) => {
this.user = user; this.user = user;
console.log('[Auth] Auth state changed:', user ? user.email : 'No user');
this.updateUI(user); this.updateUI(user);
this.authListeners.forEach((listener) => listener(user)); this.authListeners.forEach((listener) => listener(user));
}); });
// Handle redirect result (for Linux/Mobile where popup might be blocked) // Handle redirect result (for Linux/Mobile where popup might be blocked)
getRedirectResult(auth).catch((error) => { getRedirectResult(auth)
console.error('Redirect Login failed:', error); .then((result) => {
alert(`Login failed: ${error.message}`); 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) { onAuthStateChanged(callback) {
@ -50,6 +61,23 @@ export class AuthManager {
return; 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 { try {
const result = await signInWithPopup(auth, provider); const result = await signInWithPopup(auth, provider);

View file

@ -1,5 +1,4 @@
//js/app.js //js/app.js
import { LosslessAPI } from './api.js';
import { MusicAPI } from './music-api.js'; import { MusicAPI } from './music-api.js';
import { import {
apiSettings, apiSettings,
@ -266,7 +265,13 @@ document.addEventListener('DOMContentLoaded', async () => {
initTracker(player); initTracker(player);
// Initialize desktop features if in Neutralino mode // 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)); import('./desktop/desktop.js').then((m) => m.initDesktop(player));
} }

View file

@ -81,12 +81,14 @@ class AudioContextManager {
this.analyser = null; this.analyser = null;
this.filters = []; this.filters = [];
this.outputNode = null; this.outputNode = null;
this.volumeNode = null;
this.isInitialized = false; this.isInitialized = false;
this.isEQEnabled = false; this.isEQEnabled = false;
this.isMonoAudioEnabled = false; this.isMonoAudioEnabled = false;
this.monoMergerNode = null; this.monoMergerNode = null;
this.currentGains = new Array(16).fill(0); this.currentGains = new Array(16).fill(0);
this.audio = null; this.audio = null;
this.currentVolume = 1.0;
// Callbacks for audio graph changes (for visualizers like Butterchurn) // Callbacks for audio graph changes (for visualizers like Butterchurn)
this._graphChangeCallbacks = []; this._graphChangeCallbacks = [];
@ -131,6 +133,8 @@ class AudioContextManager {
if (this.isInitialized) return; if (this.isInitialized) return;
if (!audioElement) return; if (!audioElement) return;
this.audio = audioElement;
// Detect iOS - skip Web Audio initialization on iOS to avoid lock screen audio issues // 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 // iOS suspends AudioContext when screen locks, and MediaSession controls don't count
// as user gestures to resume it, causing audio to play silently // 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); const isIOS = /iphone|ipad|ipod/.test(ua) || (ua.includes('mac') && navigator.maxTouchPoints > 1);
if (isIOS) { if (isIOS) {
console.log('[AudioContext] Skipping Web Audio initialization on iOS for lock screen compatibility'); 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; return;
} }
try { try {
this.audio = audioElement;
const AudioContext = window.AudioContext || window.webkitAudioContext; const AudioContext = window.AudioContext || window.webkitAudioContext;
this.audioContext = new AudioContext(); this.audioContext = new AudioContext();
@ -170,6 +173,10 @@ class AudioContextManager {
this.outputNode = this.audioContext.createGain(); this.outputNode = this.audioContext.createGain();
this.outputNode.gain.value = 1; this.outputNode.gain.value = 1;
// Create volume node
this.volumeNode = this.audioContext.createGain();
this.volumeNode.gain.value = this.currentVolume;
// Create mono audio merger node // Create mono audio merger node
this.monoMergerNode = this.audioContext.createChannelMerger(2); this.monoMergerNode = this.audioContext.createChannelMerger(2);
@ -199,6 +206,11 @@ class AudioContextManager {
// Disconnect everything first // Disconnect everything first
this.source.disconnect(); this.source.disconnect();
this.outputNode.disconnect(); this.outputNode.disconnect();
if (this.volumeNode) {
this.volumeNode.disconnect();
}
this.analyser.disconnect();
if (this.monoMergerNode) { if (this.monoMergerNode) {
try { try {
this.monoMergerNode.disconnect(); 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; let lastNode = this.source;
// Apply mono audio if enabled // Apply mono audio if enabled
@ -234,15 +239,17 @@ class AudioContextManager {
} }
if (this.isEQEnabled && this.filters.length > 0) { 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]); lastNode.connect(this.filters[0]);
this.outputNode.connect(this.analyser); 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'); console.log('[AudioContext] EQ connected');
} else { } else {
// EQ disabled: lastNode -> analyser -> destination // EQ disabled: lastNode -> analyser -> volume -> destination
lastNode.connect(this.analyser); 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'); console.log('[AudioContext] EQ bypassed');
} }
@ -307,10 +314,22 @@ class AudioContextManager {
} }
/** /**
* Check if initialized * Check if initialized and active
*/ */
isReady() { 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); scrobbler.updateNowPlaying(player.currentTrack);
} }
// Resume AudioContext for waveform on mobile (iOS)
if (waveformGenerator.audioContext.state === 'suspended') {
waveformGenerator.audioContext.resume();
}
updateWaveform(); updateWaveform();
} }

View file

@ -107,10 +107,10 @@ export class LastFMScrobbler {
console.log('Signature string:', signatureString); console.log('Signature string:', signatureString);
try { 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); return md5(signatureString);
} catch { } catch (e) {
console.error('MD5 library not available'); console.error('MD5 library not available', e);
throw new Error('MD5 library required for Last.fm'); 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; const signatureString = sortedKeys.map((key) => `${key}${filteredParams[key]}`).join('') + this.API_SECRET;
try { 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); return md5(signatureString);
} catch { } catch {
console.error('MD5 library not available'); 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); return api.getArtist(cleanId);
} }
async getPlaylist(id, provider = null) { async getPlaylist(id, _provider = null) {
// Playlists are always Tidal for now // Playlists are always Tidal for now
return this.tidalAPI.getPlaylist(id); return this.tidalAPI.getPlaylist(id);
} }
async getMix(id, provider = null) { async getMix(id, _provider = null) {
// Mixes are always Tidal for now // Mixes are always Tidal for now
return this.tidalAPI.getMix(id); return this.tidalAPI.getMix(id);
} }
@ -173,10 +173,4 @@ export class MusicAPI {
get settings() { get settings() {
return this._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 // Calculate effective volume
const effectiveVolume = curvedVolume * scale; const effectiveVolume = curvedVolume * scale;
// Apply to audio element // Apply to audio element and/or Web Audio graph
this.audio.volume = Math.max(0, Math.min(1, effectiveVolume)); 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() { applyAudioEffects() {
@ -213,6 +222,7 @@ export class Player {
// Must happen before audio.play() or audio won't route through Web Audio // Must happen before audio.play() or audio won't route through Web Audio
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio); audioContextManager.init(this.audio);
this.applyReplayGain();
} }
await audioContextManager.resume(); await audioContextManager.resume();
@ -233,6 +243,7 @@ export class Player {
// Ensure audio context is active for iOS lock screen controls // Ensure audio context is active for iOS lock screen controls
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio); audioContextManager.init(this.audio);
this.applyReplayGain();
} }
await audioContextManager.resume(); await audioContextManager.resume();
this.playPrev(); this.playPrev();
@ -242,6 +253,7 @@ export class Player {
// Ensure audio context is active for iOS lock screen controls // Ensure audio context is active for iOS lock screen controls
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio); audioContextManager.init(this.audio);
this.applyReplayGain();
} }
await audioContextManager.resume(); await audioContextManager.resume();
this.playNext(); this.playNext();

View file

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

View file

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

View file

@ -1659,6 +1659,41 @@ export const fontSettings = {
document.documentElement.style.setProperty('--font-family', fontValue); 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() { applyFont() {
const config = this.getConfig(); const config = this.getConfig();
@ -1674,7 +1709,11 @@ export const fontSettings = {
break; break;
case 'preset': case 'preset':
default: default:
this.loadPresetFont(config.family, config.fallback); if (config.family === 'Apple Music') {
this.loadAppleMusicFont();
} else {
this.loadPresetFont(config.family, config.fallback);
}
break; break;
} }
}, },

View file

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

View file

@ -2,7 +2,9 @@
export class WaveformGenerator { export class WaveformGenerator {
constructor() { 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(); 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)", "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", "main": "sw.js",
"scripts": { "scripts": {
"preview": "vite preview",
"start": "vite preview",
"dev": "vite", "dev": "vite",
"dev:desktop": "start npm run dev & node scripts/dev-runner.js", "dev:desktop": "start npm run dev & node scripts/dev-runner.js",
"build": "vite build --mode neutralino && bun x neu build", "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); }\"", "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:js": "eslint .",
"lint:css": "stylelint \"**/*.css\"", "lint:css": "stylelint \"**/*.css\"",
"lint:html": "htmlhint \"**/*.html\" --ignore=\"dist/**,legacy/**,node_modules/**\"", "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> <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"> <svg class="google-icon" viewBox="0 0 24 24">
<path <path
fill="#4285F4" fill="#4285F4"
@ -205,9 +205,9 @@
Sign in with Google Sign in with Google
</button> </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"> <div class="form-group">
<input type="email" id="email" placeholder="Email" required autocomplete="email" /> <input type="email" id="email" placeholder="Email" required autocomplete="email" />
</div> </div>
@ -287,6 +287,30 @@
document.getElementById('error').style.display = 'none'; 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) { function setLoading(loading) {
document.getElementById('google-btn').disabled = loading; document.getElementById('google-btn').disabled = loading;
document.getElementById('email-btn').disabled = loading; document.getElementById('email-btn').disabled = loading;

View file

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

View file

@ -455,6 +455,14 @@ kbd {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.sidebar-logo-link {
display: flex;
align-items: center;
gap: 6px;
color: inherit;
text-decoration: none;
}
.sidebar-logo .app-logo { .sidebar-logo .app-logo {
width: 15px; width: 15px;
height: 15px; height: 15px;
@ -1301,7 +1309,7 @@ input[type='search']::-webkit-search-cancel-button {
} }
#playlist-detail-recommended .track-item { #playlist-detail-recommended .track-item {
grid-template-columns: 40px 1fr 80px 90px; grid-template-columns: 40px 1fr 32px 64px;
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
@ -2190,7 +2198,7 @@ input:checked + .slider::before {
.player-controls.waveform-loaded { .player-controls.waveform-loaded {
gap: 0.1rem; gap: 0.1rem;
margin-bottom: -0.4rem; margin-bottom: 0.3rem;
} }
.player-controls .buttons { .player-controls .buttons {
@ -3236,6 +3244,26 @@ input:checked + .slider::before {
width: 100%; 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 { #api-instance-list {
list-style: none; list-style: none;
margin-bottom: 1rem; margin-bottom: 1rem;
@ -5321,6 +5349,7 @@ body:has(#fullscreen-cover-overlay:not([style*='display: none'])) .now-playing-b
max-width: 400px; max-width: 400px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
will-change: transform, opacity;
} }
/* Genius i love genius brah!! */ /* Genius i love genius brah!! */
@ -6058,27 +6087,6 @@ textarea:focus {
font-size: 0.95rem; 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 { #custom-tooltip.visible {
opacity: 1; opacity: 1;
} }

View file

@ -33,6 +33,8 @@ export default function authGatePlugin() {
const AUTH_ENABLED = (env.AUTH_ENABLED ?? 'false') !== 'false'; const AUTH_ENABLED = (env.AUTH_ENABLED ?? 'false') !== 'false';
const FIREBASE_CONFIG = env.FIREBASE_CONFIG; const FIREBASE_CONFIG = env.FIREBASE_CONFIG;
const POCKETBASE_URL = env.POCKETBASE_URL; 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) // Parse Firebase config once (used for injection + auth verification)
let parsedFirebaseConfig = null; let parsedFirebaseConfig = null;
@ -51,6 +53,17 @@ export default function authGatePlugin() {
const flags = []; const flags = [];
if (AUTH_ENABLED) flags.push('window.__AUTH_GATE__=true'); 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 (parsedFirebaseConfig) flags.push(`window.__FIREBASE_CONFIG__=${JSON.stringify(parsedFirebaseConfig)}`);
if (POCKETBASE_URL) flags.push(`window.__POCKETBASE_URL__=${JSON.stringify(POCKETBASE_URL)}`); if (POCKETBASE_URL) flags.push(`window.__POCKETBASE_URL__=${JSON.stringify(POCKETBASE_URL)}`);
const configScript = flags.length > 0 ? `<script>${flags.join(';')};</script>` : null; const configScript = flags.length > 0 ? `<script>${flags.join(';')};</script>` : null;