diff --git a/js/app.js b/js/app.js
index a61aff2..311d15c 100644
--- a/js/app.js
+++ b/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();
}
});
diff --git a/js/db.js b/js/db.js
index 95c306b..acd548e 100644
--- a/js/db.js
+++ b/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);
});
}
diff --git a/js/player.js b/js/player.js
index 81f57fe..508e9d5 100644
--- a/js/player.js
+++ b/js/player.js
@@ -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();
}
diff --git a/js/storage.js b/js/storage.js
index 222fa10..f702eac 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -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());
},
diff --git a/js/tests/db.test.js b/js/tests/db.test.js
new file mode 100644
index 0000000..8f24d57
--- /dev/null
+++ b/js/tests/db.test.js
@@ -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);
+ });
+});
diff --git a/js/tests/player.test.js b/js/tests/player.test.js
new file mode 100644
index 0000000..07821a9
--- /dev/null
+++ b/js/tests/player.test.js
@@ -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 = `
+
+
+
+
![]()
+
+
+
+
+
+ `;
+
+ 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);
+ });
+});
diff --git a/js/tests/storage.test.js b/js/tests/storage.test.js
new file mode 100644
index 0000000..3162623
--- /dev/null
+++ b/js/tests/storage.test.js
@@ -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);
+ });
+ });
+});
diff --git a/js/tests/utils.test.js b/js/tests/utils.test.js
new file mode 100644
index 0000000..edacfbc
--- /dev/null
+++ b/js/tests/utils.test.js
@@ -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: () => '
',
+}));
+
+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
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);
+ });
+ });
+});
diff --git a/js/ui.js b/js/ui.js
index da78079..225b908 100644
--- a/js/ui.js
+++ b/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 = `Critic Score: ${data.critic.score}, Based on ${data.critic.count} reviews`;
+
+ if (data.critic.score == 'NR') {
+ rateCriticsEl.innerHTML = `Critic Score Not Available Yet`;
+ }
rateUsersEl.innerHTML = `User Score: ${data.user.score}, Based on ${data.user.count} reviews`;
} catch (e) {
rateCriticsEl.innerHTML = `Unable to Fetch Critic Score`;
diff --git a/legacy.html b/legacy.html
deleted file mode 100644
index d2d75f9..0000000
--- a/legacy.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
- Redirecting...
-
-
-
-
- If you are not redirected, click here.
-
-
diff --git a/editors-picks-input.txt b/public/editors-picks-input.txt
similarity index 100%
rename from editors-picks-input.txt
rename to public/editors-picks-input.txt
diff --git a/styles.css b/styles.css
index 04e9b75..9f66f1d 100644
--- a/styles.css
+++ b/styles.css
@@ -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;