fix: honor selected visualizer & fix scrollbar bug and lyrics shortcut bug

This commit is contained in:
Alan Brooks 2026-04-06 11:50:48 -04:00
commit 4dba288592
19 changed files with 752 additions and 70 deletions

View file

@ -97,7 +97,7 @@ jobs:
- name: Generate new editors picks
if: steps.backoff.outputs.skip == 'false'
run: python3 gen-editors-picks.py
run: python3 .github/scripts/gen-editors-picks.py
- name: Commit and push
if: steps.backoff.outputs.skip == 'false'

View file

@ -206,7 +206,11 @@
"
></div>
<button id="fullscreen-dismiss-handle" type="button" aria-label="Dismiss fullscreen"></button>
<button id="toggle-fullscreen-lyrics-mobile-btn" class="fullscreen-mobile-lyrics-toggle" title="Hide Lyrics">
<button
id="toggle-fullscreen-lyrics-mobile-btn"
class="fullscreen-mobile-lyrics-toggle"
title="Hide Lyrics"
>
<use svg="!lucide/mic-vocal.svg" size="18" />
</button>
<button id="toggle-ui-btn" class="fullscreen-ui-toggle" title="Toggle UI">
@ -261,7 +265,11 @@
</div>
<div class="fullscreen-controls">
<div id="fullscreen-mobile-quality" class="fullscreen-mobile-quality" aria-hidden="true"></div>
<div
id="fullscreen-mobile-quality"
class="fullscreen-mobile-quality"
aria-hidden="true"
></div>
<div class="fullscreen-progress-container">
<span id="fs-current-time">0:00</span>
<div id="fs-progress-bar" class="progress-bar">

View file

@ -299,6 +299,13 @@ function initializeKeyboardShortcuts(player, _audioPlayer) {
},
lyrics: () => {
trackKeyboardShortcut('L');
const overlay = document.getElementById('fullscreen-cover-overlay');
const isFullscreenOpen = overlay && getComputedStyle(overlay).display !== 'none';
if (isFullscreenOpen && UIRenderer.instance?.toggleFullscreenLyrics(overlay)) {
return;
}
document.getElementById('toggle-lyrics-btn')?.click();
},
search: () => {
@ -361,6 +368,19 @@ function initializeKeyboardShortcuts(player, _audioPlayer) {
});
}
async function closeFullscreenOverlay() {
if (UIRenderer.instance?.dismissFullscreenCover) {
await UIRenderer.instance.dismissFullscreenCover({ animate: false });
return;
}
if (window.location.hash === '#fullscreen') {
window.history.back();
} else {
UIRenderer.instance?.closeFullscreenCover();
}
}
function showOfflineNotification() {
const notification = document.createElement('div');
notification.className = 'offline-notification';
@ -736,11 +756,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} else if (mode === 'cover') {
const overlay = document.getElementById('fullscreen-cover-overlay');
if (overlay && overlay.style.display === 'flex') {
if (window.location.hash === '#fullscreen') {
window.history.back();
} else {
UIRenderer.instance.closeFullscreenCover();
}
await closeFullscreenOverlay();
} else {
const nextTrack = Player.instance.getNextTrack();
UIRenderer.instance.showFullscreenCover(
@ -764,13 +780,9 @@ document.addEventListener('DOMContentLoaded', async () => {
if (shareBtn) shareBtn.style.display = e.target.checked ? 'flex' : 'none';
});
document.getElementById('close-fullscreen-cover-btn')?.addEventListener('click', () => {
document.getElementById('close-fullscreen-cover-btn')?.addEventListener('click', async () => {
trackCloseFullscreenCover();
if (window.location.hash === '#fullscreen') {
window.history.back();
} else {
UIRenderer.instance.closeFullscreenCover();
}
await closeFullscreenOverlay();
});
document.getElementById('fullscreen-cover-overlay')?.addEventListener('click', (e) => {
@ -785,11 +797,7 @@ document.addEventListener('DOMContentLoaded', async () => {
switch (action) {
case 'exit':
if (window.location.hash === '#fullscreen') {
window.history.back();
} else {
UIRenderer.instance.closeFullscreenCover();
}
closeFullscreenOverlay();
break;
case 'hide-ui':
if (overlay) {
@ -831,11 +839,7 @@ document.addEventListener('DOMContentLoaded', async () => {
case 'nothing':
break;
default:
if (window.location.hash === '#fullscreen') {
window.history.back();
} else {
UIRenderer.instance.closeFullscreenCover();
}
closeFullscreenOverlay();
}
});

View file

@ -102,8 +102,6 @@ export class MusicDatabase {
async addToHistory(track) {
const storeName = 'history_tracks';
const minified = this._minifyItem(track.type || 'track', track);
const timestamp = Date.now();
const entry = { ...minified, timestamp };
const db = await this.open();
@ -112,25 +110,34 @@ export class MusicDatabase {
const store = transaction.objectStore(storeName);
const index = store.index('timestamp');
const cursorReq = index.openCursor(null, 'prev');
const lastReq = index.openCursor(null, 'prev');
let lastTimestamp = 0;
cursorReq.onsuccess = (e) => {
lastReq.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
const lastTrack = cursor.value;
if (lastTrack.id === track.id) {
store.delete(cursor.primaryKey);
}
if (cursor && lastTimestamp === 0) {
lastTimestamp = cursor.value.timestamp;
}
store.put(entry);
const timestamp = Math.max(Date.now(), lastTimestamp + 1);
const entry = { ...minified, timestamp };
const dedupeReq = index.openCursor(null, 'prev');
dedupeReq.onsuccess = (e2) => {
const dedupeCursor = e2.target.result;
if (dedupeCursor) {
const trackInHistory = dedupeCursor.value;
if (trackInHistory.id === track.id) {
store.delete(dedupeCursor.primaryKey);
}
dedupeCursor.continue();
} else {
store.put(entry);
resolve(entry);
}
};
};
cursorReq.onerror = (_e) => {
// If cursor fails, just try to put (fallback)
store.put(entry);
};
transaction.oncomplete = () => resolve(entry);
transaction.onerror = (e) => reject(e.target.error);
});
}

View file

@ -291,7 +291,8 @@ export class Player {
}
setPlaybackSpeed(speed) {
const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0));
const parsed = parseFloat(speed);
const validSpeed = Math.max(0.01, Math.min(100, isNaN(parsed) ? 1.0 : parsed));
audioEffectsSettings.setSpeed(validSpeed);
this.applyAudioEffects();
}

View file

@ -1830,7 +1830,8 @@ export const audioEffectsSettings = {
},
setSpeed(speed) {
const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0));
const parsed = parseFloat(speed);
const validSpeed = Math.max(0.01, Math.min(100, isNaN(parsed) ? 1.0 : parsed));
localStorage.setItem(this.SPEED_KEY, validSpeed.toString());
},

107
js/tests/db.test.js Normal file
View file

@ -0,0 +1,107 @@
import { expect, test, describe, beforeEach, afterEach, vi } from 'vitest';
import { MusicDatabase } from '../db.js';
describe('MusicDatabase', () => {
let db;
const TEST_DB_NAME = 'TestMonochromeDB';
beforeEach(async () => {
db = new MusicDatabase();
db.dbName = TEST_DB_NAME;
const req = indexedDB.deleteDatabase(TEST_DB_NAME);
await new Promise((resolve) => {
req.onsuccess = resolve;
req.onerror = resolve;
});
});
afterEach(async () => {
if (db.db) {
db.db.close();
}
const req = indexedDB.deleteDatabase(TEST_DB_NAME);
await new Promise((resolve) => {
req.onsuccess = resolve;
req.onerror = resolve;
});
});
test('opens database and creates stores', async () => {
const openedDb = await db.open();
expect(openedDb.name).toBe(TEST_DB_NAME);
expect(openedDb.objectStoreNames.contains('favorites_tracks')).toBe(true);
expect(openedDb.objectStoreNames.contains('history_tracks')).toBe(true);
expect(openedDb.objectStoreNames.contains('user_playlists')).toBe(true);
});
test('toggleFavorite adds and removes items', async () => {
const track = { id: 'track1', title: 'Test Track', artist: { name: 'Artist' } };
const added = await db.toggleFavorite('track', track);
expect(added).toBe(true);
const favorites = await db.getFavorites('track');
expect(favorites.length).toBe(1);
expect(favorites[0].id).toBe('track1');
const removed = await db.toggleFavorite('track', track);
expect(removed).toBe(false);
const favoritesAfter = await db.getFavorites('track');
expect(favoritesAfter.length).toBe(0);
});
test('addToHistory manages recent tracks and avoids duplicates', async () => {
const track1 = { id: 't1', title: 'Track 1' };
const track2 = { id: 't2', title: 'Track 2' };
await db.addToHistory(track1);
await db.addToHistory(track2);
await db.addToHistory(track1);
const history = await db.getHistory();
expect(history.length).toBe(2);
expect(history[0].id).toBe('t1');
expect(history[1].id).toBe('t2');
});
test('playlist operations: create, add, remove, delete', async () => {
const track = { id: 'track1', title: 'Test Track' };
const playlist = await db.createPlaylist('My Playlist', [track]);
expect(playlist.name).toBe('My Playlist');
expect(playlist.tracks.length).toBe(1);
const track2 = { id: 'track2', title: 'Track 2' };
await db.addTrackToPlaylist(playlist.id, track2);
const updated = await db.getPlaylist(playlist.id);
expect(updated.tracks.length).toBe(2);
expect(updated.tracks[1].id).toBe('track2');
await db.removeTrackFromPlaylist(playlist.id, 'track1');
const afterRemove = await db.getPlaylist(playlist.id);
expect(afterRemove.tracks.length).toBe(1);
expect(afterRemove.tracks[0].id).toBe('track2');
await db.deletePlaylist(playlist.id);
const deleted = await db.getPlaylist(playlist.id);
expect(deleted).toBeUndefined();
});
test('pinned items management', async () => {
const album = { id: 'album1', title: 'Album 1', type: 'album' };
await db.togglePinned(album, 'album');
let pinned = await db.getPinned();
expect(pinned.length).toBe(1);
expect(pinned[0].id).toBe('album1');
await db.togglePinned({ id: 'a2', title: 'A2' }, 'album');
await db.togglePinned({ id: 'a3', title: 'A3' }, 'album');
await db.togglePinned({ id: 'a4', title: 'A4' }, 'album');
pinned = await db.getPinned();
expect(pinned.length).toBe(3);
expect(pinned.some((p) => p.id === 'a4')).toBe(true);
expect(pinned.some((p) => p.id === 'album1')).toBe(false);
});
});

195
js/tests/player.test.js Normal file
View file

@ -0,0 +1,195 @@
import { expect, test, describe, beforeEach, vi, afterEach } from 'vitest';
import { Player } from '../player.js';
import { REPEAT_MODE } from '../utils.js';
import { audioEffectsSettings } from '../storage.js';
vi.mock('../audio-context.js', () => ({
audioContextManager: {
init: vi.fn(),
resume: vi.fn(() => Promise.resolve()),
isReady: vi.fn(() => false),
setVolume: vi.fn(),
changeSource: vi.fn(),
},
}));
vi.mock('../storage.js', () => ({
queueManager: {
getQueue: vi.fn(() => null),
saveQueue: vi.fn(),
},
replayGainSettings: { getMode: vi.fn(() => 'off'), getPreamp: vi.fn(() => 0) },
trackDateSettings: { useAlbumYear: vi.fn(() => true) },
exponentialVolumeSettings: { applyCurve: vi.fn((v) => v) },
audioEffectsSettings: {
getSpeed: vi.fn(() => 1.0),
setSpeed: vi.fn(),
isPreservePitchEnabled: vi.fn(() => true),
setPreservePitch: vi.fn(),
},
radioSettings: { isEnabled: vi.fn(() => false) },
contentBlockingSettings: {
shouldHideTrack: vi.fn(() => false),
shouldHideAlbum: vi.fn(() => false),
shouldHideArtist: vi.fn(() => false),
},
qualityBadgeSettings: { isEnabled: vi.fn(() => true) },
coverArtSizeSettings: { getSize: vi.fn(() => '1280') },
apiSettings: {
loadInstancesFromGitHub: vi.fn(() => Promise.resolve([])),
getInstances: vi.fn(() => Promise.resolve([])),
},
recentActivityManager: { addArtist: vi.fn(), addAlbum: vi.fn() },
themeManager: { getTheme: vi.fn(() => 'dark'), setTheme: vi.fn() },
lastFMStorage: { isEnabled: vi.fn(() => false) },
nowPlayingSettings: { getMode: vi.fn(() => 'cover') },
gaplessPlaybackSettings: { isEnabled: vi.fn(() => true) },
}));
vi.mock('../db.js', () => ({
db: {
get: vi.fn(),
put: vi.fn(),
},
}));
vi.mock('../ui.js', () => ({
UIRenderer: {
renderQueue: vi.fn(),
},
}));
vi.mock('shaka-player', () => ({
default: {
polyfill: { installAll: vi.fn() },
Player: {
isBrowserSupported: vi.fn(() => true),
prototype: {
configure: vi.fn(),
addEventListener: vi.fn(),
load: vi.fn(),
unload: vi.fn(),
},
},
},
polyfill: { installAll: vi.fn() },
Player: class {
static isBrowserSupported() {
return true;
}
configure() {}
addEventListener() {}
load() {
return Promise.resolve();
}
unload() {
return Promise.resolve();
}
destroy() {
return Promise.resolve();
}
},
}));
describe('Player', () => {
let audioElement;
let api;
let player;
beforeEach(async () => {
document.body.innerHTML = `
<audio id="audio-player"></audio>
<video id="video-player"></video>
<div class="now-playing-bar">
<img class="cover" src="">
<div class="title"></div>
<div class="artist"></div>
<div class="album"></div>
</div>
<div id="total-duration"></div>
`;
audioElement = document.getElementById('audio-player');
api = {
getCoverUrl: vi.fn((id) => `url-${id}`),
getCoverSrcset: vi.fn(),
getStreamUrl: vi.fn(),
};
Player._instance = null;
});
afterEach(() => {
vi.clearAllMocks();
});
test('initialization sets up initial state', async () => {
player = new Player(audioElement, api);
expect(player.audio).toBe(audioElement);
expect(player.api).toBe(api);
expect(player.queue).toEqual([]);
expect(player.shuffleActive).toBe(false);
});
test('setVolume updates userVolume and localStorage', () => {
player = new Player(audioElement, api);
player.setVolume(0.5);
expect(player.userVolume).toBe(0.5);
expect(localStorage.getItem('volume')).toBe('0.5');
});
test('shuffle toggles correctly', () => {
player = new Player(audioElement, api);
player.queue = [{ id: 1 }, { id: 2 }, { id: 3 }];
player.toggleShuffle();
expect(player.shuffleActive).toBe(true);
expect(player.shuffledQueue.length).toBe(3);
player.toggleShuffle();
expect(player.shuffleActive).toBe(false);
});
test('repeat mode cycles correctly', () => {
player = new Player(audioElement, api);
expect(player.repeatMode).toBe(REPEAT_MODE.OFF);
player.toggleRepeat();
expect(player.repeatMode).toBe(REPEAT_MODE.ALL);
player.toggleRepeat();
expect(player.repeatMode).toBe(REPEAT_MODE.ONE);
player.toggleRepeat();
expect(player.repeatMode).toBe(REPEAT_MODE.OFF);
});
test('addToQueue adds tracks to the end', async () => {
player = new Player(audioElement, api);
player.queue = [{ id: 1 }];
await player.addToQueue([{ id: 2 }, { id: 3 }]);
expect(player.queue.length).toBe(3);
expect(player.queue[2].id).toBe(3);
});
test('clearQueue resets queue state', async () => {
player = new Player(audioElement, api);
player.queue = [{ id: 1 }];
player.currentQueueIndex = 0;
await player.clearQueue();
expect(player.queue).toEqual([]);
expect(player.currentQueueIndex).toBe(-1);
});
test('setPlaybackSpeed clamps values', () => {
player = new Player(audioElement, api);
player.setPlaybackSpeed(2.0);
expect(audioEffectsSettings.setSpeed).toHaveBeenCalledWith(2.0);
player.setPlaybackSpeed(0);
expect(audioEffectsSettings.setSpeed).toHaveBeenCalledWith(0.01);
});
});

125
js/tests/storage.test.js Normal file
View file

@ -0,0 +1,125 @@
import { expect, test, describe, beforeEach, vi } from 'vitest';
import {
recentActivityManager,
themeManager,
lastFMStorage,
nowPlayingSettings,
gaplessPlaybackSettings,
exponentialVolumeSettings,
audioEffectsSettings,
} from '../storage.js';
describe('storage.js', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
describe('recentActivityManager', () => {
test('initializes with empty arrays', () => {
const recents = recentActivityManager.getRecents();
expect(recents.artists).toEqual([]);
expect(recents.albums).toEqual([]);
});
test('adds artist and maintains limit', () => {
for (let i = 0; i < 15; i++) {
recentActivityManager.addArtist({ id: i, name: `Artist ${i}` });
}
const recents = recentActivityManager.getRecents();
expect(recents.artists.length).toBe(10);
expect(recents.artists[0].id).toBe(14);
});
test('clears recents', () => {
recentActivityManager.addArtist({ id: 1, name: 'Artist' });
recentActivityManager.clear();
const recents = recentActivityManager.getRecents();
expect(recents.artists).toEqual([]);
});
});
describe('themeManager', () => {
test('gets and sets theme', () => {
themeManager.setTheme('dark');
expect(themeManager.getTheme()).toBe('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
test('handles custom theme', () => {
const colors = { primary: '#ff0000', background: '#000000' };
themeManager.setCustomTheme(colors);
expect(themeManager.getTheme()).toBe('custom');
expect(themeManager.getCustomTheme()).toEqual(colors);
expect(document.documentElement.style.getPropertyValue('--primary')).toBe('#ff0000');
});
});
describe('lastFMStorage', () => {
test('handles enabled state', () => {
lastFMStorage.setEnabled(true);
expect(lastFMStorage.isEnabled()).toBe(true);
lastFMStorage.setEnabled(false);
expect(lastFMStorage.isEnabled()).toBe(false);
});
test('obfuscates sensitive data', () => {
const key = 'test-api-key';
lastFMStorage.setCustomApiKey(key);
expect(localStorage.getItem(lastFMStorage.CUSTOM_API_KEY)).not.toBe(key);
expect(lastFMStorage.getCustomApiKey()).toBe(key);
});
});
describe('nowPlayingSettings', () => {
test('gets and sets mode', () => {
expect(nowPlayingSettings.getMode()).toBe('cover');
nowPlayingSettings.setMode('visualizer');
expect(nowPlayingSettings.getMode()).toBe('visualizer');
});
});
describe('gaplessPlaybackSettings', () => {
test('defaults to true', () => {
expect(gaplessPlaybackSettings.isEnabled()).toBe(true);
});
test('sets enabled state', () => {
gaplessPlaybackSettings.setEnabled(false);
expect(gaplessPlaybackSettings.isEnabled()).toBe(false);
});
});
describe('exponentialVolumeSettings', () => {
test('applies curve when enabled', () => {
exponentialVolumeSettings.setEnabled(true);
expect(exponentialVolumeSettings.applyCurve(0.5)).toBeCloseTo(0.125);
expect(exponentialVolumeSettings.inverseCurve(0.125)).toBeCloseTo(0.5);
});
test('does not apply curve when disabled', () => {
exponentialVolumeSettings.setEnabled(false);
expect(exponentialVolumeSettings.applyCurve(0.5)).toBe(0.5);
expect(exponentialVolumeSettings.inverseCurve(0.5)).toBe(0.5);
});
});
describe('audioEffectsSettings', () => {
test('gets and sets speed within bounds', () => {
audioEffectsSettings.setSpeed(2.0);
expect(audioEffectsSettings.getSpeed()).toBe(2.0);
audioEffectsSettings.setSpeed(200);
expect(audioEffectsSettings.getSpeed()).toBe(100);
audioEffectsSettings.setSpeed(0);
expect(audioEffectsSettings.getSpeed()).toBe(0.01);
});
test('resets speed', () => {
audioEffectsSettings.setSpeed(2.0);
audioEffectsSettings.resetSpeed();
expect(audioEffectsSettings.getSpeed()).toBe(1.0);
});
});
});

211
js/tests/utils.test.js Normal file
View file

@ -0,0 +1,211 @@
import { expect, test, describe, vi } from 'vitest';
import * as utils from '../utils.js';
vi.mock('../ModernSettings.js', () => ({
modernSettings: {
filenameTemplate: '{artist} - {album} - {trackNumber} - {title}',
},
}));
vi.mock('../icons.js', () => ({
SVG_ATMOS: () => '<svg>atmos</svg>',
}));
vi.mock('../storage.js', () => ({
qualityBadgeSettings: { isEnabled: vi.fn(() => true) },
coverArtSizeSettings: { getSize: vi.fn(() => '1280') },
trackDateSettings: { useAlbumYear: vi.fn(() => false) },
}));
describe('utils.js', () => {
describe('formatTime', () => {
test('formats seconds into M:SS', () => {
expect(utils.formatTime(0)).toBe('0:00');
expect(utils.formatTime(5)).toBe('0:05');
expect(utils.formatTime(60)).toBe('1:00');
expect(utils.formatTime(65)).toBe('1:05');
});
test('formats seconds into H:MM:SS', () => {
expect(utils.formatTime(3600)).toBe('1:00:00');
expect(utils.formatTime(3665)).toBe('1:01:05');
});
test('handles NaN', () => {
expect(utils.formatTime(NaN)).toBe('0:00');
});
});
describe('sanitizeForFilename', () => {
test('replaces invalid characters with underscores', () => {
expect(utils.sanitizeForFilename('a/b:c*d?e"f<g>h|i')).toBe('a_b_c_d_e_f_g_h_i');
});
test('collapses multiple spaces and trims', () => {
expect(utils.sanitizeForFilename(' hello world ')).toBe('hello world');
});
test('returns "Unknown" for empty input', () => {
expect(utils.sanitizeForFilename('')).toBe('Unknown');
expect(utils.sanitizeForFilename(null)).toBe('Unknown');
});
});
describe('replaceTokens', () => {
test('replaces tokens in template', () => {
const template = '{artist} - {title}';
const tokens = { artist: 'Artist', title: 'Title' };
expect(utils.replaceTokens(template, tokens)).toBe('Artist - Title');
});
test('leaves unknown tokens as is', () => {
const template = '{artist} - {unknown}';
const tokens = { artist: 'Artist' };
expect(utils.replaceTokens(template, tokens)).toBe('Artist - {unknown}');
});
});
describe('formatPathTemplate', () => {
test('formats path correctly', () => {
const data = {
artist: 'Artist',
album: 'Album',
trackNumber: 1,
title: 'Title',
discNumber: 1,
};
const template = '{artist}/{album}/{trackNumber} - {title}';
expect(utils.formatPathTemplate(template, data)).toBe('Artist/Album/01 - Title');
});
test('strips . and .. segments', () => {
const data = { artist: '..', title: '.' };
const template = '{artist}/{title}/song';
expect(utils.formatPathTemplate(template, data)).toBe('song');
});
});
describe('detectAudioFormat', () => {
test('detects flac', () => {
const view = new DataView(new Uint8Array([0x66, 0x4c, 0x61, 0x43]).buffer);
expect(utils.detectAudioFormat(view)).toBe('flac');
});
test('detects mp4', () => {
const view = new DataView(new Uint8Array([0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70]).buffer);
expect(utils.detectAudioFormat(view)).toBe('mp4');
});
test('detects mp3 (ID3)', () => {
const view = new DataView(new Uint8Array([0x49, 0x44, 0x33]).buffer);
expect(utils.detectAudioFormat(view)).toBe('mp3');
});
test('detects ogg', () => {
const view = new DataView(new Uint8Array([0x4f, 0x67, 0x67, 0x53]).buffer);
expect(utils.detectAudioFormat(view)).toBe('ogg');
});
test('returns null for unknown format', () => {
const view = new DataView(new Uint8Array([0, 0, 0, 0]).buffer);
expect(utils.detectAudioFormat(view)).toBeNull();
});
});
describe('normalizeQualityToken', () => {
test('normalizes various quality strings', () => {
expect(utils.normalizeQualityToken('HI_RES_LOSSLESS')).toBe('HI_RES_LOSSLESS');
expect(utils.normalizeQualityToken('MASTER')).toBe('HI_RES_LOSSLESS');
expect(utils.normalizeQualityToken('HIFI')).toBe('LOSSLESS');
expect(utils.normalizeQualityToken('ATMOS')).toBe('DOLBY_ATMOS');
});
test('returns null for unknown quality', () => {
expect(utils.normalizeQualityToken('UNKNOWN')).toBeNull();
});
});
describe('pickBestQuality', () => {
test('picks the highest quality from list', () => {
expect(utils.pickBestQuality(['LOSSLESS', 'HI_RES_LOSSLESS', 'HIGH'])).toBe('HI_RES_LOSSLESS');
expect(utils.pickBestQuality(['LOW', 'HIGH'])).toBe('HIGH');
expect(utils.pickBestQuality(['DOLBY_ATMOS', 'HI_RES_LOSSLESS'])).toBe('DOLBY_ATMOS');
});
});
describe('getTrackTitle', () => {
test('returns title with version if present', () => {
expect(utils.getTrackTitle({ title: 'Song', version: 'Remix' })).toBe('Song (Remix)');
});
test('returns just title if no version', () => {
expect(utils.getTrackTitle({ title: 'Song' })).toBe('Song');
});
test('returns fallback if no title', () => {
expect(utils.getTrackTitle({}, { fallback: 'No Title' })).toBe('No Title');
});
});
describe('getTrackArtists', () => {
test('joins multiple artists', () => {
const track = { artists: [{ name: 'A' }, { name: 'B' }] };
expect(utils.getTrackArtists(track)).toBe('A, B');
});
test('returns fallback if no artists', () => {
expect(utils.getTrackArtists({})).toBe('Unknown Artist');
});
});
describe('getTrackDiscNumber', () => {
test('extracts disc number from various properties', () => {
expect(utils.getTrackDiscNumber({ discNumber: 2 })).toBe(2);
expect(utils.getTrackDiscNumber({ volumeNumber: 3 })).toBe(3);
expect(utils.getTrackDiscNumber({ mediaNumber: 4 })).toBe(4);
});
test('returns null for invalid values', () => {
expect(utils.getTrackDiscNumber({ discNumber: 0 })).toBeNull();
expect(utils.getTrackDiscNumber({ discNumber: 'abc' })).toBeNull();
});
});
describe('tryCatch', () => {
test('executes sync function', () => {
const fn = vi.fn(() => 'success');
const onError = vi.fn();
expect(utils.tryCatch(fn, onError)).toBe('success');
expect(onError).not.toHaveBeenCalled();
});
test('handles sync error', () => {
const error = new Error('fail');
const fn = vi.fn(() => {
throw error;
});
const onError = vi.fn((err) => err.message);
expect(utils.tryCatch(fn, onError)).toBe('fail');
expect(onError).toHaveBeenCalledWith(error);
});
test('executes async function', async () => {
const fn = vi.fn(async () => 'success');
const onError = vi.fn();
const result = await utils.tryCatch(fn, onError);
expect(result).toBe('success');
expect(onError).not.toHaveBeenCalled();
});
test('handles async error', async () => {
const error = new Error('fail');
const fn = vi.fn(async () => {
throw error;
});
const onError = vi.fn(async (err) => err.message);
const result = await utils.tryCatch(fn, onError);
expect(result).toBe('fail');
expect(onError).toHaveBeenCalledWith(error);
});
});
});

View file

@ -1361,8 +1361,15 @@ export class UIRenderer {
}
const mainContent = document.querySelector('.main-content');
if (mainContent instanceof HTMLElement) {
this.fullscreenMainContentOverflow = mainContent.style.overflowY;
mainContent.style.overflowY = 'hidden';
const computedStyles = window.getComputedStyle(mainContent);
this.fullscreenMainContentOverflow = {
overflow: mainContent.style.overflow,
overflowX: mainContent.style.overflowX,
overflowY: mainContent.style.overflowY,
computedOverflowX: computedStyles.overflowX,
computedOverflowY: computedStyles.overflowY,
};
mainContent.style.overflow = 'hidden';
}
this.setupFullscreenControls();
@ -1413,6 +1420,14 @@ export class UIRenderer {
});
}
toggleFullscreenLyrics(overlay = document.getElementById('fullscreen-cover-overlay')) {
if (!overlay || overlay.classList.contains('lyrics-unavailable')) return false;
this.fullscreenLyricsVisible = !this.fullscreenLyricsVisible;
this.updateFullscreenLyricsVisibility(overlay);
return true;
}
updateFullscreenQualityBadgePlacement(track, overlay = document.getElementById('fullscreen-cover-overlay')) {
if (!track || !overlay) return;
@ -1421,7 +1436,8 @@ export class UIRenderer {
if (!title) return;
const qualityBadge = this.getFullscreenQualityBadgeHTML(track);
const useMobileBadgeOnly = window.matchMedia('(max-width: 768px)').matches && overlay.classList.contains('lyrics-hidden');
const useMobileBadgeOnly =
window.matchMedia('(max-width: 768px)').matches && overlay.classList.contains('lyrics-hidden');
title.innerHTML = useMobileBadgeOnly ? escapeHtml(track.title) : `${escapeHtml(track.title)} ${qualityBadge}`;
if (mobileQuality) {
@ -1488,9 +1504,32 @@ export class UIRenderer {
if (playerBar) playerBar.style.removeProperty('display');
const mainContent = document.querySelector('.main-content');
if (mainContent instanceof HTMLElement) {
if (typeof this.fullscreenMainContentOverflow === 'string' && this.fullscreenMainContentOverflow.length > 0) {
mainContent.style.overflowY = this.fullscreenMainContentOverflow;
const previousOverflow = this.fullscreenMainContentOverflow;
if (previousOverflow && typeof previousOverflow === 'object') {
if (previousOverflow.overflow) {
mainContent.style.overflow = previousOverflow.overflow;
} else {
mainContent.style.removeProperty('overflow');
}
if (previousOverflow.overflowX) {
mainContent.style.overflowX = previousOverflow.overflowX;
} else if (previousOverflow.computedOverflowX && previousOverflow.computedOverflowX !== 'visible') {
mainContent.style.overflowX = previousOverflow.computedOverflowX;
} else {
mainContent.style.removeProperty('overflow-x');
}
if (previousOverflow.overflowY) {
mainContent.style.overflowY = previousOverflow.overflowY;
} else if (previousOverflow.computedOverflowY && previousOverflow.computedOverflowY !== 'visible') {
mainContent.style.overflowY = previousOverflow.computedOverflowY;
} else {
mainContent.style.removeProperty('overflow-y');
}
} else {
mainContent.style.removeProperty('overflow');
mainContent.style.removeProperty('overflow-x');
mainContent.style.removeProperty('overflow-y');
}
this.fullscreenMainContentOverflow = null;
@ -1567,7 +1606,6 @@ export class UIRenderer {
}
if (this.visualizer) {
this.visualizer.applyPresetOverride('kawarp');
await this.visualizer.start();
overlay.classList.add('visualizer-active');
}
@ -1898,9 +1936,7 @@ export class UIRenderer {
const handleToggle = (event) => {
event.preventDefault();
event.stopPropagation();
if (overlay.classList.contains('lyrics-unavailable')) return;
this.fullscreenLyricsVisible = !this.fullscreenLyricsVisible;
this.updateFullscreenLyricsVisibility(overlay);
this.toggleFullscreenLyrics(overlay);
};
toggleButtons.forEach((toggleBtn) => toggleBtn.addEventListener('click', handleToggle));
@ -3847,6 +3883,10 @@ export class UIRenderer {
const data = await response.json();
rateCriticsEl.innerHTML = `<a href="${data.url}" target="_blank" style="color: var(--muted-foreground);">Critic Score: <span style="text-decoration: underline;">${data.critic.score}</span>, Based on ${data.critic.count} reviews</a>`;
if (data.critic.score == 'NR') {
rateCriticsEl.innerHTML = `<a style="color: var(--muted-foreground);">Critic Score Not Available Yet</a>`;
}
rateUsersEl.innerHTML = `<a href="${data.url}" target="_blank" style="color: var(--muted-foreground);">User Score: <span style="text-decoration: underline;">${data.user.score}</span>, Based on ${data.user.count} reviews</a>`;
} catch (e) {
rateCriticsEl.innerHTML = `<a style="color: var(--muted-foreground);">Unable to Fetch Critic Score</a>`;

View file

@ -1,16 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="favicon.ico" type="image/x-icon" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Redirecting...</title>
<meta http-equiv="refresh" content="0; URL='https://legacy.monochrome.tf'" />
<script>
window.location.href = 'https://legacy.monochrome.tf';
</script>
</head>
<body>
<p>If you are not redirected, <a href="https://legacy.monochrome.tf">click here</a>.</p>
</body>
</html>

View file

@ -3936,8 +3936,7 @@ input:checked + .slider::before {
radial-gradient(circle at 50% 50%, rgb(255 255 255 / 0.035), transparent 58%),
linear-gradient(180deg, rgb(6 8 12 / 0.12), rgb(6 8 12 / 0.34));
z-index: -1;
transition:
opacity 0.65s ease;
transition: opacity 0.65s ease;
opacity: calc(1 - (var(--fullscreen-drag-progress, 0) * 0.32));
}
@ -10334,7 +10333,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
#fullscreen-cover-overlay #toggle-ui-btn {
top: 1.25rem;
left: calc(9.9rem + env(safe-area-inset-left));
left: calc(1.5rem + env(safe-area-inset-left) + (40px * 3) + (0.4rem * 3));
right: auto;
width: 40px;
height: 40px;