fix: honor selected visualizer & fix scrollbar bug and lyrics shortcut bug
This commit is contained in:
commit
4dba288592
19 changed files with 752 additions and 70 deletions
0
fix-gen.py → .github/scripts/fix-gen.py
vendored
0
fix-gen.py → .github/scripts/fix-gen.py
vendored
2
.github/workflows/editors-picks.yml
vendored
2
.github/workflows/editors-picks.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
12
index.html
12
index.html
|
|
@ -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">
|
||||
|
|
|
|||
46
js/app.js
46
js/app.js
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
39
js/db.js
39
js/db.js
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
107
js/tests/db.test.js
Normal 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
195
js/tests/player.test.js
Normal 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
125
js/tests/storage.test.js
Normal 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
211
js/tests/utils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
js/ui.js
58
js/ui.js
|
|
@ -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>`;
|
||||
|
|
|
|||
16
legacy.html
16
legacy.html
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue