Merge branch 'main' of github.com:SamidyFR/monochrome
This commit is contained in:
commit
2b9c9b344c
28 changed files with 604 additions and 99 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
53
index.html
53
index.html
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
267
js/md5.js
Normal 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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
js/player.js
16
js/player.js
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
|
|
|||
23
js/ui.js
23
js/ui.js
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/**\"",
|
||||
|
|
|
|||
BIN
public/fonts/apple/SFPRODISPLAYBOLD.woff2
Normal file
BIN
public/fonts/apple/SFPRODISPLAYBOLD.woff2
Normal file
Binary file not shown.
BIN
public/fonts/apple/SFPRODISPLAYMEDIUM.woff2
Normal file
BIN
public/fonts/apple/SFPRODISPLAYMEDIUM.woff2
Normal file
Binary file not shown.
BIN
public/fonts/apple/SFPRODISPLAYREGULAR.woff2
Normal file
BIN
public/fonts/apple/SFPRODISPLAYREGULAR.woff2
Normal file
Binary file not shown.
BIN
public/fonts/apple/SFPRODISPLAYSEMIBOLDITALIC.woff2
Normal file
BIN
public/fonts/apple/SFPRODISPLAYSEMIBOLDITALIC.woff2
Normal file
Binary file not shown.
36
public/fonts/apple/sf-pro-display.css
Normal file
36
public/fonts/apple/sf-pro-display.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
54
styles.css
54
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue