cc
This commit is contained in:
parent
7f90a278fa
commit
0f1a9841d1
11 changed files with 1550 additions and 1185 deletions
|
|
@ -122,7 +122,7 @@
|
||||||
|
|
||||||
<div id="page-album" class="page">
|
<div id="page-album" class="page">
|
||||||
<header class="detail-header">
|
<header class="detail-header">
|
||||||
<img id="album-detail-image" src="" alt="Album Art" class="detail-header-image">
|
<img id="album-detail-image" src="" alt="" class="detail-header-image">
|
||||||
<div class="detail-header-info">
|
<div class="detail-header-info">
|
||||||
<div class="type">Album</div>
|
<div class="type">Album</div>
|
||||||
<h1 class="title" id="album-detail-title"></h1>
|
<h1 class="title" id="album-detail-title"></h1>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
//js/api.js
|
||||||
import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js';
|
import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js';
|
||||||
import { APICache } from './cache.js';
|
import { APICache } from './cache.js';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
//js/app.js
|
||||||
import { LosslessAPI } from './api.js';
|
import { LosslessAPI } from './api.js';
|
||||||
import { apiSettings } from './storage.js';
|
import { apiSettings } from './storage.js';
|
||||||
import { UIRenderer } from './ui.js';
|
import { UIRenderer } from './ui.js';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
//js/cache.js
|
||||||
export class APICache {
|
export class APICache {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.memoryCache = new Map();
|
this.memoryCache = new Map();
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
//js/player.js
|
||||||
import { REPEAT_MODE, SVG_PLAY, SVG_PAUSE, formatTime } from './utils.js';
|
import { REPEAT_MODE, SVG_PLAY, SVG_PAUSE, formatTime } from './utils.js';
|
||||||
|
|
||||||
export class Player {
|
export class Player {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
//js/storage.js
|
||||||
export const apiSettings = {
|
export const apiSettings = {
|
||||||
STORAGE_KEY: 'monochrome-api-instances',
|
STORAGE_KEY: 'monochrome-api-instances',
|
||||||
defaultInstances: [
|
defaultInstances: [
|
||||||
|
|
|
||||||
288
js/ui.js
288
js/ui.js
|
|
@ -45,6 +45,53 @@ export class UIRenderer {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createSkeletonTrack(showCover = false) {
|
||||||
|
return `
|
||||||
|
<div class="skeleton-track">
|
||||||
|
<div class="skeleton skeleton-track-number"></div>
|
||||||
|
<div class="skeleton-track-info">
|
||||||
|
${showCover ? '<div class="skeleton skeleton-track-cover"></div>' : ''}
|
||||||
|
<div class="skeleton-track-details">
|
||||||
|
<div class="skeleton skeleton-track-title"></div>
|
||||||
|
<div class="skeleton skeleton-track-artist"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton skeleton-track-duration"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
createSkeletonCard(isArtist = false) {
|
||||||
|
return `
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
createSkeletonDetailHeader(isArtist = false) {
|
||||||
|
return `
|
||||||
|
<div class="skeleton-detail-header">
|
||||||
|
<div class="skeleton skeleton-detail-image ${isArtist ? 'artist' : ''}"></div>
|
||||||
|
<div class="skeleton-detail-info">
|
||||||
|
<div class="skeleton skeleton-detail-type"></div>
|
||||||
|
<div class="skeleton skeleton-detail-title"></div>
|
||||||
|
<div class="skeleton skeleton-detail-meta"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
createSkeletonTracks(count = 5, showCover = false) {
|
||||||
|
return `<div class="skeleton-container">${Array(count).fill(0).map(() => this.createSkeletonTrack(showCover)).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
createSkeletonCards(count = 6, isArtist = false) {
|
||||||
|
return `<div class="card-grid">${Array(count).fill(0).map(() => this.createSkeletonCard(isArtist)).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
renderListWithTracks(container, tracks, showCover) {
|
renderListWithTracks(container, tracks, showCover) {
|
||||||
container.innerHTML = tracks.map((track, i) =>
|
container.innerHTML = tracks.map((track, i) =>
|
||||||
this.createTrackItemHTML(track, i, showCover)
|
this.createTrackItemHTML(track, i, showCover)
|
||||||
|
|
@ -85,92 +132,109 @@ export class UIRenderer {
|
||||||
: createPlaceholder("You haven't viewed any artists yet.");
|
: createPlaceholder("You haven't viewed any artists yet.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderSearchPage(query) {
|
async renderSearchPage(query) {
|
||||||
this.showPage('search');
|
this.showPage('search');
|
||||||
document.getElementById('search-results-title').textContent = `Search Results for "${query}"`;
|
document.getElementById('search-results-title').textContent = `Search Results for "${query}"`;
|
||||||
|
|
||||||
const tracksContainer = document.getElementById('search-tracks-container');
|
const tracksContainer = document.getElementById('search-tracks-container');
|
||||||
const artistsContainer = document.getElementById('search-artists-container');
|
const artistsContainer = document.getElementById('search-artists-container');
|
||||||
const albumsContainer = document.getElementById('search-albums-container');
|
const albumsContainer = document.getElementById('search-albums-container');
|
||||||
|
|
||||||
tracksContainer.innerHTML = createPlaceholder('Searching...', true);
|
tracksContainer.innerHTML = this.createSkeletonTracks(8, false);
|
||||||
artistsContainer.innerHTML = createPlaceholder('Searching...', true);
|
artistsContainer.innerHTML = this.createSkeletonCards(6, true);
|
||||||
albumsContainer.innerHTML = createPlaceholder('Searching...', true);
|
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [tracksResult, artistsResult, albumsResult] = await Promise.all([
|
const [tracksResult, artistsResult, albumsResult] = await Promise.all([
|
||||||
this.api.searchTracks(query),
|
this.api.searchTracks(query),
|
||||||
this.api.searchArtists(query),
|
this.api.searchArtists(query),
|
||||||
this.api.searchAlbums(query)
|
this.api.searchAlbums(query)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let finalTracks = tracksResult.items;
|
let finalTracks = tracksResult.items;
|
||||||
let finalArtists = artistsResult.items;
|
let finalArtists = artistsResult.items;
|
||||||
let finalAlbums = albumsResult.items;
|
let finalAlbums = albumsResult.items;
|
||||||
|
|
||||||
if (finalArtists.length === 0 && finalTracks.length > 0) {
|
if (finalArtists.length === 0 && finalTracks.length > 0) {
|
||||||
console.log('Using fallback: extracting artists from tracks');
|
console.log('Using fallback: extracting artists from tracks');
|
||||||
const artistMap = new Map();
|
const artistMap = new Map();
|
||||||
finalTracks.forEach(track => {
|
finalTracks.forEach(track => {
|
||||||
if (track.artist && !artistMap.has(track.artist.id)) {
|
if (track.artist && !artistMap.has(track.artist.id)) {
|
||||||
artistMap.set(track.artist.id, track.artist);
|
artistMap.set(track.artist.id, track.artist);
|
||||||
}
|
}
|
||||||
if (track.artists) {
|
if (track.artists) {
|
||||||
track.artists.forEach(artist => {
|
track.artists.forEach(artist => {
|
||||||
if (!artistMap.has(artist.id)) {
|
if (!artistMap.has(artist.id)) {
|
||||||
artistMap.set(artist.id, artist);
|
artistMap.set(artist.id, artist);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
finalArtists = Array.from(artistMap.values());
|
finalArtists = Array.from(artistMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalAlbums.length === 0 && finalTracks.length > 0) {
|
||||||
|
console.log('Using fallback: extracting albums from tracks');
|
||||||
|
const albumMap = new Map();
|
||||||
|
finalTracks.forEach(track => {
|
||||||
|
if (track.album && !albumMap.has(track.album.id)) {
|
||||||
|
albumMap.set(track.album.id, track.album);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
finalAlbums = Array.from(albumMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalTracks.length) {
|
||||||
|
this.renderListWithTracks(tracksContainer, finalTracks, false);
|
||||||
|
} else {
|
||||||
|
tracksContainer.innerHTML = createPlaceholder('No tracks found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
artistsContainer.innerHTML = finalArtists.length
|
||||||
|
? finalArtists.map(artist => this.createArtistCardHTML(artist)).join('')
|
||||||
|
: createPlaceholder('No artists found.');
|
||||||
|
|
||||||
|
albumsContainer.innerHTML = finalAlbums.length
|
||||||
|
? finalAlbums.map(album => this.createAlbumCardHTML(album)).join('')
|
||||||
|
: createPlaceholder('No albums found.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search failed:", error);
|
||||||
|
const errorMsg = createPlaceholder(`Error during search. ${error.message}`);
|
||||||
|
tracksContainer.innerHTML = errorMsg;
|
||||||
|
artistsContainer.innerHTML = errorMsg;
|
||||||
|
albumsContainer.innerHTML = errorMsg;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalAlbums.length === 0 && finalTracks.length > 0) {
|
|
||||||
console.log('Using fallback: extracting albums from tracks');
|
|
||||||
const albumMap = new Map();
|
|
||||||
finalTracks.forEach(track => {
|
|
||||||
if (track.album && !albumMap.has(track.album.id)) {
|
|
||||||
albumMap.set(track.album.id, track.album);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
finalAlbums = Array.from(albumMap.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalTracks.length) {
|
|
||||||
this.renderListWithTracks(tracksContainer, finalTracks, false);
|
|
||||||
} else {
|
|
||||||
tracksContainer.innerHTML = createPlaceholder('No tracks found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
artistsContainer.innerHTML = finalArtists.length
|
|
||||||
? finalArtists.map(artist => this.createArtistCardHTML(artist)).join('')
|
|
||||||
: createPlaceholder('No artists found.');
|
|
||||||
|
|
||||||
albumsContainer.innerHTML = finalAlbums.length
|
|
||||||
? finalAlbums.map(album => this.createAlbumCardHTML(album)).join('')
|
|
||||||
: createPlaceholder('No albums found.');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Search failed:", error);
|
|
||||||
const errorMsg = createPlaceholder(`Error during search. ${error.message}`);
|
|
||||||
tracksContainer.innerHTML = errorMsg;
|
|
||||||
artistsContainer.innerHTML = errorMsg;
|
|
||||||
albumsContainer.innerHTML = errorMsg;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async renderAlbumPage(albumId) {
|
async renderAlbumPage(albumId) {
|
||||||
this.showPage('album');
|
this.showPage('album');
|
||||||
|
|
||||||
|
const imageEl = document.getElementById('album-detail-image');
|
||||||
|
const titleEl = document.getElementById('album-detail-title');
|
||||||
|
const metaEl = document.getElementById('album-detail-meta');
|
||||||
const tracklistContainer = document.getElementById('album-detail-tracklist');
|
const tracklistContainer = document.getElementById('album-detail-tracklist');
|
||||||
tracklistContainer.innerHTML = createPlaceholder('Loading...', true);
|
|
||||||
|
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>';
|
||||||
|
tracklistContainer.innerHTML = `
|
||||||
|
<div class="track-list-header">
|
||||||
|
<span style="width: 40px; text-align: center;">#</span>
|
||||||
|
<span>Title</span>
|
||||||
|
<span class="duration-header">Duration</span>
|
||||||
|
</div>
|
||||||
|
${this.createSkeletonTracks(10, false)}
|
||||||
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { album, tracks } = await this.api.getAlbum(albumId);
|
const { album, tracks } = await this.api.getAlbum(albumId);
|
||||||
|
|
||||||
document.getElementById('album-detail-image').src = this.api.getCoverUrl(album.cover);
|
imageEl.src = this.api.getCoverUrl(album.cover);
|
||||||
document.getElementById('album-detail-title').textContent = album.title;
|
imageEl.style.backgroundColor = '';
|
||||||
document.getElementById('album-detail-meta').innerHTML =
|
titleEl.textContent = album.title;
|
||||||
|
metaEl.innerHTML =
|
||||||
`By <a href="#artist/${album.artist.id}">${album.artist.name}</a> • ${new Date(album.releaseDate).getFullYear()}`;
|
`By <a href="#artist/${album.artist.id}">${album.artist.name}</a> • ${new Date(album.releaseDate).getFullYear()}`;
|
||||||
|
|
||||||
tracklistContainer.innerHTML = `
|
tracklistContainer.innerHTML = `
|
||||||
|
|
@ -193,19 +257,27 @@ async renderSearchPage(query) {
|
||||||
|
|
||||||
async renderArtistPage(artistId) {
|
async renderArtistPage(artistId) {
|
||||||
this.showPage('artist');
|
this.showPage('artist');
|
||||||
|
|
||||||
|
const imageEl = document.getElementById('artist-detail-image');
|
||||||
|
const nameEl = document.getElementById('artist-detail-name');
|
||||||
|
const metaEl = document.getElementById('artist-detail-meta');
|
||||||
const tracksContainer = document.getElementById('artist-detail-tracks');
|
const tracksContainer = document.getElementById('artist-detail-tracks');
|
||||||
const albumsContainer = document.getElementById('artist-detail-albums');
|
const albumsContainer = document.getElementById('artist-detail-albums');
|
||||||
|
|
||||||
tracksContainer.innerHTML = albumsContainer.innerHTML = createPlaceholder('Loading...', true);
|
imageEl.src = '';
|
||||||
|
imageEl.style.backgroundColor = 'var(--muted)';
|
||||||
|
nameEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
|
||||||
|
metaEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 150px;"></div>';
|
||||||
|
tracksContainer.innerHTML = this.createSkeletonTracks(5, true);
|
||||||
|
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const artist = await this.api.getArtist(artistId);
|
const artist = await this.api.getArtist(artistId);
|
||||||
|
|
||||||
document.getElementById('artist-detail-image').src =
|
imageEl.src = this.api.getArtistPictureUrl(artist.picture, '750');
|
||||||
this.api.getArtistPictureUrl(artist.picture, '750');
|
imageEl.style.backgroundColor = '';
|
||||||
document.getElementById('artist-detail-name').textContent = artist.name;
|
nameEl.textContent = artist.name;
|
||||||
document.getElementById('artist-detail-meta').textContent =
|
metaEl.textContent = `${artist.popularity} popularity`;
|
||||||
`${artist.popularity} popularity`;
|
|
||||||
|
|
||||||
this.renderListWithTracks(tracksContainer, artist.tracks, true);
|
this.renderListWithTracks(tracksContainer, artist.tracks, true);
|
||||||
albumsContainer.innerHTML = artist.albums.map(album =>
|
albumsContainer.innerHTML = artist.albums.map(album =>
|
||||||
|
|
@ -220,41 +292,41 @@ async renderSearchPage(query) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderApiSettings() {
|
renderApiSettings() {
|
||||||
const container = document.getElementById('api-instance-list');
|
const container = document.getElementById('api-instance-list');
|
||||||
const instances = this.api.settings.getInstances();
|
const instances = this.api.settings.getInstances();
|
||||||
const defaultInstancesSet = new Set(this.api.settings.defaultInstances);
|
const defaultInstancesSet = new Set(this.api.settings.defaultInstances);
|
||||||
|
|
||||||
container.innerHTML = instances.map((url, index) => `
|
container.innerHTML = instances.map((url, index) => `
|
||||||
<li data-index="${index}">
|
<li data-index="${index}">
|
||||||
<span class="instance-url">${url}</span>
|
<span class="instance-url">${url}</span>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button class="move-up" title="Move Up" ${index === 0 ? 'disabled' : ''}>
|
<button class="move-up" title="Move Up" ${index === 0 ? 'disabled' : ''}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 19V5M5 12l7-7 7 7"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="move-down" title="Move Down" ${index === instances.length - 1 ? 'disabled' : ''}>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 5v14M19 12l-7 7-7-7"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
${!defaultInstancesSet.has(url) ? `
|
|
||||||
<button class="delete-instance" title="Delete">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
<path d="M12 19V5M5 12l7-7 7 7"/>
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
<button class="move-down" title="Move Down" ${index === instances.length - 1 ? 'disabled' : ''}>
|
||||||
</div>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
</li>
|
<path d="M12 5v14M19 12l-7 7-7-7"/>
|
||||||
`).join('');
|
</svg>
|
||||||
|
</button>
|
||||||
|
${!defaultInstancesSet.has(url) ? `
|
||||||
|
<button class="delete-instance" title="Delete">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
const stats = this.api.getCacheStats();
|
const stats = this.api.getCacheStats();
|
||||||
const cacheInfo = document.getElementById('cache-info');
|
const cacheInfo = document.getElementById('cache-info');
|
||||||
if (cacheInfo) {
|
if (cacheInfo) {
|
||||||
cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`;
|
cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
//js/utils.js
|
||||||
export const QUALITY = 'LOSSLESS';
|
export const QUALITY = 'LOSSLESS';
|
||||||
|
|
||||||
export const REPEAT_MODE = {
|
export const REPEAT_MODE = {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"name": "Search",
|
"name": "Search",
|
||||||
"short_name": "Search",
|
"short_name": "Search",
|
||||||
"description": "Search for music",
|
"description": "Search for music",
|
||||||
"url": "/#search",
|
"url": "/search",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "https://prigoana.com/favicon.png",
|
"src": "https://prigoana.com/favicon.png",
|
||||||
|
|
|
||||||
2430
styles.css
2430
styles.css
File diff suppressed because it is too large
Load diff
1
sw.js
1
sw.js
|
|
@ -1,3 +1,4 @@
|
||||||
|
//sw.js
|
||||||
const CACHE_NAME = 'monochrome-v1';
|
const CACHE_NAME = 'monochrome-v1';
|
||||||
const urlsToCache = [
|
const urlsToCache = [
|
||||||
'/',
|
'/',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue