This commit is contained in:
Eduard Prigoana 2025-10-11 19:22:53 +03:00
parent 7f90a278fa
commit 0f1a9841d1
11 changed files with 1550 additions and 1185 deletions

View file

@ -122,7 +122,7 @@
<div id="page-album" class="page">
<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="type">Album</div>
<h1 class="title" id="album-detail-title"></h1>

View file

@ -1,3 +1,4 @@
//js/api.js
import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js';
import { APICache } from './cache.js';

View file

@ -1,3 +1,4 @@
//js/app.js
import { LosslessAPI } from './api.js';
import { apiSettings } from './storage.js';
import { UIRenderer } from './ui.js';

View file

@ -1,3 +1,4 @@
//js/cache.js
export class APICache {
constructor(options = {}) {
this.memoryCache = new Map();

View file

@ -1,3 +1,4 @@
//js/player.js
import { REPEAT_MODE, SVG_PLAY, SVG_PAUSE, formatTime } from './utils.js';
export class Player {

View file

@ -1,3 +1,4 @@
//js/storage.js
export const apiSettings = {
STORAGE_KEY: 'monochrome-api-instances',
defaultInstances: [

View file

@ -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) {
container.innerHTML = tracks.map((track, i) =>
this.createTrackItemHTML(track, i, showCover)
@ -93,9 +140,9 @@ async renderSearchPage(query) {
const artistsContainer = document.getElementById('search-artists-container');
const albumsContainer = document.getElementById('search-albums-container');
tracksContainer.innerHTML = createPlaceholder('Searching...', true);
artistsContainer.innerHTML = createPlaceholder('Searching...', true);
albumsContainer.innerHTML = createPlaceholder('Searching...', true);
tracksContainer.innerHTML = this.createSkeletonTracks(8, false);
artistsContainer.innerHTML = this.createSkeletonCards(6, true);
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
try {
const [tracksResult, artistsResult, albumsResult] = await Promise.all([
@ -162,15 +209,32 @@ async renderSearchPage(query) {
async renderAlbumPage(albumId) {
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');
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 {
const { album, tracks } = await this.api.getAlbum(albumId);
document.getElementById('album-detail-image').src = this.api.getCoverUrl(album.cover);
document.getElementById('album-detail-title').textContent = album.title;
document.getElementById('album-detail-meta').innerHTML =
imageEl.src = this.api.getCoverUrl(album.cover);
imageEl.style.backgroundColor = '';
titleEl.textContent = album.title;
metaEl.innerHTML =
`By <a href="#artist/${album.artist.id}">${album.artist.name}</a> • ${new Date(album.releaseDate).getFullYear()}`;
tracklistContainer.innerHTML = `
@ -193,19 +257,27 @@ async renderSearchPage(query) {
async renderArtistPage(artistId) {
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 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 {
const artist = await this.api.getArtist(artistId);
document.getElementById('artist-detail-image').src =
this.api.getArtistPictureUrl(artist.picture, '750');
document.getElementById('artist-detail-name').textContent = artist.name;
document.getElementById('artist-detail-meta').textContent =
`${artist.popularity} popularity`;
imageEl.src = this.api.getArtistPictureUrl(artist.picture, '750');
imageEl.style.backgroundColor = '';
nameEl.textContent = artist.name;
metaEl.textContent = `${artist.popularity} popularity`;
this.renderListWithTracks(tracksContainer, artist.tracks, true);
albumsContainer.innerHTML = artist.albums.map(album =>

View file

@ -1,3 +1,4 @@
//js/utils.js
export const QUALITY = 'LOSSLESS';
export const REPEAT_MODE = {

View file

@ -28,7 +28,7 @@
"name": "Search",
"short_name": "Search",
"description": "Search for music",
"url": "/#search",
"url": "/search",
"icons": [
{
"src": "https://prigoana.com/favicon.png",

View file

@ -1,3 +1,4 @@
/*styles.css*/
:root {
--background: #000;
--foreground: #fafafa;
@ -15,6 +16,12 @@
--radius: .5rem;
--highlight: #4ade80;
--active-highlight: var(--highlight);
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
}
*, *::before, *::after {
@ -34,10 +41,13 @@ body {
font-family: 'Inter', sans-serif;
overflow: hidden;
}
img {
max-width: 100%;
display: block;
background-color: var(--muted);
color: transparent;
font-size: 0;
border: none;
}
a {
@ -70,18 +80,18 @@ a {
.main-content {
grid-area: main;
overflow-y: auto;
padding: 2rem;
padding: var(--spacing-xl);
}
.now-playing-bar {
grid-area: player;
background-color: #050505;
border-top: 1px solid var(--border);
padding: .75rem 1.5rem;
padding: var(--spacing-md) var(--spacing-lg);
display: grid;
grid-template-columns: 1fr 2fr 1fr;
align-items: center;
gap: 2rem;
gap: var(--spacing-xl);
}
.sidebar-logo {
@ -140,15 +150,22 @@ a {
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.main-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
margin-bottom: var(--spacing-xl);
gap: var(--spacing-md);
}
.hamburger-menu {
@ -192,19 +209,19 @@ a {
}
.content-section {
margin-bottom: 3rem;
margin-bottom: var(--spacing-2xl);
}
.section-title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 1.5rem;
margin-bottom: var(--spacing-lg);
}
.search-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--border);
}
@ -212,7 +229,7 @@ a {
background: transparent;
border: none;
color: var(--muted-foreground);
padding: 0.75rem 1.5rem;
padding: var(--spacing-sm) var(--spacing-lg);
cursor: pointer;
font-size: 1rem;
font-weight: 500;
@ -241,7 +258,7 @@ a {
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1.5rem;
gap: var(--spacing-lg);
}
.card {
@ -288,19 +305,19 @@ a {
.track-list {
display: flex;
flex-direction: column;
gap: .25rem;
gap: 2px;
}
.track-list .track-list-header {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: 1rem;
padding: .75rem;
gap: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-sm);
color: var(--muted-foreground);
font-size: .9rem;
border-bottom: 1px solid var(--border);
margin-bottom: .5rem;
margin-bottom: var(--spacing-xs);
}
.track-list .track-list-header .duration-header {
@ -311,8 +328,8 @@ a {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: 1rem;
padding: .75rem;
gap: var(--spacing-md);
padding: var(--spacing-sm);
border-radius: var(--radius);
cursor: pointer;
transition: background-color .2s ease-in-out;
@ -331,7 +348,7 @@ a {
.track-item-info {
display: flex;
align-items: center;
gap: 1rem;
gap: var(--spacing-md);
min-width: 0;
}
@ -377,8 +394,9 @@ a {
.detail-header {
display: flex;
align-items: flex-end;
gap: 2rem;
margin-bottom: 3rem;
gap: var(--spacing-xl);
margin-bottom: var(--spacing-2xl);
padding-bottom: var(--spacing-xl);
}
.detail-header-image {
@ -389,6 +407,11 @@ a {
border-radius: var(--radius);
object-fit: cover;
box-shadow: 0 10px 30px rgba(0, 0, 0, .5);
transition: opacity 0.3s ease-in-out;
}
.detail-header-image.loading {
opacity: 0.3;
}
.detail-header-image.artist {
@ -411,12 +434,17 @@ a {
margin-top: 1rem;
}
.settings-list {
max-width: 800px;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
padding: var(--spacing-lg) 0;
border-bottom: 1px solid var(--border);
gap: var(--spacing-lg);
}
.setting-item .info {
@ -546,13 +574,13 @@ input:checked + .slider:before {
display: flex;
flex-direction: column;
align-items: center;
gap: .5rem;
gap: var(--spacing-sm);
}
.player-controls .buttons {
display: flex;
align-items: center;
gap: 1.5rem;
gap: var(--spacing-md);
}
.player-controls .buttons button {
@ -977,16 +1005,186 @@ input:checked + .slider:before {
border-left: 3px solid var(--muted-foreground);
}
.skeleton {
background: linear-gradient(
90deg,
var(--secondary) 0%,
var(--muted) 50%,
var(--secondary) 100%
);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
border-radius: var(--radius);
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton-track {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm);
margin-bottom: 2px;
}
.skeleton-track-number {
width: 24px;
height: 20px;
margin: 0 auto;
}
.skeleton-track-info {
display: flex;
align-items: center;
gap: var(--spacing-md);
min-width: 0;
}
.skeleton-track-cover {
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 4px;
}
.skeleton-track-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.skeleton-track-title {
height: 16px;
width: 60%;
max-width: 200px;
}
.skeleton-track-artist {
height: 14px;
width: 40%;
max-width: 150px;
}
.skeleton-track-duration {
width: 40px;
height: 14px;
}
.skeleton-card {
background-color: var(--card);
border-radius: var(--radius);
padding: var(--spacing-md);
}
.skeleton-card-image {
width: 100%;
aspect-ratio: 1/1;
margin-bottom: var(--spacing-md);
border-radius: calc(var(--radius) - 4px);
}
.skeleton-card.artist .skeleton-card-image {
border-radius: 50%;
}
.skeleton-card-title {
height: 18px;
width: 80%;
margin-bottom: var(--spacing-xs);
}
.skeleton-card-subtitle {
height: 14px;
width: 60%;
}
.skeleton-detail-header {
display: flex;
align-items: flex-end;
gap: var(--spacing-xl);
margin-bottom: var(--spacing-2xl);
padding-bottom: var(--spacing-xl);
}
.skeleton-detail-image {
width: 200px;
height: 200px;
flex-shrink: 0;
border-radius: var(--radius);
}
.skeleton-detail-image.artist {
border-radius: 50%;
}
.skeleton-detail-info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.skeleton-detail-type {
height: 16px;
width: 60px;
}
.skeleton-detail-title {
height: 48px;
width: 70%;
max-width: 400px;
}
.skeleton-detail-meta {
height: 16px;
width: 50%;
max-width: 300px;
}
.skeleton-container {
width: 100%;
}
.content-loaded {
animation: contentFadeIn 0.3s ease-in-out;
}
@keyframes contentFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (max-width: 1024px) {
.app-container {
grid-template-columns: 240px 1fr;
}
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--spacing-md);
}
.detail-header-info .title {
font-size: 3rem;
}
.main-content {
padding: var(--spacing-lg);
}
}
@media (max-width: 768px) {
@ -998,15 +1196,18 @@ input:checked + .slider:before {
"main"
"player";
}
.main-content {
padding: 1rem;
padding: var(--spacing-md);
grid-area: main;
}
.main-header {
grid-area: header;
padding: 1rem 1rem 0 1rem;
margin-bottom: 0;
padding: var(--spacing-md) var(--spacing-md) 0 var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.sidebar {
position: fixed;
top: 0;
@ -1015,68 +1216,153 @@ input:checked + .slider:before {
transform: translateX(-100%);
box-shadow: 0 0 20px rgba(0, 0, 0, .5);
}
.sidebar.is-open {
transform: translateX(0);
}
.hamburger-menu {
display: block;
}
#sidebar-overlay.is-visible {
display: block;
}
.search-bar {
max-width: none;
}
.content-section {
margin-bottom: var(--spacing-xl);
}
.section-title {
font-size: 1.5rem;
margin-bottom: var(--spacing-md);
}
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--spacing-md);
}
.detail-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-lg);
padding-bottom: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.detail-header-image {
width: 150px;
height: 150px;
}
.detail-header-info .title {
font-size: 2.5rem;
font-size: 2rem;
line-height: 1.2;
}
.skeleton-detail-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-lg);
}
.skeleton-detail-image {
width: 150px;
height: 150px;
}
.skeleton-detail-title {
height: 40px;
width: 90%;
}
.now-playing-bar {
grid-template-columns: 1fr;
grid-template-rows: auto auto;
gap: .75rem;
padding: 1rem;
gap: var(--spacing-md);
padding: var(--spacing-md);
height: auto;
place-items: center;
}
.now-playing-bar .track-info {
grid-column: 1;
grid-row: 1;
width: 100%;
justify-content: flex-start;
}
.track-info .details {
max-width: calc(100% - 72px);
.track-info .cover {
width: 48px;
height: 48px;
}
.track-info .details {
max-width: calc(100% - 64px);
}
.track-info .details .artist {
display: block;
}
.now-playing-bar .player-controls {
grid-column: 1;
grid-row: 2;
width: 100%;
flex-direction: column;
gap: .75rem;
gap: var(--spacing-sm);
}
.player-controls .progress-container {
display: flex;
max-width: none;
}
.now-playing-bar .volume-controls {
display: none;
}
.about-links {
flex-direction: column;
}
.github-link {
width: 100%;
justify-content: center;
}
.setting-item {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-md);
}
.setting-item .info {
width: 100%;
}
}
@media (max-width: 480px) {
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--spacing-sm);
}
.section-title {
font-size: 1.25rem;
}
.detail-header-info .title {
font-size: 1.75rem;
}
.search-tab {
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.9rem;
}
}

1
sw.js
View file

@ -1,3 +1,4 @@
//sw.js
const CACHE_NAME = 'monochrome-v1';
const urlsToCache = [
'/',