Merge pull request #5 from JulienMaille/main

UI Enhancements, Light Theme Support, and Miscellaneous Fixes
This commit is contained in:
Samidy 2025-12-23 04:37:25 -08:00 committed by GitHub
commit 70c942e39b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 225 additions and 36 deletions

View file

@ -47,7 +47,7 @@
<div>
<div class="sidebar-logo">
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="14.75 14.75 70.5 70.5" >
<g fill="white" >
<g fill="currentColor" >
<path d="M38.25 14.75H85.25V61.75H61.75V38.25H38.25ZM14.75 38.25H38.25V61.75H61.75V85.25H14.75Z" />
</g>
</svg>
@ -138,9 +138,9 @@
<header class="detail-header">
<img id="album-detail-image" src="" alt="" class="detail-header-image">
<div class="detail-header-info">
<div class="type">Album</div>
<h1 class="title" id="album-detail-title"></h1>
<div class="meta" id="album-detail-meta"></div>
<div class="meta" id="album-detail-producer"></div>
<div class="detail-header-actions">
<button id="play-album-btn" class="btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@ -236,8 +236,10 @@
</div>
</div>
<div class="theme-picker" id="theme-picker">
<div class="theme-option" data-theme="monochrome">Black</div>
<div class="theme-option" data-theme="system">System</div>
<div class="theme-option" data-theme="light">Light</div>
<div class="theme-option" data-theme="dark">Dark</div>
<div class="theme-option" data-theme="monochrome">Black</div>
<div class="theme-option" data-theme="ocean">Ocean</div>
<div class="theme-option" data-theme="purple">Purple</div>
<div class="theme-option" data-theme="forest">Forest</div>

View file

@ -324,6 +324,20 @@ export class LosslessAPI {
}
}
// If album exists but has no releaseDate, try to extract from tracks
if (album && !album.releaseDate && tracksSection?.items && tracksSection.items.length > 0) {
const firstTrack = tracksSection.items[0];
const track = firstTrack.item || firstTrack;
if (track) {
if (track.album && track.album.releaseDate) {
album = { ...album, releaseDate: track.album.releaseDate };
} else if (track.streamStartDate) {
album = { ...album, releaseDate: track.streamStartDate.split('T')[0] };
}
}
}
const tracks = (tracksSection?.items || []).map(i => this.prepareTrack(i.item || i));
const result = { album, tracks };

View file

@ -207,10 +207,13 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
const zip = new JSZip();
const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf';
const releaseDate = album.releaseDate ? new Date(album.releaseDate) : null;
const year = (releaseDate && !isNaN(releaseDate.getTime())) ? releaseDate.getFullYear() : '';
const folderName = formatTemplate(template, {
albumTitle: album.title,
albumArtist: album.artist?.name,
year: new Date(album.releaseDate).getFullYear()
year: year
});
const notification = createBulkDownloadNotification('album', album.title, tracks.length);
@ -380,10 +383,13 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
try {
const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
const releaseDate = fullAlbum.releaseDate ? new Date(fullAlbum.releaseDate) : null;
const year = (releaseDate && !isNaN(releaseDate.getTime())) ? releaseDate.getFullYear() : '';
const albumFolder = formatTemplate(template, {
albumTitle: fullAlbum.title,
albumArtist: fullAlbum.artist?.name,
year: new Date(fullAlbum.releaseDate).getFullYear()
year: year
});
for (const track of tracks) {

View file

@ -275,9 +275,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
contextTrack = trackDataStore.get(trackItem);
if (contextTrack) {
const rect = menuBtn.getBoundingClientRect();
contextMenu.style.top = `${rect.bottom + 5}px`;
contextMenu.style.left = `${rect.left}px`;
contextMenu.style.display = 'block';
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
}
}
return;
@ -307,9 +305,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
contextTrack = trackDataStore.get(trackItem);
if (contextTrack) {
contextMenu.style.top = `${e.pageY}px`;
contextMenu.style.left = `${e.pageX}px`;
contextMenu.style.display = 'block';
positionMenu(contextMenu, e.pageX, e.pageY);
}
}
});
@ -387,3 +383,42 @@ function formatTime(seconds) {
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
function positionMenu(menu, x, y, anchorRect = null) {
// Temporarily show to measure dimensions
menu.style.visibility = 'hidden';
menu.style.display = 'block';
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
let left = x;
let top = y;
if (anchorRect) {
// Adjust horizontal position if it overflows right
if (left + menuWidth > windowWidth - 10) { // 10px buffer
left = anchorRect.right - menuWidth;
if (left < 10) left = 10;
}
// Adjust vertical position if it overflows bottom
if (top + menuHeight > windowHeight - 10) {
top = anchorRect.top - menuHeight - 5;
}
} else {
// Adjust horizontal position if it overflows right
if (left + menuWidth > windowWidth - 10) {
left = windowWidth - menuWidth - 10;
}
// Adjust vertical position if it overflows bottom
if (top + menuHeight > windowHeight - 10) {
top = y - menuHeight;
}
}
menu.style.top = `${top}px`;
menu.style.left = `${left}px`;
menu.style.visibility = 'visible';
}

View file

@ -229,8 +229,9 @@ export const themeManager = {
CUSTOM_THEME_KEY: 'monochrome-custom-theme',
defaultThemes: {
monochrome: {},
light: {},
dark: {},
monochrome: {},
ocean: {},
purple: {},
forest: {}
@ -238,15 +239,21 @@ export const themeManager = {
getTheme() {
try {
return localStorage.getItem(this.STORAGE_KEY) || 'monochrome';
return localStorage.getItem(this.STORAGE_KEY) || 'system';
} catch (e) {
return 'monochrome';
return 'system';
}
},
setTheme(theme) {
localStorage.setItem(this.STORAGE_KEY, theme);
document.documentElement.setAttribute('data-theme', theme);
if (theme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-theme', theme);
}
},
getCustomTheme() {
@ -318,3 +325,12 @@ export const lyricsSettings = {
localStorage.setItem(this.DOWNLOAD_WITH_TRACKS, enabled ? 'true' : 'false');
}
};
// System theme listener
if (typeof window !== 'undefined' && window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (themeManager.getTheme() === 'system') {
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
}
});
}

View file

@ -23,14 +23,41 @@ export class UIRenderer {
`;
}
createTrackItemHTML(track, index, showCover = false) {
adjustTitleFontSize(element, text) {
element.classList.remove('long-title', 'very-long-title');
if (text.length > 40) {
element.classList.add('very-long-title');
} else if (text.length > 25) {
element.classList.add('long-title');
}
}
createTrackItemHTML(track, index, showCover = false, hasMultipleDiscs = false) {
const playIconSmall = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>';
const trackImageHTML = showCover ? `<img src="${this.api.getCoverUrl(track.album?.cover, '80')}" alt="Track Cover" class="track-item-cover" loading="lazy">` : '';
const trackNumberHTML = `<div class="track-number">${showCover ? trackImageHTML : index + 1}</div>`;
let displayIndex;
if (hasMultipleDiscs && !showCover) {
const discNum = track.volumeNumber ?? track.discNumber ?? 1;
displayIndex = `${discNum}-${track.trackNumber}`;
} else {
displayIndex = index + 1;
}
const trackNumberHTML = `<div class="track-number">${showCover ? trackImageHTML : displayIndex}</div>`;
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
const trackArtists = getTrackArtists(track);
const trackTitle = getTrackTitle(track);
let yearDisplay = '';
const releaseDate = track.album?.releaseDate || track.streamStartDate;
if (releaseDate) {
const date = new Date(releaseDate);
if (!isNaN(date.getTime())) {
yearDisplay = `${date.getFullYear()}`;
}
}
return `
<div class="track-item" data-track-id="${track.id}">
${trackNumberHTML}
@ -40,7 +67,7 @@ export class UIRenderer {
${trackTitle}
${explicitBadge}
</div>
<div class="artist">${trackArtists}</div>
<div class="artist">${trackArtists}${yearDisplay}</div>
</div>
</div>
<div class="track-item-duration">${formatTime(track.duration)}</div>
@ -57,13 +84,21 @@ export class UIRenderer {
createAlbumCardHTML(album) {
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
let yearDisplay = '';
if (album.releaseDate) {
const date = new Date(album.releaseDate);
if (!isNaN(date.getTime())) {
yearDisplay = `${date.getFullYear()}`;
}
}
return `
<a href="#album/${album.id}" class="card">
<div class="card-image-wrapper">
<img src="${this.api.getCoverUrl(album.cover, '320')}" alt="${album.title}" class="card-image" loading="lazy">
</div>
<h3 class="card-title">${album.title} ${explicitBadge}</h3>
<p class="card-subtitle">Album ${album.artist?.name ?? ''}</p>
<p class="card-subtitle">${album.artist?.name ?? ''}</p>
<p class="card-subtitle">${yearDisplay}</p>
</a>
`;
}
@ -75,7 +110,6 @@ export class UIRenderer {
<img src="${this.api.getArtistPictureUrl(artist.picture, '320')}" alt="${artist.name}" class="card-image" loading="lazy">
</div>
<h3 class="card-title">${artist.name}</h3>
<p class="card-subtitle">Artist</p>
</a>
`;
}
@ -100,7 +134,7 @@ export class UIRenderer {
<div class="skeleton-card ${isArtist ? 'artist' : ''}">
<div class="skeleton skeleton-card-image"></div>
<div class="skeleton skeleton-card-title"></div>
<div class="skeleton skeleton-card-subtitle"></div>
${!isArtist ? '<div class="skeleton skeleton-card-subtitle"></div>' : ''}
</div>
`;
}
@ -117,8 +151,11 @@ export class UIRenderer {
const fragment = document.createDocumentFragment();
const tempDiv = document.createElement('div');
// Check if there are multiple discs in the tracks array
const hasMultipleDiscs = tracks.some(t => (t.volumeNumber || t.discNumber || 1) > 1);
tempDiv.innerHTML = tracks.map((track, i) =>
this.createTrackItemHTML(track, i, showCover)
this.createTrackItemHTML(track, i, showCover, hasMultipleDiscs)
).join('');
while (tempDiv.firstChild) {
@ -245,12 +282,14 @@ export class UIRenderer {
const imageEl = document.getElementById('album-detail-image');
const titleEl = document.getElementById('album-detail-title');
const metaEl = document.getElementById('album-detail-meta');
const prodEl = document.getElementById('album-detail-producer');
const tracklistContainer = document.getElementById('album-detail-tracklist');
imageEl.src = '';
imageEl.style.backgroundColor = 'var(--muted)';
titleEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
metaEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 200px; max-width: 80%;"></div>';
prodEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 200px; max-width: 80%;"></div>';
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
@ -269,16 +308,29 @@ export class UIRenderer {
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
titleEl.innerHTML = `${album.title} ${explicitBadge}`;
const totalDuration = calculateTotalDuration(tracks);
const releaseDate = new Date(album.releaseDate);
const year = releaseDate.getFullYear();
this.adjustTitleFontSize(titleEl, album.title);
const dateDisplay = window.innerWidth > 768
? releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
: year;
const totalDuration = calculateTotalDuration(tracks);
let dateDisplay = '';
if (album.releaseDate) {
const releaseDate = new Date(album.releaseDate);
if (!isNaN(releaseDate.getTime())) {
const year = releaseDate.getFullYear();
dateDisplay = window.innerWidth > 768
? releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
: year;
}
}
const firstCopyright = tracks.find(track => track.copyright)?.copyright;
metaEl.innerHTML =
`By <a href="#artist/${album.artist.id}">${album.artist.name}</a> • ${dateDisplay}${tracks.length} tracks • ${formatDuration(totalDuration)}`;
(dateDisplay ? `${dateDisplay}` : '') +
`${tracks.length} tracks • ${formatDuration(totalDuration)}`;
prodEl.innerHTML =
`By <a href="#artist/${album.artist.id}">${album.artist.name}</a>` +
(firstCopyright ? `${firstCopyright}` : '');
tracklistContainer.innerHTML = `
<div class="track-list-header">
@ -288,7 +340,12 @@ export class UIRenderer {
</div>
`;
tracks.sort((a, b) => a.trackNumber - b.trackNumber);
tracks.sort((a, b) => {
const discA = a.volumeNumber ?? a.discNumber ?? 1;
const discB = b.volumeNumber ?? b.discNumber ?? 1;
if (discA !== discB) return discA - discB;
return a.trackNumber - b.trackNumber;
});
this.renderListWithTracks(tracklistContainer, tracks, false);
recentActivityManager.addAlbum(album);
@ -332,6 +389,8 @@ async renderPlaylistPage(playlistId) {
titleEl.textContent = playlist.title;
this.adjustTitleFontSize(titleEl, playlist.title);
const totalDuration = calculateTotalDuration(tracks);
metaEl.textContent = `${playlist.numberOfTracks} tracks • ${formatDuration(totalDuration)}`;
@ -377,6 +436,9 @@ async renderPlaylistPage(playlistId) {
imageEl.src = this.api.getArtistPictureUrl(artist.picture, '750');
imageEl.style.backgroundColor = '';
nameEl.textContent = artist.name;
this.adjustTitleFontSize(nameEl, artist.name);
metaEl.textContent = `${artist.popularity} popularity`;
this.renderListWithTracks(tracksContainer, artist.tracks, true);

View file

@ -114,6 +114,27 @@
--explicit-badge: #f59e0b;
}
:root[data-theme="light"] {
--background: #ffffff;
--foreground: #000000;
--card: #f4f4f5;
--card-foreground: #000000;
--primary: #2563eb;
--primary-foreground: #ffffff;
--secondary: #e4e4e7;
--secondary-foreground: #000000;
--muted: #e4e4e7;
--muted-foreground: #71717a;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #2563eb;
--highlight: #2563eb;
--highlight-rgb: 37, 99, 235;
--active-highlight: var(--highlight);
--explicit-badge: #ef4444;
--explicit-badge-foreground: #ffffff;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
@ -166,19 +187,18 @@ kbd {
height: 100dvh;
grid-template:
"sidebar main" 1fr
"player player" auto / 280px 1fr;
"player player" auto / 190px 1fr;
}
.sidebar {
grid-area: sidebar;
background-color: var(--background);
border-right: 1px solid var(--border);
padding: 1.5rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 2rem;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 2000;
}
.main-content {
@ -190,7 +210,7 @@ kbd {
.now-playing-bar {
grid-area: player;
background-color: #050505;
background-color: var(--card);
border-top: 1px solid var(--border);
padding: var(--spacing-md) var(--spacing-lg);
display: grid;
@ -491,7 +511,7 @@ kbd {
align-items: center;
justify-content: center;
background-color: var(--explicit-badge);
color: #000;
color: var(--explicit-badge-foreground, #000);
font-size: 0.65rem;
font-weight: 700;
padding: 0.15rem 0.35rem;
@ -682,6 +702,15 @@ kbd {
align-items: center;
gap: 1rem;
flex-wrap: wrap;
word-break: break-word;
}
.detail-header-info .title.long-title {
font-size: 2.5rem;
}
.detail-header-info .title.very-long-title {
font-size: 1.75rem;
}
.detail-header-info .meta {
@ -1805,7 +1834,7 @@ input:checked + .slider::before {
/* Responsive Design */
@media (max-width: 1024px) {
.app-container {
grid-template-columns: 240px 1fr;
grid-template-columns: 160px 1fr;
}
.card-grid {
@ -1817,6 +1846,14 @@ input:checked + .slider::before {
font-size: 3rem;
}
.detail-header-info .title.long-title {
font-size: 2rem;
}
.detail-header-info .title.very-long-title {
font-size: 1.35rem;
}
.main-content {
padding: var(--spacing-lg);
}
@ -1850,6 +1887,7 @@ input:checked + .slider::before {
height: 100%;
transform: translateX(-100%);
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
z-index: 2000;
}
.sidebar.is-open {
@ -1900,6 +1938,14 @@ input:checked + .slider::before {
line-height: 1.2;
}
.detail-header-info .title.long-title {
font-size: 1.5rem;
}
.detail-header-info .title.very-long-title {
font-size: 1.25rem;
}
.detail-header-info .meta {
font-size: 0.85rem;
gap: 0.35rem;
@ -2136,6 +2182,14 @@ input:checked + .slider::before {
font-size: 1.75rem;
}
.detail-header-info .title.long-title {
font-size: 1.35rem;
}
.detail-header-info .title.very-long-title {
font-size: 1.1rem;
}
.search-tab {
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.9rem;