This commit is contained in:
Eduard Prigoana 2025-10-10 22:20:23 +03:00
parent 9158cb60e3
commit bfc7c6b685
12 changed files with 3376 additions and 556 deletions

View file

@ -4,138 +4,93 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Monochrome Music</title>
<meta name="theme-color" content="#000000" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Monochrome">
<meta name="description" content="A minimalist music streaming application">
<link rel="apple-touch-icon" href="https://prigoana.com/favicon.png">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="https://prigoana.com/favicon.png" type="image/png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{--background:#000;--foreground:#fafafa;--card:#111;--card-foreground:#fafafa;--primary:#fafafa;--primary-foreground:#111;--secondary:#27272a;--secondary-foreground:#fafafa;--muted:#27272a;--muted-foreground:#a1a1aa;--border:#27272a;--input:#27272a;--ring:#fafafa;--radius:.5rem;--highlight:#4ade80}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
body{background-color:var(--background);color:var(--foreground);font-family:'Inter',sans-serif;overflow:hidden}
img{max-width:100%;display:block}
a{color:inherit;text-decoration:none}
.app-container{display:grid;height:100vh;grid-template-columns:280px 1fr;grid-template-rows:1fr auto;grid-template-areas:"sidebar main" "player player"}
.sidebar{grid-area:sidebar;background-color:var(--background);border-right:1px solid var(--border);padding:1.5rem;display:flex;flex-direction:column;gap:2rem}
.main-content{grid-area:main;overflow-y:auto;padding:2rem}
.now-playing-bar{grid-area:player;background-color:#050505;border-top:1px solid var(--border);padding:.75rem 1.5rem;display:grid;grid-template-columns:1fr 2fr 1fr;align-items:center;gap:2rem}
.sidebar-logo{display:flex;align-items:center;gap:.75rem;font-size:1.1rem;font-weight:600;margin-bottom:1rem}
.sidebar-logo svg{width:24px;height:24px}
.sidebar-nav ul{list-style:none}
.sidebar-nav .nav-item a{display:flex;align-items:center;gap:.75rem;padding:.75rem;border-radius:var(--radius);color:var(--muted-foreground);text-decoration:none;font-weight:500;transition:all .2s ease-in-out;cursor:pointer}
.sidebar-nav .nav-item a:hover{background-color:var(--secondary);color:var(--foreground)}
.sidebar-nav .nav-item a.active{background-color:var(--primary);color:var(--primary-foreground)}
.sidebar-nav .nav-item a svg{width:20px;height:20px}
.page{display:none}
.page.active{display:block;animation:fadeIn .3s ease-in-out}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.main-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:2rem}
.search-bar{position:relative;width:100%;max-width:400px}
.search-bar svg{position:absolute;left:.75rem;top:50%;transform:translateY(-50%);color:var(--muted-foreground);width:20px;height:20px}
.search-bar input{width:100%;padding:.75rem .75rem .75rem 2.5rem;background-color:var(--input);border:1px solid var(--border);border-radius:var(--radius);color:var(--foreground);font-size:1rem}
.search-bar input:focus{outline:none;border-color:var(--ring)}
.content-section{margin-bottom:3rem}
.section-title{font-size:1.75rem;font-weight:700;margin-bottom:1.5rem}
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1.5rem}
.card{display:block;background-color:var(--card);border-radius:var(--radius);padding:1rem;transition:background-color .2s ease-in-out}
.card:hover{background-color:var(--secondary)}
.card-image{width:100%;aspect-ratio:1/1;background-color:var(--muted);border-radius:calc(var(--radius) - 4px);margin-bottom:1rem;object-fit:cover}
.card.artist .card-image{border-radius:50%}
.card-title{font-weight:600;margin-bottom:.25rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card-subtitle{font-size:.9rem;color:var(--muted-foreground);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.track-list{display:flex;flex-direction:column;gap:.25rem}
.track-list .track-list-header{display:grid;grid-template-columns:40px 1fr auto;align-items:center;gap:1rem;padding:.75rem;color:var(--muted-foreground);font-size:.9rem;border-bottom:1px solid var(--border);margin-bottom:.5rem}
.track-list .track-list-header .duration-header{justify-self:flex-end}
.track-item{display:grid;grid-template-columns:40px 1fr auto;align-items:center;gap:1rem;padding:.75rem;border-radius:var(--radius);cursor:pointer;transition:background-color .2s ease-in-out}
.track-item:hover{background-color:var(--secondary)}
.track-item .track-number{color:var(--muted-foreground);text-align:center;font-size:.9rem}
.track-item-info{display:flex;align-items:center;gap:1rem}
.track-item-cover{width:40px;height:40px;background-color:var(--muted);border-radius:4px;object-fit:cover}
.track-item-details .title{font-weight:500}
.track-item-details .artist{font-size:.9rem;color:var(--muted-foreground)}
.track-item-duration{font-size:.9rem;color:var(--muted-foreground);justify-self:flex-end}
.track-item.playing .track-number,.track-item.playing .track-item-details .title{color:var(--highlight)}
.detail-header{display:flex;align-items:flex-end;gap:2rem;margin-bottom:3rem}
.detail-header-image{width:200px;height:200px;flex-shrink:0;background-color:var(--muted);border-radius:var(--radius);object-fit:cover;box-shadow:0 10px 30px rgba(0,0,0,.5)}
.detail-header-image.artist{border-radius:50%}
.detail-header-info .type{font-weight:600;margin-bottom:.5rem}
.detail-header-info .title{font-size:4rem;font-weight:800;line-height:1.1}
.detail-header-info .meta{color:var(--muted-foreground);margin-top:1rem}
.setting-item{display:flex;justify-content:space-between;align-items:center;padding:1rem 0;border-bottom:1px solid var(--border)}
.setting-item .info{display:flex;flex-direction:column}
.setting-item .label{font-weight:500}
.setting-item .description{font-size:.9rem;color:var(--muted-foreground)}
.setting-item select { background-color: var(--input); color: var(--foreground); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.5rem; }
.toggle-switch{position:relative;display:inline-block;width:40px;height:24px}
.toggle-switch input{opacity:0;width:0;height:0}
.slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background-color:var(--secondary);transition:.4s;border-radius:24px}
.slider:before{position:absolute;content:"";height:16px;width:16px;left:4px;bottom:4px;background-color:var(--foreground);transition:.4s;border-radius:50%}
input:checked+.slider{background-color:var(--primary)}
input:checked+.slider:before{transform:translateX(16px);background-color:var(--primary-foreground)}
.now-playing-bar .track-info{display:flex;align-items:center;gap:1rem}
.track-info .cover{width:56px;height:56px;border-radius:4px;background-color:var(--muted);object-fit:cover}
.track-info .details .title{font-weight:500}
.track-info .details .artist{font-size:.8rem;color:var(--muted-foreground)}
.player-controls{display:flex;flex-direction:column;align-items:center;gap:.5rem}
.player-controls .buttons{display:flex;align-items:center;gap:1.5rem}
.player-controls .buttons button{background:0 0;border:none;color:var(--muted-foreground);cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%}
.player-controls .buttons button:hover{color:var(--foreground);background-color:var(--secondary)}
.player-controls .buttons .play-pause-btn{background-color:var(--primary);color:var(--primary-foreground);width:36px;height:36px;border-radius:50%;display:flex;justify-content:center;align-items:center}
.player-controls .buttons .play-pause-btn:hover{transform:scale(1.05);background-color:var(--primary);color:var(--primary-foreground)}
.player-controls .progress-container{width:100%;max-width:500px;display:flex;align-items:center;gap:.75rem;font-size:.8rem;color:var(--muted-foreground)}
.progress-bar{flex-grow:1;height:4px;background-color:var(--secondary);border-radius:2px;cursor:pointer}
.progress-bar .progress-fill{width:0;height:100%;background-color:var(--foreground);border-radius:2px;transition:width .1s linear}
.progress-bar:hover .progress-fill{background-color:var(--highlight)}
.volume-controls{display:flex;justify-content:flex-end;align-items:center;gap:.75rem}
.volume-controls button{background:0 0;border:none;color:var(--muted-foreground);cursor:pointer;transition:color .2s}
.volume-controls button:hover{color:var(--foreground)}
.volume-controls .volume-bar{width:100px;height:4px;background-color:var(--secondary);border-radius:2px;cursor:pointer}
.volume-controls .volume-bar .volume-fill{width:70%;height:100%;background-color:var(--foreground);border-radius:2px}
.volume-controls .volume-bar:hover .volume-fill{background-color:var(--highlight)}
#context-menu{display:none;position:absolute;background-color:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:.5rem;box-shadow:0 4px 12px rgba(0,0,0,.5);z-index:1000}
#context-menu ul{list-style:none;margin:0;padding:0}
#context-menu li{padding:.5rem .75rem;cursor:pointer;border-radius:4px}
#context-menu li:hover{background-color:var(--secondary)}
#queue-modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,.7);z-index:1000;display:flex;justify-content:center;align-items:center}
#queue-modal{background-color:var(--card);width:90%;max-width:500px;max-height:80vh;border-radius:var(--radius);display:flex;flex-direction:column}
#queue-modal-header{padding:1rem;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
#queue-modal-header h3{margin:0}
#queue-modal-header button{background:0 0;border:none;color:var(--muted-foreground);font-size:1.5rem;cursor:pointer}
#queue-list{overflow-y:auto;padding:.5rem}
.placeholder-text { padding: 1rem; color: var(--muted-foreground); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.placeholder-text.loading { animation: pulse 1.5s infinite ease-in-out; }
#api-instance-manager { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
#api-instance-list { list-style: none; margin-bottom: 1rem; }
#api-instance-list li { display: flex; align-items: center; gap: .75rem; padding: .75rem; background-color: var(--secondary); border-radius: var(--radius); margin-bottom: .5rem; }
#api-instance-list li .instance-url { flex-grow: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .9rem; }
#api-instance-list li .controls { display: flex; gap: .5rem; }
#api-instance-list li button { background: none; border: none; color: var(--muted-foreground); cursor: pointer; padding: 4px; display: flex; align-items: center; justify-content: center; border-radius: 4px; }
#api-instance-list li button:hover { color: var(--foreground); background-color: var(--muted); }
#api-instance-list li button:disabled { opacity: 0.3; cursor: not-allowed; }
#add-instance-form { display: flex; gap: .75rem; }
#add-instance-form input { flex-grow: 1; padding: .5rem .75rem; background-color: var(--input); border: 1px solid var(--border); border-radius: var(--radius); color: var(--foreground); }
#add-instance-form button { padding: .5rem 1rem; background-color: var(--primary); color: var(--primary-foreground); border: none; border-radius: var(--radius); cursor: pointer; font-weight: 500; }
#add-instance-form button:hover { opacity: 0.9; }
@media (max-width:1024px){.app-container{grid-template-columns:240px 1fr}.card-grid{grid-template-columns:repeat(auto-fill,minmax(160px,1fr))}.detail-header-info .title{font-size:3rem}}
@media (max-width:768px){.app-container{grid-template-columns:1fr;grid-template-rows:1fr auto;grid-template-areas:"main" "player"}.sidebar{display:none}.main-content{padding:1rem}.detail-header{flex-direction:column;align-items:flex-start}.detail-header-image{width:150px;height:150px}.detail-header-info .title{font-size:2.5rem}.now-playing-bar{grid-template-columns:1fr auto;padding:.75rem;gap:1rem}.now-playing-bar .player-controls{grid-column:2/3;grid-row:1/2;flex-direction:row}.player-controls .progress-container{display:none}.track-info .details{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}.track-info .details .artist{display:none}}
</style>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<audio id="audio-player"></audio>
<div id="context-menu"><ul><li data-action="add-to-queue">Add to Queue</li><li data-action="download">Download</li></ul></div>
<div id="queue-modal-overlay" style="display: none;"><div id="queue-modal"><div id="queue-modal-header"><h3>Queue</h3><button id="close-queue-btn">&times;</button></div><div id="queue-list"></div></div></div>
<div id="context-menu">
<ul>
<li data-action="add-to-queue">Add to Queue</li>
<li data-action="download">Download</li>
</ul>
</div>
<div id="queue-modal-overlay" style="display: none;">
<div id="queue-modal">
<div id="queue-modal-header">
<h3>Queue</h3>
<button id="close-queue-btn">&times;</button>
</div>
<div id="queue-list"></div>
</div>
</div>
<div id="sidebar-overlay"></div>
<div class="app-container">
<aside class="sidebar">
<div>
<div class="sidebar-logo"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg><span>Monochrome</span></div>
<nav class="sidebar-nav"><ul><li class="nav-item"><a href="#home"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg><span>Home</span></a></li><li class="nav-item"><a href="#settings"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 0 2l-.15.08a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l-.22-.38a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1 0 2l.15-.08a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path><circle cx="12" cy="12" r="3"></circle></svg><span>Settings</span></a></li></ul></nav>
<div class="sidebar-logo">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
<span>Monochrome</span>
</div>
<nav class="sidebar-nav">
<ul>
<li class="nav-item">
<a href="#home">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<span>Home</span>
</a>
</li>
<li class="nav-item">
<a href="#settings">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 0 2l-.15.08a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l-.22-.38a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1 0 2l.15-.08a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span>Settings</span>
</a>
</li>
</ul>
</nav>
</div>
</aside>
<main class="main-content">
<header class="main-header"><form class="search-bar" id="search-form"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg><input type="search" id="search-input" placeholder="Search for tracks, artists, albums..."></form></header>
<header class="main-header">
<button class="hamburger-menu" id="hamburger-btn" title="Open navigation">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<form class="search-bar" id="search-form">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="search" id="search-input" placeholder="Search for tracks, artists, albums...">
</form>
</header>
<div id="page-home" class="page">
<section class="content-section">
<h2 class="section-title">Recent Albums</h2>
@ -146,38 +101,108 @@
<div class="card-grid" id="home-recent-artists"></div>
</section>
</div>
<div id="page-search" class="page"><h2 class="section-title" id="search-results-title">Search Results</h2><section class="content-section"><h3>Tracks</h3><div class="track-list" id="search-tracks-container"><div class="placeholder-text">Search to see track results.</div></div></section><section class="content-section"><h3>Artists</h3><div class="card-grid" id="search-artists-container"><div class="placeholder-text">Search to see artist results.</div></div></section><section class="content-section"><h3>Albums</h3><div class="card-grid" id="search-albums-container"><div class="placeholder-text">Search to see album results.</div></div></section></div>
<div id="page-album" class="page"><header class="detail-header"><img id="album-detail-image" src="" alt="Album Art" 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></header><div class="track-list" id="album-detail-tracklist"></div></div>
<div id="page-artist" class="page"><header class="detail-header"><img id="artist-detail-image" src="" alt="Artist" class="detail-header-image artist"><div class="detail-header-info"><div class="type">Artist</div><h1 class="title" id="artist-detail-name"></h1><div class="meta" id="artist-detail-meta"></div></div></header><section class="content-section"><h2 class="section-title">Popular Tracks</h2><div class="track-list" id="artist-detail-tracks"></div></section><section class="content-section"><h2 class="section-title">Albums</h2><div class="card-grid" id="artist-detail-albums"></div></section></div>
<div id="page-search" class="page">
<h2 class="section-title" id="search-results-title">Search Results</h2>
<div class="search-tabs">
<button class="search-tab active" data-tab="tracks">Tracks</button>
<button class="search-tab" data-tab="albums">Albums</button>
<button class="search-tab" data-tab="artists">Artists</button>
</div>
<div class="search-tab-content active" id="search-tab-tracks">
<div class="track-list" id="search-tracks-container"></div>
</div>
<div class="search-tab-content" id="search-tab-albums">
<div class="card-grid" id="search-albums-container"></div>
</div>
<div class="search-tab-content" id="search-tab-artists">
<div class="card-grid" id="search-artists-container"></div>
</div>
</div>
<div id="page-album" class="page">
<header class="detail-header">
<img id="album-detail-image" src="" alt="Album Art" 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>
</header>
<div class="track-list" id="album-detail-tracklist"></div>
</div>
<div id="page-artist" class="page">
<header class="detail-header">
<img id="artist-detail-image" src="" alt="Artist" class="detail-header-image artist">
<div class="detail-header-info">
<div class="type">Artist</div>
<h1 class="title" id="artist-detail-name"></h1>
<div class="meta" id="artist-detail-meta"></div>
</div>
</header>
<section class="content-section">
<h2 class="section-title">Popular Tracks</h2>
<div class="track-list" id="artist-detail-tracks"></div>
</section>
<section class="content-section">
<h2 class="section-title">Albums</h2>
<div class="card-grid" id="artist-detail-albums"></div>
</section>
</div>
<div id="page-settings" class="page">
<h2 class="section-title">Settings</h2>
<div class="settings-list">
<div class="setting-item"><div class="info"><span class="label">Audio Quality</span><span class="description">Set to LOSSLESS by default.</span></div></div>
<div class="setting-item"><div class="info"><span class="label">Crossfade</span><span class="description">Allow songs to fade into each other.</span></div><label class="toggle-switch"><input type="checkbox" checked><span class="slider"></span></label></div>
<div class="setting-item"><div class="info"><span class="label">Gapless Playback</span><span class="description">Play audio without interruption between tracks.</span></div><label class="toggle-switch"><input type="checkbox" checked><span class="slider"></span></label></div>
<div class="setting-item"><div class="info"><span class="label">Normalize Volume</span><span class="description">Set the same volume level for all tracks.</span></div><label class="toggle-switch"><input type="checkbox"><span class="slider"></span></label></div>
<div class="setting-item">
<div class="info">
<span class="label">API Cache Expiration</span>
<span class="description">How long to keep API data before re-fetching.</span>
<span class="label">Audio Quality</span>
<span class="description">Set to LOSSLESS by default.</span>
</div>
<select id="cache-duration-select">
<option value="infinite">Infinite</option>
<option value="1">1 Day</option>
<option value="7">7 Days</option>
<option value="30">30 Days</option>
</select>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Clear Caches Now</span>
<span class="description">Removes all cached images, audio, and API data.</span>
<span class="label">Crossfade</span>
<span class="description">Allow songs to fade into each other.</span>
</div>
<button id="clear-cache-btn" style="padding: .5rem 1rem; background-color: var(--secondary); color: var(--secondary-foreground); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; font-weight: 500;">Clear Caches</button>
<label class="toggle-switch">
<input type="checkbox" checked>
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Gapless Playback</span>
<span class="description">Play audio without interruption between tracks.</span>
</div>
<label class="toggle-switch">
<input type="checkbox" checked>
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Normalize Volume</span>
<span class="description">Set the same volume level for all tracks.</span>
</div>
<label class="toggle-switch">
<input type="checkbox">
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Cache</span>
<span class="description" id="cache-info">Stores API responses to reduce requests.</span>
</div>
<button id="clear-cache-btn" class="btn-secondary">Clear Cache</button>
</div>
<div id="api-instance-manager">
<div class="setting-item" style="padding-bottom: 1rem; border: none;">
<div class="info"><span class="label">API Instances</span><span class="description">Manage and prioritize API instances. The app will try them in order if one fails.</span></div>
<div class="info">
<span class="label">API Instances</span>
<span class="description">Manage and prioritize API instances. The app will try them in order if one fails.</span>
</div>
</div>
<ul id="api-instance-list"></ul>
<form id="add-instance-form">
@ -185,287 +210,129 @@
<button type="submit">Add Instance</button>
</form>
</div>
<div id="about-section">
<div class="setting-item" style="padding-bottom: 1rem; border: none; margin-top: 2rem;">
<div class="info">
<span class="label">About Monochrome</span>
<span class="description">A minimalist, open-source music streaming application</span>
</div>
</div>
<div class="about-content">
<p class="about-description">
Monochrome is a lightweight, privacy-focused music streaming client designed for high-fidelity audio playback.
Built with modern web technologies, it provides a clean, distraction-free listening experience.
</p>
<div class="about-features">
<h4>Features</h4>
<ul>
<li>High-quality lossless audio streaming</li>
<li>Intelligent API caching for improved performance</li>
<li>Offline-capable Progressive Web App (PWA)</li>
<li>Media Session API integration for system controls</li>
<li>Queue management with shuffle and repeat modes</li>
<li>Track downloads with automatic metadata embedding</li>
<li>Multiple API instance support with failover</li>
<li>Dark, minimalist interface optimized for focus</li>
</ul>
</div>
<div class="about-tech">
<h4>Technology Stack</h4>
<p>Vanilla JavaScript • ES6 Modules • IndexedDB • Service Workers • Media Session API</p>
</div>
<div class="about-links">
<a href="https://github.com/eduardprigoana/monochrome" target="_blank" rel="noopener noreferrer" class="github-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>View on GitHub</span>
</a>
<a href="https://github.com/eduardprigoana/monochrome/issues" target="_blank" rel="noopener noreferrer" class="github-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span>Report Issue</span>
</a>
</div>
<div class="about-footer">
<p class="version">Version 1.0.0</p>
<p class="disclaimer">This is an independent client and is not affiliated with or endorsed by TIDAL or any music streaming service.</p>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="now-playing-bar">
<div class="track-info"><img src="https://picsum.photos/seed/placeholder/112" alt="Current Track Cover" class="cover"><div class="details"><div class="title">Select a song</div><div class="artist"></div></div></div>
<div class="track-info">
<img src="https://picsum.photos/seed/placeholder/112" alt="Current Track Cover" class="cover">
<div class="details">
<div class="title">Select a song</div>
<div class="artist"></div>
</div>
</div>
<div class="player-controls">
<div class="buttons">
<button id="shuffle-btn" title="Shuffle"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"></polyline><line x1="4" y1="20" x2="21" y2="3"></line><polyline points="16 16 21 16 21 21"></polyline><line x1="15" y1="15" x2="21" y2="21"></line><line x1="4" y1="4" x2="9" y2="9"></line></svg></button>
<button id="prev-btn" title="Previous"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="19 20 9 12 19 4 19 20"></polygon><line x1="5" y1="19" x2="5" y2="5"></line></svg></button>
<button id="shuffle-btn" title="Shuffle">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 3 21 3 21 8"></polyline>
<line x1="4" y1="20" x2="21" y2="3"></line>
<polyline points="16 16 21 16 21 21"></polyline>
<line x1="15" y1="15" x2="21" y2="21"></line>
<line x1="4" y1="4" x2="9" y2="9"></line>
</svg>
</button>
<button id="prev-btn" title="Previous">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="19 20 9 12 19 4 19 20"></polygon>
<line x1="5" y1="19" x2="5" y2="5"></line>
</svg>
</button>
<button class="play-pause-btn" title="Play"></button>
<button id="next-btn" title="Next"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"></polygon><line x1="19" y1="5" x2="19" y2="19"></line></svg></button>
<button id="repeat-btn" title="Repeat"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg></button>
<button id="next-btn" title="Next">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="5 4 15 12 5 20 5 4"></polygon>
<line x1="19" y1="5" x2="19" y2="19"></line>
</svg>
</button>
<button id="repeat-btn" title="Repeat">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"></polyline>
<path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
<polyline points="7 23 3 19 7 15"></polyline>
<path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
</svg>
</button>
</div>
<div class="progress-container">
<span id="current-time">0:00</span>
<div id="progress-bar" class="progress-bar">
<div id="progress-fill" class="progress-fill"></div>
</div>
<span id="total-duration">0:00</span>
</div>
<div class="progress-container"><span id="current-time">0:00</span><div id="progress-bar" class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div><span id="total-duration">0:00</span></div>
</div>
<div class="volume-controls">
<button id="queue-btn" title="Queue"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg></button>
<button id="volume-btn" title="Volume"></button>
<div id="volume-bar" class="volume-bar"><div id="volume-fill" class="volume-fill"></div></div>
<button id="queue-btn" title="Queue">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</button>
<button id="volume-btn" title="Mute"></button>
<div id="volume-bar" class="volume-bar">
<div id="volume-fill" class="volume-fill"></div>
</div>
</div>
</footer>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const cacheSettings = {
STORAGE_KEY: 'monochrome-cache-duration',
getDuration() { return localStorage.getItem(this.STORAGE_KEY) || 'infinite'; },
saveDuration(duration) { localStorage.setItem(this.STORAGE_KEY, duration); }
};
const postMessageToSW = (message) => {
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage(message);
}
};
const registerServiceWorker = () => {
if (!('serviceWorker' in navigator)) {
console.warn('Service Worker not supported. Caching will be disabled.');
return Promise.resolve();
}
return new Promise((resolve, reject) => {
navigator.serviceWorker.register('./sw.js')
.then(registration => {
console.log('Service Worker registered:', registration.scope);
navigator.serviceWorker.addEventListener('message', event => {
if (event.data?.status === 'caches_cleared') alert('All caches have been cleared successfully!');
if (event.data?.status === 'cache_clear_failed') alert('Could not clear caches. See console for details.');
});
resolve(navigator.serviceWorker.ready);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
reject(error);
});
});
};
const cacheDurationSelect = document.getElementById('cache-duration-select');
cacheDurationSelect.value = cacheSettings.getDuration();
cacheDurationSelect.addEventListener('change', (e) => {
const newDuration = e.target.value;
cacheSettings.saveDuration(newDuration);
postMessageToSW({ action: 'update_setting', key: 'cacheDuration', value: newDuration });
alert(`Cache expiration set to ${newDuration === 'infinite' ? 'Infinite' : `${newDuration} day(s)`}.`);
});
const clearCacheBtn = document.getElementById('clear-cache-btn');
clearCacheBtn.addEventListener('click', () => {
if (confirm('Are you sure you want to clear all cached audio, images, and API data? This cannot be undone.')) {
postMessageToSW({ action: 'clear_caches' });
}
});
const apiSettings = {
STORAGE_KEY: 'monochrome-api-instances',
defaultInstances: ['https://triton.squid.wtf', 'https://kraken.squid.wtf', 'https://zeus.squid.wtf', 'https://aether.squid.wtf', 'https://tidal.401658.xyz'],
getInstances() { try { const s = localStorage.getItem(this.STORAGE_KEY); return s ? JSON.parse(s) : [...this.defaultInstances]; } catch (e) { return [...this.defaultInstances]; } },
saveInstances(instances) { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(instances)); }
};
const recentActivityManager = {
STORAGE_KEY: 'monochrome-recent-activity',
LIMIT: 10,
_get() {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
return data ? JSON.parse(data) : { artists: [], albums: [] };
} catch (e) {
return { artists: [], albums: [] };
}
},
_save(data) {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
},
getRecents() {
return this._get();
},
addArtist(artist) {
const data = this._get();
data.artists = data.artists.filter(a => a.id !== artist.id);
data.artists.unshift(artist);
data.artists = data.artists.slice(0, this.LIMIT);
this._save(data);
},
addAlbum(album) {
const data = this._get();
data.albums = data.albums.filter(a => a.id !== album.id);
data.albums.unshift(album);
data.albums = data.albums.slice(0, this.LIMIT);
this._save(data);
}
};
class LosslessAPI {
constructor(settings) {
this.settings = settings;
this.RATE_LIMIT_ERROR_MESSAGE = 'Too Many Requests. Please wait a moment and try again.';
}
async fetch(relativePath) {
const instances = this.settings.getInstances();
if (instances.length === 0) {
throw new Error("No API instances configured.");
}
const errors = [];
for (const baseUrl of instances) {
const url = baseUrl.endsWith('/') ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`;
try {
const response = await fetch(url);
this.ensureNotRateLimited(response);
if (response.ok) {
return response;
}
errors.push(`'${baseUrl}': ${response.status} ${response.statusText}`);
} catch (error) {
if (error.message === this.RATE_LIMIT_ERROR_MESSAGE) {
throw error;
}
errors.push(`'${baseUrl}': ${error.message}`);
}
}
throw new Error(`All API instances failed. Errors: [${errors.join(', ')}]`);
}
ensureNotRateLimited(response) { if (response.status === 429) throw new Error(this.RATE_LIMIT_ERROR_MESSAGE); }
findSearchSection(source, key, visited) { if (!source || typeof source !== 'object') return; if (Array.isArray(source)) { for (const e of source) { const f = this.findSearchSection(e, key, visited); if (f) return f; } return; } if (visited.has(source)) return; visited.add(source); if ('items' in source && Array.isArray(source.items)) return source; if (key in source) { const f = this.findSearchSection(source[key], key, visited); if (f) return f; } for (const v of Object.values(source)) { const f = this.findSearchSection(v, key, visited); if (f) return f; } }
buildSearchResponse(section) { const items = section?.items ?? []; return { items, limit: section?.limit ?? items.length, offset: section?.offset ?? 0, totalNumberOfItems: section?.totalNumberOfItems ?? items.length }; }
normalizeSearchResponse(data, key) { const section = this.findSearchSection(data, key, new Set()); return this.buildSearchResponse(section); }
prepareTrack(track) { if (track && !track.artist && track.artists?.[0]) track.artist = track.artists[0]; return track; }
prepareAlbum(album) { if (album && !album.artist && album.artists?.[0]) album.artist = album.artists[0]; return album; }
prepareArtist(artist) { if (artist && !artist.type && artist.artistTypes?.[0]) artist.type = artist.artistTypes[0]; return artist; }
async searchTracks(query) { const r = await this.fetch(`/search/?s=${encodeURIComponent(query)}`); const d = await r.json(); const n = this.normalizeSearchResponse(d, 'tracks'); return { ...n, items: n.items.map(t => this.prepareTrack(t)) }; }
async getAlbum(id) { const r = await this.fetch(`/album/?id=${id}`); const d = await r.json(); const e = Array.isArray(d) ? d : [d]; let a, t; for (const i of e) { if (i && typeof i === 'object') { if (!a && 'numberOfTracks' in i) a = this.prepareAlbum(i); if (!t && 'items' in i) t = i; } } if (!a) throw new Error('Album not found'); const ts = (t?.items || []).map(i => this.prepareTrack(i.item || i)); return { album: a, tracks: ts }; }
async getArtist(id) { const [pr, cr] = await Promise.all([this.fetch(`/artist/?id=${id}`), this.fetch(`/artist/?f=${id}`)]); const pd = await pr.json(); const artist = this.prepareArtist(Array.isArray(pd) ? pd[0] : pd); if (!artist) throw new Error('Primary artist details not found.'); const cd = await cr.json(); const entries = Array.isArray(cd) ? cd : [cd]; const am = new Map(), tm = new Map(); const isTrack = v => v?.id && v.duration && v.album; const isAlbum = v => v?.id && v.cover && 'numberOfTracks' in v; const scan = (v, visited = new Set()) => { if (!v || typeof v !== 'object' || visited.has(v)) return; visited.add(v); if (Array.isArray(v)) { v.forEach(i => scan(i, visited)); return; } const item = v.item || v; if (isAlbum(item)) am.set(item.id, this.prepareAlbum(item)); if (isTrack(item)) tm.set(item.id, this.prepareTrack(item)); Object.values(v).forEach(i => scan(i, visited)); }; entries.forEach(e => scan(e)); const albums = Array.from(am.values()).sort((a,b) => new Date(b.releaseDate) - new Date(a.releaseDate)); const tracks = Array.from(tm.values()).sort((a,b) => (b.popularity ?? 0) - (a.popularity ?? 0)).slice(0, 10); return { ...artist, albums, tracks }; }
parseTrackLookup(d) { const e = Array.isArray(d) ? d : [d]; let t, i, o; for(const item of e) { if(item && typeof item === 'object') { if (!t && 'duration' in item) t=item; if (!i && 'manifest' in item) i=item; if (!o && 'OriginalTrackUrl' in item) o=item.OriginalTrackUrl; }} if(!t || !i) throw new Error('Malformed'); return {track: t, info: i, originalTrackUrl: o}; }
async getTrack(id, q='LOSSLESS') { const r = await this.fetch(`/track/?id=${id}&quality=${q}`); return this.parseTrackLookup(await r.json()); }
extractStreamUrlFromManifest(m) { try { const p = JSON.parse(atob(m)); if (p?.urls?.[0]) return p.urls[0]; } catch (e) {} return null; }
async getStreamUrl(id, q='LOSSLESS') { const l = await this.getTrack(id, q); if (l.originalTrackUrl) return l.originalTrackUrl; const u = this.extractStreamUrlFromManifest(l.info.manifest); if (u) return u; throw new Error('Could not resolve stream URL'); }
async downloadTrack(id, q='LOSSLESS', filename) { try { const u = await this.getStreamUrl(id, q); const r = await fetch(u, {cache:'no-store'}); if (!r.ok) throw new Error(`Fetch failed: ${r.status}`); const b = await r.blob(); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(a.href); } catch (e) { console.error("Download failed:", e); alert("Download failed."); } }
getCoverUrl(id, s='640') { return id ? `https://resources.tidal.com/images/${id.replace(/-/g,'/')}/${s}x${s}.jpg` : `https://picsum.photos/seed/${Math.random()}/${s}`; }
getArtistPictureUrl(id, s='750') { return id ? `https://resources.tidal.com/images/${id.replace(/-/g,'/')}/${s}x${s}.jpg` : `https://picsum.photos/seed/${Math.random()}/${s}`; }
}
const api = new LosslessAPI(apiSettings);
const QUALITY = 'LOSSLESS';
const trackDataStore = new WeakMap();
let queue = []; let currentQueueIndex = -1; let contextTrack = null;
const audioPlayer = document.getElementById('audio-player');
const mainContent = document.querySelector('.main-content');
const playPauseBtn = document.querySelector('.play-pause-btn');
const nextBtn = document.getElementById('next-btn');
const prevBtn = document.getElementById('prev-btn');
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
const currentTimeEl = document.getElementById('current-time');
const totalDurationEl = document.getElementById('total-duration');
const volumeBar = document.getElementById('volume-bar');
const volumeFill = document.getElementById('volume-fill');
const volumeBtn = document.getElementById('volume-btn');
const nowPlayingCover = document.querySelector('.now-playing-bar .cover');
const nowPlayingTitle = document.querySelector('.now-playing-bar .title');
const nowPlayingArtist = document.querySelector('.now-playing-bar .artist');
const contextMenu = document.getElementById('context-menu');
const queueBtn = document.getElementById('queue-btn');
const queueModalOverlay = document.getElementById('queue-modal-overlay');
const closeQueueBtn = document.getElementById('close-queue-btn');
const queueList = document.getElementById('queue-list');
const searchForm = document.getElementById('search-form');
const searchInput = document.getElementById('search-input');
const svgPlay = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>';
const svgPause = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>';
const svgVolume = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg>';
const svgMute = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><line x1="23" y1="9" x2="17" y2="15"></line><line x1="17" y1="9" x2="23" y2="15"></line></svg>';
const formatTime = seconds => { if (isNaN(seconds)) return '0:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${String(s).padStart(2, '0')}`; };
const updateMediaSession = (track) => { if (!('mediaSession' in navigator)) return; navigator.mediaSession.metadata = new MediaMetadata({ title: track.title, artist: track.artist?.name, album: track.album?.title, artwork: [ { src: api.getCoverUrl(track.album?.cover, '96'), sizes: '96x96', type: 'image/jpeg' }, { src: api.getCoverUrl(track.album?.cover, '512'), sizes: '512x512', type: 'image/jpeg' }] }); };
const playTrackFromQueue = async () => { if (currentQueueIndex < 0 || currentQueueIndex >= queue.length) return; const track = queue[currentQueueIndex]; nowPlayingCover.src = api.getCoverUrl(track.album?.cover, '80'); nowPlayingTitle.textContent = track.title; nowPlayingArtist.textContent = track.artist?.name; updatePlayingTrackIndicator(); renderQueue(); updateMediaSession(track); try { const streamUrl = await api.getStreamUrl(track.id, QUALITY); audioPlayer.src = streamUrl; audioPlayer.play(); } catch (error) { console.error(`Could not get track URL for: ${track.title}`, error); audioPlayer.pause(); audioPlayer.src = ''; nowPlayingTitle.textContent = `Error: ${track.title}`; nowPlayingArtist.textContent = 'Could not load track.'; playPauseBtn.innerHTML = svgPlay; if ('mediaSession' in navigator) navigator.mediaSession.metadata = null; } };
const playNext = () => { if (currentQueueIndex < queue.length - 1) { currentQueueIndex++; playTrackFromQueue(); } };
const playPrev = () => { if (currentQueueIndex > 0) { currentQueueIndex--; playTrackFromQueue(); } };
const updatePlayingTrackIndicator = () => { const current = queue[currentQueueIndex]; document.querySelectorAll('.track-item').forEach(item => { item.classList.toggle('playing', current && item.dataset.trackId == current.id); }); };
const renderQueue = () => { if (!queueModalOverlay.style.display || queueModalOverlay.style.display === "none") return; queueList.innerHTML = queue.length > 0 ? queue.map((track, index) => { const isPlaying = index === currentQueueIndex; return ` <div class="track-item ${isPlaying ? 'playing' : ''}" data-queue-index="${index}"> <div class="track-number">${index + 1}</div> <div class="track-item-info"> <img src="${api.getCoverUrl(track.album?.cover, '40')}" class="track-item-cover"> <div class="track-item-details"> <div class="title">${track.title}</div> <div class="artist">${track.artist?.name}</div> </div> </div> <div class="track-item-duration">${formatTime(track.duration)}</div> </div>`; }).join('') : '<div class="placeholder-text">Queue is empty.</div>'; };
const createTrackItemElement = (track, index, showCover = false) => { const trackItem = document.createElement('div'); trackItem.className = 'track-item'; trackItem.dataset.trackId = track.id; 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 trackNumberHTML = `<div class="track-number" style="font-size: 1.1em; display: flex; align-items: center; justify-content: center;">${showCover ? playIconSmall : index + 1}</div>`; trackItem.innerHTML = `${trackNumberHTML}<div class="track-item-info">${showCover ? `<img src="${api.getCoverUrl(track.album?.cover, '80')}" alt="Track Cover" class="track-item-cover">` : ''}<div class="track-item-details"><div class="title">${track.title}</div><div class="artist">${track.artist?.name ?? 'Unknown Artist'}</div></div></div><div class="track-item-duration">${formatTime(track.duration)}</div>`; trackDataStore.set(trackItem, track); return trackItem; };
const createAlbumCardHTML = (album) => ` <a href="#album/${album.id}" class="card"> <img src="${api.getCoverUrl(album.cover)}" alt="${album.title}" class="card-image"> <h3 class="card-title">${album.title}</h3> <p class="card-subtitle">Album • ${album.artist?.name ?? ''}</p> </a>`;
const createArtistCardHTML = (artist) => ` <a href="#artist/${artist.id}" class="card artist"> <img src="${api.getArtistPictureUrl(artist.picture, '750')}" alt="${artist.name}" class="card-image"> <h3 class="card-title">${artist.name}</h3> <p class="card-subtitle">Artist</p> </a>`;
const renderApiSettings = () => { const container = document.getElementById('api-instance-list'); const instances = apiSettings.getInstances(); const defaultInstancesSet = new Set(apiSettings.defaultInstances); container.innerHTML = ''; instances.forEach((instanceUrl, index) => { const isDefault = defaultInstancesSet.has(instanceUrl); const li = document.createElement('li'); li.dataset.index = index; li.innerHTML = `<span class="instance-url">${instanceUrl}</span><div class="controls"><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>${!isDefault ? '<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>`; container.appendChild(li); }); };
const showPage = (pageId) => { document.querySelectorAll('.page').forEach(page => page.classList.toggle('active', page.id === `page-${pageId}`)); document.querySelectorAll('.sidebar-nav a').forEach(link => link.classList.toggle('active', link.hash === `#${pageId}`)); mainContent.scrollTop = 0; updatePlayingTrackIndicator(); if(pageId === 'settings') renderApiSettings(); };
const renderHomePage = () => {
showPage('home');
const recentAlbumsContainer = document.getElementById('home-recent-albums');
const recentArtistsContainer = document.getElementById('home-recent-artists');
const recents = recentActivityManager.getRecents();
if (recents.albums.length > 0) {
recentAlbumsContainer.innerHTML = recents.albums.map(createAlbumCardHTML).join('');
} else {
recentAlbumsContainer.innerHTML = '<div class="placeholder-text">You haven\'t viewed any albums yet.</div>';
}
if (recents.artists.length > 0) {
recentArtistsContainer.innerHTML = recents.artists.map(createArtistCardHTML).join('');
} else {
recentArtistsContainer.innerHTML = '<div class="placeholder-text">You haven\'t viewed any artists yet.</div>';
}
};
const renderSearchPage = async (query) => { showPage('search'); document.getElementById('search-results-title').textContent = `Search Results for "${query}"`; const tracksContainer = document.getElementById('search-tracks-container'); const artistsContainer = document.getElementById('search-artists-container'); const albumsContainer = document.getElementById('search-albums-container'); tracksContainer.innerHTML = artistsContainer.innerHTML = albumsContainer.innerHTML = '<div class="placeholder-text loading">Searching...</div>'; try { const tracksResult = await api.searchTracks(query); const uniqueArtists = [...new Map(tracksResult.items.map(track => [track.artist.id, track.artist])).values()]; const uniqueAlbums = [...new Map(tracksResult.items.map(track => [track.album.id, track.album])).values()]; tracksContainer.innerHTML = ''; if(tracksResult.items.length) { tracksResult.items.forEach((track, i) => tracksContainer.appendChild(createTrackItemElement(track, i))); } else { tracksContainer.innerHTML = '<div class="placeholder-text">No tracks found.</div>'; } artistsContainer.innerHTML = uniqueArtists.length ? uniqueArtists.map(createArtistCardHTML).join('') : '<div class="placeholder-text">No artists found.</div>'; albumsContainer.innerHTML = uniqueAlbums.length ? uniqueAlbums.map(createAlbumCardHTML).join('') : '<div class="placeholder-text">No albums found.</div>'; } catch (error) { console.error("Search failed:", error); const msg = `<div class="placeholder-text">Error during search. ${error.message}</div>`; tracksContainer.innerHTML = artistsContainer.innerHTML = albumsContainer.innerHTML = msg; }};
const renderAlbumPage = async (albumId) => { showPage('album'); const tracklistContainer = document.getElementById('album-detail-tracklist'); tracklistContainer.innerHTML = '<div class="placeholder-text loading">Loading...</div>'; try { const { album, tracks } = await api.getAlbum(albumId); document.getElementById('album-detail-image').src = api.getCoverUrl(album.cover); document.getElementById('album-detail-title').textContent = album.title; document.getElementById('album-detail-meta').innerHTML = `By <a href="#artist/${album.artist.id}">${album.artist.name}</a> • ${new Date(album.releaseDate).getFullYear()}`; 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>`; tracks.sort((a,b) => a.trackNumber - b.trackNumber).forEach((track, i) => { tracklistContainer.appendChild(createTrackItemElement(track, i)); }); recentActivityManager.addAlbum(album); } catch (error) { console.error("Failed to load album:", error); tracklistContainer.innerHTML = `<div class="placeholder-text">Could not load album details. ${error.message}</div>`; }};
const renderArtistPage = async (artistId) => { showPage('artist'); const popularTracksContainer = document.getElementById('artist-detail-tracks'); const albumsContainer = document.getElementById('artist-detail-albums'); popularTracksContainer.innerHTML = albumsContainer.innerHTML = '<div class="placeholder-text loading">Loading...</div>'; try { const artist = await api.getArtist(artistId); document.getElementById('artist-detail-image').src = api.getArtistPictureUrl(artist.picture, '750'); document.getElementById('artist-detail-name').textContent = artist.name; document.getElementById('artist-detail-meta').textContent = `${artist.popularity} popularity`; popularTracksContainer.innerHTML = ''; artist.tracks.forEach((track, i) => { popularTracksContainer.appendChild(createTrackItemElement(track, i, true)); }); albumsContainer.innerHTML = artist.albums.map(createAlbumCardHTML).join(''); recentActivityManager.addArtist(artist); } catch (error) { console.error("Failed to load artist:", error); popularTracksContainer.innerHTML = albumsContainer.innerHTML = `<div class="placeholder-text">Could not load artist details. ${error.message}</div>`; }};
const router = () => { const path = window.location.hash.substring(1) || "home"; const [page, param] = path.split('/'); switch (page) { case 'search': renderSearchPage(decodeURIComponent(param)); break; case 'album': renderAlbumPage(param); break; case 'artist': renderArtistPage(param); break; case 'home': renderHomePage(); break; default: showPage(page); break; } };
mainContent.addEventListener('click', e => { const trackItem = e.target.closest('.track-item'); if (trackItem) { const allTrackElements = Array.from(trackItem.parentElement.querySelectorAll('.track-item')); queue = allTrackElements.map(el => trackDataStore.get(el)).filter(Boolean); const clickedTrackObject = trackDataStore.get(trackItem); currentQueueIndex = queue.findIndex(t => t && t.id === clickedTrackObject.id); if (currentQueueIndex === -1) { currentQueueIndex = 0; queue = [clickedTrackObject]; } playTrackFromQueue(); } });
mainContent.addEventListener('contextmenu', e => { const trackItem = e.target.closest('.track-item'); if (trackItem) { e.preventDefault(); contextTrack = trackDataStore.get(trackItem); if (contextTrack) { contextMenu.style.top = `${e.pageY}px`; contextMenu.style.left = `${e.pageX}px`; contextMenu.style.display = 'block'; } } });
document.addEventListener('click', () => contextMenu.style.display = 'none');
contextMenu.addEventListener('click', async e => { e.stopPropagation(); const action = e.target.dataset.action; if (action === 'add-to-queue' && contextTrack) { queue.push(contextTrack); renderQueue(); } else if (action === 'download' && contextTrack) { const filename = `${contextTrack.artist.name} - ${contextTrack.title}.m4a`; alert(`Starting download for: ${filename}`); await api.downloadTrack(contextTrack.id, QUALITY, filename); } contextMenu.style.display = 'none'; });
searchForm.addEventListener('submit', e => { e.preventDefault(); const query = searchInput.value.trim(); if (query) { window.location.hash = `#search/${encodeURIComponent(query)}`; } });
audioPlayer.addEventListener('play', () => playPauseBtn.innerHTML = svgPause); audioPlayer.addEventListener('pause', () => playPauseBtn.innerHTML = svgPlay); audioPlayer.addEventListener('ended', playNext); audioPlayer.addEventListener('timeupdate', () => { const { currentTime, duration } = audioPlayer; if (duration) { progressFill.style.width = `${(currentTime / duration) * 100}%`; currentTimeEl.textContent = formatTime(currentTime); } }); audioPlayer.addEventListener('loadedmetadata', () => totalDurationEl.textContent = formatTime(audioPlayer.duration));
const handlePlayPause = () => { if (!audioPlayer.src) return; audioPlayer.paused ? audioPlayer.play() : audioPlayer.pause(); };
playPauseBtn.addEventListener('click', handlePlayPause); nextBtn.addEventListener('click', playNext); prevBtn.addEventListener('click', playPrev);
const seek = (bar, fill, event, setter) => { const rect = bar.getBoundingClientRect(); const x = event.clientX - rect.left; const percentage = Math.max(0, Math.min(1, x / rect.width)); setter(percentage); fill.style.width = `${percentage * 100}%`; };
progressBar.addEventListener('click', e => seek(progressBar, progressFill, e, p => { if (!isNaN(audioPlayer.duration)) audioPlayer.currentTime = p * audioPlayer.duration; }));
volumeBar.addEventListener('click', e => seek(volumeBar, volumeFill, e, p => audioPlayer.volume = p));
volumeBtn.addEventListener('click', () => audioPlayer.muted = !audioPlayer.muted);
const updateVolumeUI = () => { const { volume, muted } = audioPlayer; volumeBtn.innerHTML = (muted || volume === 0) ? svgMute : svgVolume; volumeFill.style.width = `${muted ? 0 : volume * 100}%`; };
audioPlayer.addEventListener('volumechange', updateVolumeUI);
queueBtn.addEventListener('click', () => { renderQueue(); queueModalOverlay.style.display = 'flex'; });
closeQueueBtn.addEventListener('click', () => queueModalOverlay.style.display = 'none');
queueModalOverlay.addEventListener('click', e => { if (e.target === queueModalOverlay) queueModalOverlay.style.display = 'none'; });
document.getElementById('api-instance-list').addEventListener('click', e => { const button = e.target.closest('button'); if (!button) return; const li = button.closest('li'); const index = parseInt(li.dataset.index, 10); const instances = apiSettings.getInstances(); if (button.classList.contains('move-up') && index > 0) { [instances[index], instances[index - 1]] = [instances[index - 1], instances[index]]; } else if (button.classList.contains('move-down') && index < instances.length - 1) { [instances[index], instances[index + 1]] = [instances[index + 1], instances[index]]; } else if (button.classList.contains('delete-instance')) { instances.splice(index, 1); } apiSettings.saveInstances(instances); renderApiSettings(); postMessageToSW({ action: 'update_setting', key: 'apiInstances', value: instances }); });
document.getElementById('add-instance-form').addEventListener('submit', e => { e.preventDefault(); const input = document.getElementById('custom-instance-input'); const newUrl = input.value.trim(); if (newUrl) { try { const url = new URL(newUrl); if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Invalid protocol'); const instances = apiSettings.getInstances(); const formattedUrl = newUrl.endsWith('/') ? newUrl.slice(0, -1) : newUrl; if (!instances.includes(formattedUrl)) { instances.push(formattedUrl); apiSettings.saveInstances(instances); renderApiSettings(); postMessageToSW({ action: 'update_setting', key: 'apiInstances', value: instances }); input.value = ''; } else { alert('This instance is already in the list.'); } } catch (error) { alert('Please enter a valid URL (e.g., https://example.com)'); } } });
playPauseBtn.innerHTML = svgPlay;
updateVolumeUI();
registerServiceWorker()
.then(swRegistration => {
if (swRegistration) {
console.log('Service Worker is active and ready to intercept fetches.');
postMessageToSW({ action: 'update_setting', key: 'cacheDuration', value: cacheSettings.getDuration() });
postMessageToSW({ action: 'update_setting', key: 'apiInstances', value: apiSettings.getInstances() });
}
router();
window.addEventListener('hashchange', router);
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', handlePlayPause);
navigator.mediaSession.setActionHandler('pause', handlePlayPause);
navigator.mediaSession.setActionHandler('previoustrack', playPrev);
navigator.mediaSession.setActionHandler('nexttrack', playNext);
}
})
.catch(error => {
console.error('App initialization failed:', error);
alert('Could not initialize the app. Caching will be disabled. See console for details.');
router();
window.addEventListener('hashchange', router);
});
});
</script>
<script type="module" src="js/app.js"></script>
</body>
</html>

447
js/api.js Normal file
View file

@ -0,0 +1,447 @@
import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js';
import { APICache } from './cache.js';
export class LosslessAPI {
constructor(settings) {
this.settings = settings;
this.cache = new APICache({
maxSize: 200,
ttl: 1000 * 60 * 30
});
setInterval(() => {
this.cache.clearExpired();
}, 1000 * 60 * 5);
}
async fetchWithRetry(relativePath, options = {}) {
const instances = this.settings.getInstances();
if (instances.length === 0) {
throw new Error("No API instances configured.");
}
const maxRetries = 1;
let lastError = null;
for (const baseUrl of instances) {
const url = baseUrl.endsWith('/')
? `${baseUrl}${relativePath.substring(1)}`
: `${baseUrl}${relativePath}`;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, { signal: options.signal });
if (response.status === 429) {
throw new Error(RATE_LIMIT_ERROR_MESSAGE);
}
if (response.ok) {
return response;
}
if (response.status === 401) {
let errorData;
try {
errorData = await response.clone().json();
} catch {}
if (errorData?.subStatus === 11002) {
lastError = new Error(errorData?.userMessage || 'Authentication failed');
if (attempt < maxRetries) {
await delay(200);
continue;
}
}
}
if (response.status >= 500 && attempt < maxRetries) {
await delay(200);
continue;
}
lastError = new Error(`Request failed with status ${response.status}`);
break;
} catch (error) {
if (error.name === 'AbortError') {
throw error;
}
lastError = error;
console.log(`Failed for ${baseUrl}: ${error.message}`);
if (attempt < maxRetries) {
await delay(200);
}
}
}
}
throw lastError || new Error(`All API instances failed for: ${relativePath}`);
}
findSearchSection(source, key, visited) {
if (!source || typeof source !== 'object') return;
if (Array.isArray(source)) {
for (const e of source) {
const f = this.findSearchSection(e, key, visited);
if (f) return f;
}
return;
}
if (visited.has(source)) return;
visited.add(source);
if ('items' in source && Array.isArray(source.items)) return source;
if (key in source) {
const f = this.findSearchSection(source[key], key, visited);
if (f) return f;
}
for (const v of Object.values(source)) {
const f = this.findSearchSection(v, key, visited);
if (f) return f;
}
}
buildSearchResponse(section) {
const items = section?.items ?? [];
return {
items,
limit: section?.limit ?? items.length,
offset: section?.offset ?? 0,
totalNumberOfItems: section?.totalNumberOfItems ?? items.length
};
}
normalizeSearchResponse(data, key) {
const section = this.findSearchSection(data, key, new Set());
return this.buildSearchResponse(section);
}
prepareTrack(track) {
let normalized = track;
if (!track.artist && Array.isArray(track.artists) && track.artists.length > 0) {
normalized = { ...track, artist: track.artists[0] };
}
if (normalized.album && !normalized.album.cover && normalized.album.id) {
console.warn('Track missing album cover, attempting to use album ID');
}
const derivedQuality = deriveTrackQuality(normalized);
if (derivedQuality && normalized.audioQuality !== derivedQuality) {
normalized = { ...normalized, audioQuality: derivedQuality };
}
return normalized;
}
prepareAlbum(album) {
if (!album.artist && Array.isArray(album.artists) && album.artists.length > 0) {
return { ...album, artist: album.artists[0] };
}
return album;
}
prepareArtist(artist) {
if (!artist.type && Array.isArray(artist.artistTypes) && artist.artistTypes.length > 0) {
return { ...artist, type: artist.artistTypes[0] };
}
return artist;
}
parseTrackLookup(data) {
const entries = Array.isArray(data) ? data : [data];
let track, info, originalTrackUrl;
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
if (!track && 'duration' in entry) {
track = entry;
continue;
}
if (!info && 'manifest' in entry) {
info = entry;
continue;
}
if (!originalTrackUrl && 'OriginalTrackUrl' in entry) {
const candidate = entry.OriginalTrackUrl;
if (typeof candidate === 'string') {
originalTrackUrl = candidate;
}
}
}
if (!track || !info) {
throw new Error('Malformed track response');
}
return { track, info, originalTrackUrl };
}
extractStreamUrlFromManifest(manifest) {
try {
const decoded = atob(manifest);
try {
const parsed = JSON.parse(decoded);
if (parsed?.urls?.[0]) {
return parsed.urls[0];
}
} catch {
const match = decoded.match(/https?:\/\/[\w\-.~:?#[```@!$&'()*+,;=%/]+/);
return match ? match[0] : null;
}
} catch (error) {
console.error('Failed to decode manifest:', error);
return null;
}
}
async searchTracks(query) {
const cached = await this.cache.get('search_tracks', query);
if (cached) return cached;
try {
const response = await this.fetchWithRetry(`/search/?s=${encodeURIComponent(query)}`);
const data = await response.json();
const normalized = this.normalizeSearchResponse(data, 'tracks');
const result = {
...normalized,
items: normalized.items.map(t => this.prepareTrack(t))
};
await this.cache.set('search_tracks', query, result);
return result;
} catch (error) {
console.error('Track search failed:', error);
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
}
}
async searchArtists(query) {
const cached = await this.cache.get('search_artists', query);
if (cached) return cached;
try {
const response = await this.fetchWithRetry(`/search/?a=${encodeURIComponent(query)}`);
const data = await response.json();
const normalized = this.normalizeSearchResponse(data, 'artists');
const result = {
...normalized,
items: normalized.items.map(a => this.prepareArtist(a))
};
await this.cache.set('search_artists', query, result);
return result;
} catch (error) {
console.error('Artist search failed:', error);
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
}
}
async searchAlbums(query) {
const cached = await this.cache.get('search_albums', query);
if (cached) return cached;
try {
const response = await this.fetchWithRetry(`/search/?al=${encodeURIComponent(query)}`);
const data = await response.json();
const normalized = this.normalizeSearchResponse(data, 'albums');
const result = {
...normalized,
items: normalized.items.map(a => this.prepareAlbum(a))
};
await this.cache.set('search_albums', query, result);
return result;
} catch (error) {
console.error('Album search failed:', error);
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
}
}
async getAlbum(id) {
const cached = await this.cache.get('album', id);
if (cached) return cached;
const response = await this.fetchWithRetry(`/album/?id=${id}`);
const data = await response.json();
const entries = Array.isArray(data) ? data : [data];
let album, tracksSection;
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
if (!album && 'numberOfTracks' in entry) {
album = this.prepareAlbum(entry);
}
if (!tracksSection && 'items' in entry) {
tracksSection = entry;
}
}
if (!album) throw new Error('Album not found');
const tracks = (tracksSection?.items || []).map(i => this.prepareTrack(i.item || i));
const result = { album, tracks };
await this.cache.set('album', id, result);
return result;
}
async getArtist(id) {
const cached = await this.cache.get('artist', id);
if (cached) return cached;
const [primaryResponse, contentResponse] = await Promise.all([
this.fetchWithRetry(`/artist/?id=${id}`),
this.fetchWithRetry(`/artist/?f=${id}`)
]);
const primaryData = await primaryResponse.json();
const artist = this.prepareArtist(Array.isArray(primaryData) ? primaryData[0] : primaryData);
if (!artist) throw new Error('Primary artist details not found.');
const contentData = await contentResponse.json();
const entries = Array.isArray(contentData) ? contentData : [contentData];
const albumMap = new Map();
const trackMap = new Map();
const isTrack = v => v?.id && v.duration && v.album;
const isAlbum = v => v?.id && v.cover && 'numberOfTracks' in v;
const scan = (value, visited = new Set()) => {
if (!value || typeof value !== 'object' || visited.has(value)) return;
visited.add(value);
if (Array.isArray(value)) {
value.forEach(item => scan(item, visited));
return;
}
const item = value.item || value;
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
Object.values(value).forEach(nested => scan(nested, visited));
};
entries.forEach(entry => scan(entry));
const albums = Array.from(albumMap.values()).sort((a, b) =>
new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
);
const tracks = Array.from(trackMap.values())
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
.slice(0, 10);
const result = { ...artist, albums, tracks };
await this.cache.set('artist', id, result);
return result;
}
async getTrack(id, quality = 'LOSSLESS') {
const cacheKey = `${id}_${quality}`;
const cached = await this.cache.get('track', cacheKey);
if (cached) return cached;
const response = await this.fetchWithRetry(`/track/?id=${id}&quality=${quality}`);
const result = this.parseTrackLookup(await response.json());
await this.cache.set('track', cacheKey, result);
return result;
}
async getStreamUrl(id, quality = 'LOSSLESS') {
const lookup = await this.getTrack(id, quality);
if (lookup.originalTrackUrl) {
return lookup.originalTrackUrl;
}
const url = this.extractStreamUrlFromManifest(lookup.info.manifest);
if (url) return url;
throw new Error('Could not resolve stream URL');
}
async downloadTrack(id, quality = 'LOSSLESS', filename) {
try {
const lookup = await this.getTrack(id, quality);
let streamUrl;
if (lookup.originalTrackUrl) {
streamUrl = lookup.originalTrackUrl;
} else {
streamUrl = this.extractStreamUrlFromManifest(lookup.info.manifest);
if (!streamUrl) {
throw new Error('Could not resolve stream URL');
}
}
const response = await fetch(streamUrl, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`Fetch failed: ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error("Download failed:", error);
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
throw error;
}
throw new Error('Download failed. The stream may require a proxy.');
}
}
getCoverUrl(id, size = '1280') {
if (!id) {
return `https://picsum.photos/seed/${Math.random()}/${size}`;
}
const formattedId = id.replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
getArtistPictureUrl(id, size = '750') {
if (!id) {
return `https://picsum.photos/seed/${Math.random()}/${size}`;
}
const formattedId = id.replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
async clearCache() {
await this.cache.clear();
}
getCacheStats() {
return this.cache.getCacheStats();
}
}

440
js/app.js Normal file
View file

@ -0,0 +1,440 @@
import { LosslessAPI } from './api.js';
import { apiSettings } from './storage.js';
import { UIRenderer } from './ui.js';
import { Player } from './player.js';
import {
QUALITY, REPEAT_MODE, SVG_PLAY, SVG_PAUSE,
SVG_VOLUME, SVG_MUTE, formatTime, trackDataStore,
buildTrackFilename, RATE_LIMIT_ERROR_MESSAGE
} from './utils.js';
document.addEventListener('DOMContentLoaded', () => {
const api = new LosslessAPI(apiSettings);
const ui = new UIRenderer(api);
const audioPlayer = document.getElementById('audio-player');
const player = new Player(audioPlayer, api, QUALITY);
const mainContent = document.querySelector('.main-content');
const playPauseBtn = document.querySelector('.play-pause-btn');
const nextBtn = document.getElementById('next-btn');
const prevBtn = document.getElementById('prev-btn');
const shuffleBtn = document.getElementById('shuffle-btn');
const repeatBtn = document.getElementById('repeat-btn');
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
const currentTimeEl = document.getElementById('current-time');
const totalDurationEl = document.getElementById('total-duration');
const volumeBar = document.getElementById('volume-bar');
const volumeFill = document.getElementById('volume-fill');
const volumeBtn = document.getElementById('volume-btn');
const contextMenu = document.getElementById('context-menu');
const queueBtn = document.getElementById('queue-btn');
const queueModalOverlay = document.getElementById('queue-modal-overlay');
const closeQueueBtn = document.getElementById('close-queue-btn');
const queueList = document.getElementById('queue-list');
const searchForm = document.getElementById('search-form');
const searchInput = document.getElementById('search-input');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const hamburgerBtn = document.getElementById('hamburger-btn');
let contextTrack = null;
document.querySelectorAll('.search-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.search-tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`search-tab-${tab.dataset.tab}`).classList.add('active');
});
});
const router = () => {
const path = window.location.hash.substring(1) || "home";
const [page, param] = path.split('/');
switch (page) {
case 'search':
ui.renderSearchPage(decodeURIComponent(param));
break;
case 'album':
ui.renderAlbumPage(param);
break;
case 'artist':
ui.renderArtistPage(param);
break;
case 'home':
ui.renderHomePage();
break;
default:
ui.showPage(page);
break;
}
};
const renderQueue = () => {
if (!queueModalOverlay.style.display || queueModalOverlay.style.display === "none") {
return;
}
const currentQueue = player.getCurrentQueue();
if (currentQueue.length === 0) {
queueList.innerHTML = '<div class="placeholder-text">Queue is empty.</div>';
return;
}
const html = currentQueue.map((track, index) => {
const isPlaying = index === player.currentQueueIndex &&
track.id === (currentQueue[player.currentQueueIndex] || {}).id;
return `
<div class="track-item ${isPlaying ? 'playing' : ''}" data-queue-index="${index}">
<div class="track-number">${index + 1}</div>
<div class="track-item-info">
<img src="${api.getCoverUrl(track.album?.cover, '40')}"
class="track-item-cover" loading="lazy">
<div class="track-item-details">
<div class="title">${track.title}</div>
<div class="artist">${track.artist?.name || 'Unknown'}</div>
</div>
</div>
<div class="track-item-duration">${formatTime(track.duration)}</div>
</div>
`;
}).join('');
queueList.innerHTML = html;
player.updatePlayingTrackIndicator();
};
mainContent.addEventListener('click', e => {
const trackItem = e.target.closest('.track-item');
if (trackItem) {
const parentList = trackItem.closest('.track-list');
const allTrackElements = Array.from(parentList.querySelectorAll('.track-item'));
const trackList = allTrackElements.map(el => trackDataStore.get(el)).filter(Boolean);
if (trackList.length > 0) {
const clickedTrackId = trackItem.dataset.trackId;
const startIndex = trackList.findIndex(t => t.id == clickedTrackId);
player.setQueue(trackList, startIndex);
shuffleBtn.classList.remove('active');
player.playTrackFromQueue();
}
}
});
mainContent.addEventListener('contextmenu', e => {
const trackItem = e.target.closest('.track-item');
if (trackItem) {
e.preventDefault();
contextTrack = trackDataStore.get(trackItem);
if (contextTrack) {
contextMenu.style.top = `${e.pageY}px`;
contextMenu.style.left = `${e.pageX}px`;
contextMenu.style.display = 'block';
}
}
});
document.addEventListener('click', () => {
contextMenu.style.display = 'none';
});
contextMenu.addEventListener('click', async e => {
e.stopPropagation();
const action = e.target.dataset.action;
if (action === 'add-to-queue' && contextTrack) {
player.addToQueue(contextTrack);
renderQueue();
} else if (action === 'download' && contextTrack) {
const filename = buildTrackFilename(contextTrack, QUALITY);
try {
const tempEl = document.createElement('div');
tempEl.textContent = `Downloading: ${contextTrack.title}...`;
tempEl.style.cssText = 'position:fixed;bottom:20px;right:20px;background:var(--card);padding:1rem;border-radius:var(--radius);border:1px solid var(--border);z-index:9999;';
document.body.appendChild(tempEl);
await api.downloadTrack(contextTrack.id, QUALITY, filename);
tempEl.textContent = `Downloaded: ${contextTrack.title}`;
setTimeout(() => tempEl.remove(), 3000);
} catch (error) {
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
? error.message
: 'Download failed. Please try again.';
alert(errorMsg);
}
}
contextMenu.style.display = 'none';
});
searchForm.addEventListener('submit', e => {
e.preventDefault();
const query = searchInput.value.trim();
if (query) {
window.location.hash = `#search/${encodeURIComponent(query)}`;
}
});
audioPlayer.addEventListener('play', () => {
playPauseBtn.innerHTML = SVG_PAUSE;
player.updateMediaSessionPlaybackState();
});
audioPlayer.addEventListener('pause', () => {
playPauseBtn.innerHTML = SVG_PLAY;
player.updateMediaSessionPlaybackState();
});
audioPlayer.addEventListener('ended', () => {
player.playNext();
});
audioPlayer.addEventListener('timeupdate', () => {
const { currentTime, duration } = audioPlayer;
if (duration) {
progressFill.style.width = `${(currentTime / duration) * 100}%`;
currentTimeEl.textContent = formatTime(currentTime);
}
});
audioPlayer.addEventListener('loadedmetadata', () => {
totalDurationEl.textContent = formatTime(audioPlayer.duration);
player.updateMediaSessionPositionState();
});
audioPlayer.addEventListener('error', (e) => {
console.error('Audio playback error:', e);
document.querySelector('.now-playing-bar .artist').textContent = 'Playback error. Try another track.';
playPauseBtn.innerHTML = SVG_PLAY;
});
const seek = (bar, fill, event, setter) => {
const rect = bar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
setter(position);
};
progressBar.addEventListener('click', e => {
seek(progressBar, progressFill, e, position => {
if (!isNaN(audioPlayer.duration)) {
audioPlayer.currentTime = position * audioPlayer.duration;
}
});
});
volumeBar.addEventListener('click', e => {
seek(volumeBar, volumeFill, e, position => {
audioPlayer.volume = position;
});
});
const updateVolumeUI = () => {
const { volume, muted } = audioPlayer;
volumeBtn.innerHTML = (muted || volume === 0) ? SVG_MUTE : SVG_VOLUME;
volumeFill.style.width = `${muted ? 0 : volume * 100}%`;
};
volumeBtn.addEventListener('click', () => {
audioPlayer.muted = !audioPlayer.muted;
});
audioPlayer.addEventListener('volumechange', updateVolumeUI);
playPauseBtn.addEventListener('click', () => player.handlePlayPause());
nextBtn.addEventListener('click', () => player.playNext());
prevBtn.addEventListener('click', () => player.playPrev());
shuffleBtn.addEventListener('click', () => {
player.toggleShuffle();
shuffleBtn.classList.toggle('active', player.shuffleActive);
renderQueue();
});
repeatBtn.addEventListener('click', () => {
const mode = player.toggleRepeat();
repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF);
repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE);
repeatBtn.title = mode === REPEAT_MODE.OFF
? 'Repeat'
: (mode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One');
});
queueBtn.addEventListener('click', () => {
renderQueue();
queueModalOverlay.style.display = 'flex';
});
closeQueueBtn.addEventListener('click', () => {
queueModalOverlay.style.display = 'none';
});
queueModalOverlay.addEventListener('click', e => {
if (e.target === queueModalOverlay) {
queueModalOverlay.style.display = 'none';
}
});
hamburgerBtn.addEventListener('click', () => {
sidebar.classList.add('is-open');
sidebarOverlay.classList.add('is-visible');
});
const closeSidebar = () => {
sidebar.classList.remove('is-open');
sidebarOverlay.classList.remove('is-visible');
};
sidebarOverlay.addEventListener('click', closeSidebar);
sidebar.addEventListener('click', e => {
if (e.target.closest('a')) {
closeSidebar();
}
});
document.getElementById('api-instance-list').addEventListener('click', e => {
const button = e.target.closest('button');
if (!button) return;
const li = button.closest('li');
const index = parseInt(li.dataset.index, 10);
const instances = apiSettings.getInstances();
if (button.classList.contains('move-up') && index > 0) {
[instances[index], instances[index - 1]] = [instances[index - 1], instances[index]];
} else if (button.classList.contains('move-down') && index < instances.length - 1) {
[instances[index], instances[index + 1]] = [instances[index + 1], instances[index]];
} else if (button.classList.contains('delete-instance')) {
instances.splice(index, 1);
}
apiSettings.saveInstances(instances);
ui.renderApiSettings();
});
document.getElementById('add-instance-form').addEventListener('submit', e => {
e.preventDefault();
const input = document.getElementById('custom-instance-input');
const newUrl = input.value.trim();
if (newUrl) {
try {
const url = new URL(newUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Invalid protocol');
}
const instances = apiSettings.getInstances();
const formattedUrl = newUrl.endsWith('/') ? newUrl.slice(0, -1) : newUrl;
if (!instances.includes(formattedUrl)) {
instances.push(formattedUrl);
apiSettings.saveInstances(instances);
ui.renderApiSettings();
input.value = '';
} else {
alert('This instance is already in the list.');
}
} catch (error) {
alert('Please enter a valid URL (e.g., https://example.com)');
}
}
});
document.getElementById('clear-cache-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('clear-cache-btn');
const originalText = btn.textContent;
btn.textContent = 'Clearing...';
btn.disabled = true;
try {
await api.clearCache();
btn.textContent = 'Cleared!';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
if (window.location.hash.includes('settings')) {
ui.renderApiSettings();
}
}, 1500);
} catch (error) {
console.error('Failed to clear cache:', error);
btn.textContent = 'Error';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 1500);
}
});
playPauseBtn.innerHTML = SVG_PLAY;
updateVolumeUI();
router();
window.addEventListener('hashchange', router);
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => {
player.handlePlayPause();
});
navigator.mediaSession.setActionHandler('pause', () => {
player.handlePlayPause();
});
navigator.mediaSession.setActionHandler('previoustrack', () => {
player.playPrev();
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
player.playNext();
});
navigator.mediaSession.setActionHandler('seekbackward', (details) => {
const skipTime = details.seekOffset || 10;
player.seekBackward(skipTime);
});
navigator.mediaSession.setActionHandler('seekforward', (details) => {
const skipTime = details.seekOffset || 10;
player.seekForward(skipTime);
});
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (details.fastSeek && 'fastSeek' in audioPlayer) {
audioPlayer.fastSeek(details.seekTime);
} else {
audioPlayer.currentTime = details.seekTime;
}
player.updateMediaSessionPositionState();
});
navigator.mediaSession.setActionHandler('stop', () => {
audioPlayer.pause();
audioPlayer.currentTime = 0;
});
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js')
.then(reg => console.log('Service worker registered'))
.catch(err => console.log('Service worker not registered', err));
});
}
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
});
});

179
js/cache.js Normal file
View file

@ -0,0 +1,179 @@
export class APICache {
constructor(options = {}) {
this.memoryCache = new Map();
this.maxSize = options.maxSize || 200;
this.ttl = options.ttl || 1000 * 60 * 30;
this.dbName = 'monochrome-cache';
this.dbVersion = 1;
this.db = null;
this.initDB();
}
async initDB() {
if (typeof indexedDB === 'undefined') return;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('responses')) {
const store = db.createObjectStore('responses', { keyPath: 'key' });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
});
}
generateKey(type, params) {
const paramString = typeof params === 'object'
? JSON.stringify(params)
: String(params);
return `${type}:${paramString}`;
}
async get(type, params) {
const key = this.generateKey(type, params);
if (this.memoryCache.has(key)) {
const cached = this.memoryCache.get(key);
if (Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
this.memoryCache.delete(key);
}
if (this.db) {
try {
const cached = await this.getFromIndexedDB(key);
if (cached && Date.now() - cached.timestamp < this.ttl) {
this.memoryCache.set(key, cached);
return cached.data;
}
} catch (error) {
console.debug('IndexedDB read error:', error);
}
}
return null;
}
async set(type, params, data) {
const key = this.generateKey(type, params);
const entry = {
key,
data,
timestamp: Date.now()
};
this.memoryCache.set(key, entry);
if (this.memoryCache.size > this.maxSize) {
const firstKey = this.memoryCache.keys().next().value;
this.memoryCache.delete(firstKey);
}
if (this.db) {
try {
await this.setInIndexedDB(entry);
} catch (error) {
console.debug('IndexedDB write error:', error);
}
}
}
getFromIndexedDB(key) {
return new Promise((resolve, reject) => {
if (!this.db) {
resolve(null);
return;
}
const transaction = this.db.transaction(['responses'], 'readonly');
const store = transaction.objectStore('responses');
const request = store.get(key);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
setInIndexedDB(entry) {
return new Promise((resolve, reject) => {
if (!this.db) {
resolve();
return;
}
const transaction = this.db.transaction(['responses'], 'readwrite');
const store = transaction.objectStore('responses');
const request = store.put(entry);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async clear() {
this.memoryCache.clear();
if (this.db) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['responses'], 'readwrite');
const store = transaction.objectStore('responses');
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
async clearExpired() {
const now = Date.now();
const expired = [];
for (const [key, entry] of this.memoryCache.entries()) {
if (now - entry.timestamp >= this.ttl) {
expired.push(key);
}
}
expired.forEach(key => this.memoryCache.delete(key));
if (this.db) {
try {
const transaction = this.db.transaction(['responses'], 'readwrite');
const store = transaction.objectStore('responses');
const index = store.index('timestamp');
const range = IDBKeyRange.upperBound(now - this.ttl);
const request = index.openCursor(range);
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
cursor.delete();
cursor.continue();
}
};
} catch (error) {
console.debug('Failed to clear expired IndexedDB entries:', error);
}
}
}
getCacheStats() {
return {
memoryEntries: this.memoryCache.size,
maxSize: this.maxSize,
ttl: this.ttl
};
}
}

193
js/player.js Normal file
View file

@ -0,0 +1,193 @@
import { REPEAT_MODE, SVG_PLAY, SVG_PAUSE, formatTime } from './utils.js';
export class Player {
constructor(audioElement, api, quality = 'LOSSLESS') {
this.audio = audioElement;
this.api = api;
this.quality = quality;
this.queue = [];
this.shuffledQueue = [];
this.originalQueueBeforeShuffle = [];
this.currentQueueIndex = -1;
this.shuffleActive = false;
this.repeatMode = REPEAT_MODE.OFF;
}
setQuality(quality) {
this.quality = quality;
}
async playTrackFromQueue() {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
return;
}
const track = currentQueue[this.currentQueueIndex];
document.querySelector('.now-playing-bar .cover').src =
this.api.getCoverUrl(track.album?.cover, '80');
document.querySelector('.now-playing-bar .title').textContent = track.title;
document.querySelector('.now-playing-bar .artist').textContent = track.artist?.name || 'Unknown Artist';
document.title = `${track.title}${track.artist?.name || 'Unknown'}`;
this.updatePlayingTrackIndicator();
this.updateMediaSession(track);
try {
const streamUrl = await this.api.getStreamUrl(track.id, this.quality);
this.audio.src = streamUrl;
await this.audio.play();
} catch (error) {
console.error(`Could not get track URL for: ${track.title}`, error);
document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`;
document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
document.querySelector('.play-pause-btn').innerHTML = SVG_PLAY;
}
}
playNext() {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1;
if (this.repeatMode === REPEAT_MODE.ONE) {
this.audio.currentTime = 0;
this.audio.play();
return;
}
if (!isLastTrack) {
this.currentQueueIndex++;
} else if (this.repeatMode === REPEAT_MODE.ALL) {
this.currentQueueIndex = 0;
} else {
return;
}
this.playTrackFromQueue();
}
playPrev() {
if (this.audio.currentTime > 3) {
this.audio.currentTime = 0;
} else if (this.currentQueueIndex > 0) {
this.currentQueueIndex--;
this.playTrackFromQueue();
}
}
handlePlayPause() {
if (!this.audio.src) return;
this.audio.paused ? this.audio.play() : this.audio.pause();
}
seekBackward(seconds = 10) {
const newTime = Math.max(0, this.audio.currentTime - seconds);
this.audio.currentTime = newTime;
}
seekForward(seconds = 10) {
const duration = this.audio.duration || 0;
const newTime = Math.min(duration, this.audio.currentTime + seconds);
this.audio.currentTime = newTime;
}
toggleShuffle() {
this.shuffleActive = !this.shuffleActive;
if (this.shuffleActive) {
this.originalQueueBeforeShuffle = [...this.queue];
const currentTrack = this.queue[this.currentQueueIndex];
this.shuffledQueue = [...this.queue].sort(() => Math.random() - 0.5);
this.currentQueueIndex = this.shuffledQueue.findIndex(t => t.id === currentTrack?.id);
if (this.currentQueueIndex === -1 && currentTrack) {
this.shuffledQueue.unshift(currentTrack);
this.currentQueueIndex = 0;
}
} else {
const currentTrack = this.shuffledQueue[this.currentQueueIndex];
this.queue = [...this.originalQueueBeforeShuffle];
this.currentQueueIndex = this.queue.findIndex(t => t.id === currentTrack?.id);
}
}
toggleRepeat() {
this.repeatMode = (this.repeatMode + 1) % 3;
return this.repeatMode;
}
setQueue(tracks, startIndex = 0) {
this.queue = tracks;
this.currentQueueIndex = startIndex;
this.shuffleActive = false;
}
addToQueue(track) {
this.queue.push(track);
}
getCurrentQueue() {
return this.shuffleActive ? this.shuffledQueue : this.queue;
}
updatePlayingTrackIndicator() {
const currentTrack = this.getCurrentQueue()[this.currentQueueIndex];
document.querySelectorAll('.track-item').forEach(item => {
item.classList.toggle('playing',
currentTrack && item.dataset.trackId == currentTrack.id
);
});
}
updateMediaSession(track) {
if (!('mediaSession' in navigator)) return;
const artwork = [];
const sizes = ['1280'];
const coverId = track.album?.cover;
if (coverId) {
sizes.forEach(size => {
const url = this.api.getCoverUrl(coverId, size);
artwork.push({
src: url,
sizes: `${size}x${size}`,
type: 'image/jpeg'
});
});
}
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title || 'Unknown Title',
artist: track.artist?.name || 'Unknown Artist',
album: track.album?.title || 'Unknown Album',
artwork: artwork.length > 0 ? artwork : undefined
});
navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing';
}
updateMediaSessionPlaybackState() {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing';
}
}
updateMediaSessionPositionState() {
if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) {
if (this.audio.duration && !isNaN(this.audio.duration)) {
try {
navigator.mediaSession.setPositionState({
duration: this.audio.duration,
playbackRate: this.audio.playbackRate,
position: this.audio.currentTime
});
} catch (error) {
console.debug('Failed to update position state:', error);
}
}
}
}
}

61
js/storage.js Normal file
View file

@ -0,0 +1,61 @@
export const apiSettings = {
STORAGE_KEY: 'monochrome-api-instances',
defaultInstances: [
'https://triton.squid.wtf',
'https://kraken.squid.wtf',
'https://zeus.squid.wtf',
'https://aether.squid.wtf',
'https://tidal.401658.xyz'
],
getInstances() {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
return stored ? JSON.parse(stored) : [...this.defaultInstances];
} catch (e) {
return [...this.defaultInstances];
}
},
saveInstances(instances) {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(instances));
}
};
export const recentActivityManager = {
STORAGE_KEY: 'monochrome-recent-activity',
LIMIT: 10,
_get() {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
return data ? JSON.parse(data) : { artists: [], albums: [] };
} catch (e) {
return { artists: [], albums: [] };
}
},
_save(data) {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
},
getRecents() {
return this._get();
},
_add(type, item) {
const data = this._get();
data[type] = data[type].filter(i => i.id !== item.id);
data[type].unshift(item);
data[type] = data[type].slice(0, this.LIMIT);
this._save(data);
},
addArtist(artist) {
this._add('artists', artist);
},
addAlbum(album) {
this._add('albums', album);
}
};

260
js/ui.js Normal file
View file

@ -0,0 +1,260 @@
import { formatTime, createPlaceholder, trackDataStore } from './utils.js';
import { recentActivityManager } from './storage.js';
export class UIRenderer {
constructor(api) {
this.api = api;
}
createTrackItemHTML(track, index, showCover = 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 trackNumberHTML = `<div class="track-number" style="font-size: 1.1em; display: flex; align-items: center; justify-content: center;">${showCover ? playIconSmall : index + 1}</div>`;
return `
<div class="track-item" data-track-id="${track.id}">
${trackNumberHTML}
<div class="track-item-info">
${showCover ? `<img src="${this.api.getCoverUrl(track.album?.cover, '80')}" alt="Track Cover" class="track-item-cover" loading="lazy">` : ''}
<div class="track-item-details">
<div class="title">${track.title}</div>
<div class="artist">${track.artist?.name ?? 'Unknown Artist'}</div>
</div>
</div>
<div class="track-item-duration">${formatTime(track.duration)}</div>
</div>
`;
}
createAlbumCardHTML(album) {
return `
<a href="#album/${album.id}" class="card">
<img src="${this.api.getCoverUrl(album.cover)}" alt="${album.title}" class="card-image" loading="lazy">
<h3 class="card-title">${album.title}</h3>
<p class="card-subtitle">Album ${album.artist?.name ?? ''}</p>
</a>
`;
}
createArtistCardHTML(artist) {
return `
<a href="#artist/${artist.id}" class="card artist">
<img src="${this.api.getArtistPictureUrl(artist.picture, '750')}" alt="${artist.name}" class="card-image" loading="lazy">
<h3 class="card-title">${artist.name}</h3>
<p class="card-subtitle">Artist</p>
</a>
`;
}
renderListWithTracks(container, tracks, showCover) {
container.innerHTML = tracks.map((track, i) =>
this.createTrackItemHTML(track, i, showCover)
).join('');
tracks.forEach(track => {
const element = container.querySelector(`[data-track-id="${track.id}"]`);
if (element) trackDataStore.set(element, track);
});
}
showPage(pageId) {
document.querySelectorAll('.page').forEach(page => {
page.classList.toggle('active', page.id === `page-${pageId}`);
});
document.querySelectorAll('.sidebar-nav a').forEach(link => {
link.classList.toggle('active', link.hash === `#${pageId}`);
});
document.querySelector('.main-content').scrollTop = 0;
if (pageId === 'settings') {
this.renderApiSettings();
}
}
renderHomePage() {
this.showPage('home');
const recents = recentActivityManager.getRecents();
document.getElementById('home-recent-albums').innerHTML = recents.albums.length
? recents.albums.map(album => this.createAlbumCardHTML(album)).join('')
: createPlaceholder("You haven't viewed any albums yet.");
document.getElementById('home-recent-artists').innerHTML = recents.artists.length
? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('')
: createPlaceholder("You haven't viewed any artists yet.");
}
async renderSearchPage(query) {
this.showPage('search');
document.getElementById('search-results-title').textContent = `Search Results for "${query}"`;
const tracksContainer = document.getElementById('search-tracks-container');
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);
try {
const [tracksResult, artistsResult, albumsResult] = await Promise.all([
this.api.searchTracks(query),
this.api.searchArtists(query),
this.api.searchAlbums(query)
]);
let finalTracks = tracksResult.items;
let finalArtists = artistsResult.items;
let finalAlbums = albumsResult.items;
if (finalArtists.length === 0 && finalTracks.length > 0) {
console.log('Using fallback: extracting artists from tracks');
const artistMap = new Map();
finalTracks.forEach(track => {
if (track.artist && !artistMap.has(track.artist.id)) {
artistMap.set(track.artist.id, track.artist);
}
if (track.artists) {
track.artists.forEach(artist => {
if (!artistMap.has(artist.id)) {
artistMap.set(artist.id, artist);
}
});
}
});
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;
}
}
async renderAlbumPage(albumId) {
this.showPage('album');
const tracklistContainer = document.getElementById('album-detail-tracklist');
tracklistContainer.innerHTML = createPlaceholder('Loading...', true);
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 =
`By <a href="#artist/${album.artist.id}">${album.artist.name}</a> • ${new Date(album.releaseDate).getFullYear()}`;
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>
`;
tracks.sort((a, b) => a.trackNumber - b.trackNumber);
this.renderListWithTracks(tracklistContainer, tracks, false);
recentActivityManager.addAlbum(album);
} catch (error) {
console.error("Failed to load album:", error);
tracklistContainer.innerHTML = createPlaceholder(`Could not load album details. ${error.message}`);
}
}
async renderArtistPage(artistId) {
this.showPage('artist');
const tracksContainer = document.getElementById('artist-detail-tracks');
const albumsContainer = document.getElementById('artist-detail-albums');
tracksContainer.innerHTML = albumsContainer.innerHTML = createPlaceholder('Loading...', true);
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`;
this.renderListWithTracks(tracksContainer, artist.tracks, true);
albumsContainer.innerHTML = artist.albums.map(album =>
this.createAlbumCardHTML(album)
).join('');
recentActivityManager.addArtist(artist);
} catch (error) {
console.error("Failed to load artist:", error);
tracksContainer.innerHTML = albumsContainer.innerHTML =
createPlaceholder(`Could not load artist details. ${error.message}`);
}
}
renderApiSettings() {
const container = document.getElementById('api-instance-list');
const instances = this.api.settings.getInstances();
const defaultInstancesSet = new Set(this.api.settings.defaultInstances);
container.innerHTML = instances.map((url, index) => `
<li data-index="${index}">
<span class="instance-url">${url}</span>
<div class="controls">
<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">
<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 cacheInfo = document.getElementById('cache-info');
if (cacheInfo) {
cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`;
}
}
}

141
js/utils.js Normal file
View file

@ -0,0 +1,141 @@
export const QUALITY = 'LOSSLESS';
export const REPEAT_MODE = {
OFF: 0,
ALL: 1,
ONE: 2
};
export const AUDIO_QUALITIES = {
HI_RES_LOSSLESS: 'HI_RES_LOSSLESS',
LOSSLESS: 'LOSSLESS',
HIGH: 'HIGH',
LOW: 'LOW'
};
export const QUALITY_PRIORITY = ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'];
export const QUALITY_TOKENS = {
HI_RES_LOSSLESS: ['HI_RES_LOSSLESS', 'HIRES_LOSSLESS', 'HIRESLOSSLESS', 'HIFI_PLUS', 'HI_RES_FLAC', 'HI_RES', 'HIRES', 'MASTER', 'MASTER_QUALITY', 'MQA'],
LOSSLESS: ['LOSSLESS', 'HIFI'],
HIGH: ['HIGH', 'HIGH_QUALITY'],
LOW: ['LOW', 'LOW_QUALITY']
};
export const RATE_LIMIT_ERROR_MESSAGE = 'Too Many Requests. Please wait a moment and try again.';
export const SVG_PLAY = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>';
export const SVG_PAUSE = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>';
export const SVG_VOLUME = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg>';
export const SVG_MUTE = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><line x1="23" y1="9" x2="17" y2="15"></line><line x1="17" y1="9" x2="23" y2="15"></line></svg>';
export const formatTime = (seconds) => {
if (isNaN(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
};
export const createPlaceholder = (text, isLoading = false) => {
return `<div class="placeholder-text ${isLoading ? 'loading' : ''}">${text}</div>`;
};
export const trackDataStore = new WeakMap();
export const sanitizeForFilename = (value) => {
if (!value) return 'Unknown';
return value
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim();
};
export const getExtensionForQuality = (quality) => {
switch (quality) {
case 'LOW':
case 'HIGH':
return 'm4a';
default:
return 'flac';
}
};
export const buildTrackFilename = (track, quality) => {
const extension = getExtensionForQuality(quality);
const trackNumber = Number(track.trackNumber);
const padded = Number.isFinite(trackNumber) && trackNumber > 0
? `${trackNumber}`.padStart(2, '0')
: '00';
const artistName = sanitizeForFilename(track.artist?.name);
const albumTitle = sanitizeForFilename(track.album?.title);
const trackTitle = sanitizeForFilename(track.title);
return `${artistName} - ${albumTitle} - ${padded} ${trackTitle}.${extension}`;
};
const sanitizeToken = (value) => {
if (!value) return '';
return value.trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_');
};
export const normalizeQualityToken = (value) => {
if (!value) return null;
const token = sanitizeToken(value);
for (const [quality, aliases] of Object.entries(QUALITY_TOKENS)) {
if (aliases.includes(token)) {
return quality;
}
}
return null;
};
export const deriveQualityFromTags = (rawTags) => {
if (!Array.isArray(rawTags)) return null;
const candidates = [];
for (const tag of rawTags) {
if (typeof tag !== 'string') continue;
const normalized = normalizeQualityToken(tag);
if (normalized && !candidates.includes(normalized)) {
candidates.push(normalized);
}
}
return pickBestQuality(candidates);
};
export const pickBestQuality = (candidates) => {
let best = null;
let bestRank = Infinity;
for (const candidate of candidates) {
if (!candidate) continue;
const rank = QUALITY_PRIORITY.indexOf(candidate);
const currentRank = rank === -1 ? Infinity : rank;
if (currentRank < bestRank) {
best = candidate;
bestRank = currentRank;
}
}
return best;
};
export const deriveTrackQuality = (track) => {
if (!track) return null;
const candidates = [
deriveQualityFromTags(track.mediaMetadata?.tags),
deriveQualityFromTags(track.album?.mediaMetadata?.tags),
normalizeQualityToken(track.audioQuality)
];
return pickBestQuality(candidates);
};
export const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));

233
license.md Normal file
View file

@ -0,0 +1,233 @@
# HIPPOCRATIC LICENSE
**Version 3.0, October 2021**
**Modified 2025-08-04**
<https://firstdonoharm.dev/version/3/0/bds-bod-cl-eco-extr-ffd-law-media-mil-my-soc-sup-sv-tal-usta.md>
---
## TERMS AND CONDITIONS
### TERMS AND CONDITIONS FOR USE, COPY, MODIFICATION, PREPARATION OF DERIVATIVE WORK, REPRODUCTION, AND DISTRIBUTION:
---
## [1.] DEFINITIONS
_This section defines certain terms used throughout this license agreement._
- **[1.1.]** “License” means the terms and conditions, as stated herein, for use, copy, modification, preparation of derivative work, reproduction, and distribution of Software (as defined below).
- **[1.2.]** “Licensor” means the copyright and/or patent owner or entity authorized by the copyright and/or patent owner that is granting the License.
- **[1.3.]** “Licensee” means the individual or entity exercising permissions granted by this License, including the use, copy, modification, preparation of derivative work, reproduction, and distribution of Software (as defined below).
- **[1.4.]** “Software” means any copyrighted work, including but not limited to software code, authored by Licensor and made available under this License, and includes all forms, formats, translations, transformations, compilations, or representations of the original work, whether human- or machine-readable, and any data, output, result, or derivative generated by or from the Software.
- **[1.5.]** “Supply Chain” means the sequence of processes involved in the production and/or distribution of a commodity, good, or service offered by the Licensee.
- **[1.6.]** “Supply Chain Impacted Party” or “Supply Chain Impacted Parties” means any person(s) directly impacted by any of Licensees Supply Chain, including the practices of all persons or entities within the Supply Chain prior to a good or service reaching the Licensee.
- **[1.7.]** “Duty of Care” is defined by its use in tort law, delict law, and/or similar bodies of law closely related to tort and/or delict law, including without limitation, a requirement to act with the watchfulness, attention, caution, and prudence that a reasonable person in the same or similar circumstances would use towards any Supply Chain Impacted Party.
- **[1.8.]** “Worker” is defined to include any and all permanent, temporary, and agency workers, as well as piece-rate, salaried, hourly paid, legal young (minors), part-time, night, and migrant workers.
- **[1.9.]** “Dataset” means any collection, corpus, compilation, aggregation, or aggregation of data, code, text, software, or other materials, in whole or in part, used for any purpose related to artificial intelligence (AI) or machine learning (ML), including but not limited to training, fine-tuning, evaluating, testing, benchmarking, or developing AI/ML models, systems, or services.
- **[1.10.]** “Automated Means” includes, but is not limited to, any current or future automated, semi-automated, or programmatic method, tool, or technology, whether known or unknown at the time of this License, used to access, copy, download, or acquire data or code.
---
## [2.] INTELLECTUAL PROPERTY GRANTS
_This section identifies intellectual property rights granted to a Licensee._
- **[2.1.] Grant of Copyright License:**
Subject to the terms and conditions of this License, Licensor hereby grants to Licensee a worldwide, non-exclusive, no-charge, royalty-free copyright license to use, copy, modify, prepare derivative work, reproduce, or distribute the Software, Licensor authored modified software, or other work derived from the Software.
- **[2.2.] Grant of Patent License:**
Subject to the terms and conditions of this License, Licensor hereby grants Licensee a worldwide, non-exclusive, no-charge, royalty-free patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer Software.
---
## [3.] ETHICAL STANDARDS
_This section lists conditions the Licensee must comply with in order to have rights under this License._
The rights granted to the Licensee by this License are expressly made subject to the Licensees ongoing compliance with the following conditions:
### [3.1.] The Licensee SHALL NOT, whether directly or indirectly, through agents or assigns:
- [3.1.1.] Infringe upon any persons right to life or security of person, engage in extrajudicial killings, or commit murder, without lawful cause (See Article 3, _United Nations Universal Declaration of Human Rights_; Article 6, _International Covenant on Civil and Political Rights_)
- [3.1.2.] Hold any person in slavery, servitude, or forced labor (See Article 4, _United Nations Universal Declaration of Human Rights_; Article 8, _International Covenant on Civil and Political Rights_);
- [3.1.3.] Contribute to the institution of slavery, slave trading, forced labor, or unlawful child labor (See Article 4, _United Nations Universal Declaration of Human Rights_; Article 8, _International Covenant on Civil and Political Rights_);
- [3.1.4.] Torture or subject any person to cruel, inhumane, or degrading treatment or punishment (See Article 5, _United Nations Universal Declaration of Human Rights_; Article 7, _International Covenant on Civil and Political Rights_);
- [3.1.5.] Discriminate on the basis of sex, gender, sexual orientation, race, ethnicity, nationality, religion, caste, age, medical disability or impairment, and/or any other like circumstances (See Article 7, _United Nations Universal Declaration of Human Rights_; Article 2, _International Covenant on Economic, Social and Cultural Rights_; Article 26, _International Covenant on Civil and Political Rights_);
- [3.1.6.] Prevent any person from exercising his/her/their right to seek an effective remedy by a competent court or national tribunal (including domestic judicial systems, international courts, arbitration bodies, and other adjudicating bodies) for actions violating the fundamental rights granted to him/her/them by applicable constitutions, applicable laws, or by this License (See Article 8, _United Nations Universal Declaration of Human Rights_; Articles 9 and 14, _International Covenant on Civil and Political Rights_);
- [3.1.7.] Subject any person to arbitrary arrest, detention, or exile (See Article 9, _United Nations Universal Declaration of Human Rights_; Article 9, _International Covenant on Civil and Political Rights_);
- [3.1.8.] Subject any person to arbitrary interference with a persons privacy, family, home, or correspondence without the express written consent of the person (See Article 12, _United Nations Universal Declaration of Human Rights_; Article 17, _International Covenant on Civil and Political Rights_);
- [3.1.9.] Arbitrarily deprive any person of his/her/their property (See Article 17, _United Nations Universal Declaration of Human Rights_);
- [3.1.10.] Forcibly remove indigenous peoples from their lands or territories or take any action with the aim or effect of dispossessing indigenous peoples from their lands, territories, or resources, including without limitation the intellectual property or traditional knowledge of indigenous peoples, without the free, prior, and informed consent of indigenous peoples concerned (See Articles 8 and 10, _United Nations Declaration on the Rights of Indigenous Peoples_);
- [3.1.11.] _Fossil Fuel Divestment_: Be an individual or entity, or a representative, agent, affiliate, successor, attorney, or assign of an individual or entity, on the [FFI Solutions Carbon Underground 200 list](https://www.ffisolutions.com/research-analytics-index-solutions/research-screening/the-carbon-underground-200/?cn-reloaded=1);
- [3.1.12.] _Ecocide_: Commit ecocide (see original for full definition);
- [3.1.13.] _Extractive Industries_: Be an individual or entity, or a representative, agent, affiliate, successor, attorney, or assign of an individual or entity, that engages in fossil fuel or mineral exploration, extraction, development, or sale;
- [3.1.14.] _Boycott / Divestment / Sanctions_: Be an individual or entity, or a representative, agent, affiliate, successor, attorney, or assign of an individual or entity, identified by the Boycott, Divestment, Sanctions (“BDS”) movement on its website (<https://bdsmovement.net/> and <https://bdsmovement.net/get-involved/what-to-boycott>) as a target for boycott;
- [3.1.15.] _Taliban_: Be an individual or entity that engages in any commercial transactions with the Taliban or is a representative, agent, affiliate, successor, attorney, or assign of the Taliban;
- [3.1.16.] _Myanmar_: Be an individual or entity that engages in any commercial transactions with the Myanmar/Burmese military junta or is a representative, agent, affiliate, successor, attorney, or assign of the Myanmar/Burmese government;
- [3.1.17.] _US Tariff Act_: Be an individual or entity which U.S. Customs and Border Protection (CBP) has currently issued a Withhold Release Order (WRO) or finding against based on reasonable suspicion of forced labor, or is a representative, agent, affiliate, successor, attorney, or assign of such an entity;
- [3.1.18.] _Mass Surveillance_: Be a government agency or multinational corporation, or a representative, agent, affiliate, successor, attorney, or assign of a government or multinational corporation, which participates in mass surveillance programs;
- [3.1.19.] _Military Activities_: Be an entity or a representative, agent, affiliate, successor, attorney, or assign of an entity which conducts military activities;
- [3.1.20.] _Law Enforcement_: Be an individual or entity, or a representative, agent, affiliate, successor, attorney, or assign of an individual or entity, that provides good or services to, or otherwise enters into any commercial contracts with, any local, state, or federal law enforcement agency;
- [3.1.21.] _Media_: Be an individual or entity, or a representative, agent, affiliate, successor, attorney, or assign of an individual or entity, that broadcasts messages promoting killing, torture, or other forms of extreme violence;
- [3.1.22.] Interfere with Workers' free exercise of the right to organize and associate (See Article 20, United Nations Universal Declaration of Human Rights; C087 - Freedom of Association and Protection of the Right to Organise Convention, 1948 (No. 87), International Labour Organization; Article 8, International Covenant on Economic, Social and Cultural Rights);
- [3.1.23.] Harm the environment in a manner inconsistent with local, state, national, or international law.
#### [3.1.24.] **Artificial Intelligence (AI) and Dataset Prohibition:**
- The Licensee SHALL NOT use, copy, modify, reproduce, distribute, or prepare derivative works of the Software, in whole or in part, for the purpose of training, developing, operating, or deploying artificial intelligence (AI) or machine learning (ML) models, systems, or services, including but not limited to large language models, generative AI, or any automated decision-making systems.
- The Licensee SHALL NOT use, copy, reproduce, distribute, or otherwise process the Software, in whole or in part, as part of any dataset, corpus, or collection of data for the purpose of training, fine-tuning, evaluating, testing, benchmarking, or otherwise developing artificial intelligence (AI) or machine learning (ML) models, systems, or services.
- The Licensee SHALL NOT be an entity whose primary business is the development, deployment, or commercialization of AI or ML systems, or a representative, agent, affiliate, successor, attorney, or assign of such an entity.
- For the avoidance of doubt, any use, inclusion, reference, or incorporation of the Software, in whole or in part, in connection with the training, development, operation, evaluation, benchmarking, or deployment of any AI or ML model, system, or service is strictly prohibited, regardless of the materiality or intent of such use.
- The prohibitions of this section apply to any use of the Software in connection with, in support of, or as part of any process, workflow, or system involving AI or ML, whether or not the Software is directly incorporated into the AI/ML model or system.
- These prohibitions apply regardless of whether the use is for commercial, non-commercial, academic, research, or any other purpose.
- The prohibitions of this section apply to any use, in whole or in part, of the Software.
- For the purposes of this License, any output, result, or data generated by the Software, or derived from the Software, is subject to the same restrictions as the Software itself.
- These prohibitions apply to all uses of the Software, whether public, private, internal, or external, and regardless of whether the Software is distributed or made available to third parties.
#### [3.1.25.] **No Data Mining, Scraping, or Automated Acquisition:**
- The Licensee SHALL NOT use any automated means, including but not limited to bots, scrapers, crawlers, or any current or future automated, semi-automated, or programmatic method, tool, or technology, whether known or unknown at the time of this License, to access, copy, download, or otherwise acquire the Software or any part thereof for any purpose, including but not limited to AI/ML training, dataset creation, or data analysis.
#### [3.1.26.] **No Indirect Use or Circumvention:**
- The Licensee SHALL NOT use, or cause or permit others to use, the Software, in whole or in part, indirectly or through intermediaries, for any purpose prohibited by this License, including but not limited to inclusion in third-party datasets, corpora, or as part of any service or product that enables or facilitates AI/ML training or development.
- The Licensee SHALL take all reasonable and practical steps to ensure that any third party to whom the Software is provided, directly or indirectly, is made aware of and complies with the prohibitions of this License, including but not limited to the AI/ML, dataset, and data scraping prohibitions. The Licensee SHALL immediately cease distribution to, and take reasonable steps to prevent further use by, any third party found to be in violation of these prohibitions.
#### [3.1.27.] **Unethical Megacorporations and User Data Harvesting:**
- The Licensee SHALL NOT be, or be owned or controlled by, a corporation or entity that:
- (a) has been found by a court of competent jurisdiction, regulatory body, or credible investigative reporting to systematically harvest, exploit, or monetize user data without meaningful informed consent, or
- (b) is widely recognized as engaging in unethical data practices, including but not limited to Google (Alphabet), Microsoft, Amazon, Meta (Facebook), or their subsidiaries, or
- (c) is a representative, agent, affiliate, successor, attorney, or assign of such an entity.
- The named entities are provided as examples only and do not limit the scope of this prohibition. Any entity engaging in similar practices, as determined by a court of competent jurisdiction, regulatory body, or credible investigative reporting, is also covered.
#### [3.1.28.] **Platforms that Underpay Creators or Contributors:**
- The Licensee SHALL NOT be, or be owned or controlled by, a platform or service that:
- (a) derives significant revenue from the creative or productive work of individuals (such as musicians, artists, writers, or gig workers), and
- (b) has been credibly accused, through legal action, regulatory finding, or substantial evidence, of systematically underpaying, exploiting, or failing to fairly compensate those individuals for their contributions, including but not limited to Spotify, or
- (c) is a representative, agent, affiliate, successor, attorney, or assign of such an entity.
- The named entities are provided as examples only and do not limit the scope of this prohibition. Any entity engaging in similar practices, as determined by a court of competent jurisdiction, regulatory body, or credible investigative reporting, is also covered.
---
### [3.2.] The Licensee SHALL:
- [3.2.1.] _Social Auditing_: Only use social auditing mechanisms that adhere to Worker-Driven Social Responsibility Networks Statement of Principles (<https://wsr-network.org/what-is-wsr/statement-of-principles/>) over traditional social auditing mechanisms, to the extent the Licensee uses any social auditing mechanisms at all;
- [3.2.2.] _Workers on Board of Directors_: Ensure that if the Licensee has a Board of Directors, 30% of Licensees board seats are held by Workers paid no more than 200% of the compensation of the lowest paid Worker of the Licensee;
- [3.2.3.] _Supply Chain_: Provide clear, accessible supply chain data to the public in accordance with the following conditions:
- [3.2.3.1.] All data will be on Licensees website and/or, to the extent Licensee is a representative, agent, affiliate, successor, attorney, subsidiary, or assign, on Licensees principals or parents website or some other online platform accessible to the public via an internet search on a common internet search engine; and
- [3.2.3.2.] Data published will include, where applicable, manufacturers, top tier suppliers, subcontractors, cooperatives, component parts producers, and farms;
- [3.2.4.] Provide equal pay for equal work where the performance of such work requires equal skill, effort, and responsibility, and which are performed under similar working conditions, except where such payment is made pursuant to:
- [3.2.4.1.] A seniority system;
- [3.2.4.2.] A merit system;
- [3.2.4.3.] A system which measures earnings by quantity or quality of production; or
- [3.2.4.4.] A differential based on any other factor other than sex, gender, sexual orientation, race, ethnicity, nationality, religion, caste, age, medical disability or impairment, and/or any other like circumstances (See 29 U.S.C.A. § 206(d)(1); Article 23, _United Nations Universal Declaration of Human Rights_; Article 7, _International Covenant on Economic, Social and Cultural Rights_; Article 26, _International Covenant on Civil and Political Rights_);
- [3.2.5.] Allow for reasonable limitation of working hours and periodic holidays with pay (See Article 24, _United Nations Universal Declaration of Human Rights_; Article 7, _International Covenant on Economic, Social and Cultural Rights_).
---
### [3.3.] **Audit and Transparency Rights:**
- The Licensor reserves the right to request, and the Licensee agrees to provide within 30 days, reasonable documentation or evidence demonstrating compliance with all terms of this License, including but not limited to the prohibition on AI/ML use, dataset creation, and data scraping. Failure to provide requested documentation or evidence of compliance within 30 days shall constitute a material breach of this License and result in immediate termination of all rights granted hereunder.
### [3.4.] **Attribution and Notice Requirements:**
- Any copy, distribution, or derivative of the Software must include this License and a prominent notice stating:
_“Use of this software for AI/ML training, dataset creation, or automated data acquisition is strictly prohibited.”_
- Licensee must ensure that the License and all required notices are provided in a clear, conspicuous, and accessible manner at every point of distribution, download, or access, including but not limited to websites, repositories, and physical media.
### [3.5.] **Statutory Damages / Penalty Clause:**
- Any violation of the AI/ML prohibition, dataset prohibition, or data scraping prohibition in this License shall entitle the Licensor to seek statutory damages of up to $150,000 per violation, or the maximum allowed by applicable law, in addition to any other remedies available at law or in equity. Statutory damages are in addition to, and not in lieu of, any actual damages, injunctive relief, or other remedies available at law or in equity.
### [3.6.] **Retroactive Termination:**
- Any violation of the terms of this License, including but not limited to the AI/ML prohibition, dataset prohibition, or data scraping prohibition, shall result in the immediate and retroactive termination of all rights granted under this License.
### [3.7.] **Third-Party Beneficiary Clause:**
- All contributors to the Software and any Supply Chain Impacted Party are intended third-party beneficiaries of this License and shall have the right to enforce its terms.
---
## [4.] SUPPLY CHAIN IMPACTED PARTIES
_This section identifies additional individuals or entities that a Licensee could harm as a result of violating the Ethical Standards section, the condition that the Licensee must voluntarily accept a Duty of Care for those individuals or entities, and the right to a private right of action that those individuals or entities possess as a result of violations of the Ethical Standards section._
- **[4.1.]** In addition to the above Ethical Standards, Licensee voluntarily accepts a Duty of Care for Supply Chain Impacted Parties of this License, including individuals and communities impacted by violations of the Ethical Standards. The Duty of Care is breached when a provision within the Ethical Standards section is violated by a Licensee, one of its successors or assigns, or by an individual or entity that exists within the Supply Chain prior to a good or service reaching the Licensee.
- **[4.2.]** Breaches of the Duty of Care, as stated within this section, shall create a private right of action, allowing any Supply Chain Impacted Party harmed by the Licensee to take legal action against the Licensee in accordance with applicable negligence laws, whether they be in tort law, delict law, and/or similar bodies of law closely related to tort and/or delict law, regardless if Licensee is directly responsible for the harms suffered by a Supply Chain Impacted Party. Nothing in this section shall be interpreted to include acts committed by individuals outside of the scope of his/her/their employment.
---
## [5.] NOTICE
_This section explains when a Licensee must notify others of the License._
- **[5.1.] Distribution of Notice:**
Licensee must ensure that everyone who receives a copy of or uses any part of Software from Licensee, with or without changes, also receives the License and the copyright notice included with Software (and if included by the Licensor, patent, trademark, and attribution notice). Licensee must ensure that License is prominently displayed so that any individual or entity seeking to download, copy, use, or otherwise receive any part of Software from Licensee is notified of this License and its terms and conditions. Licensee must cause any modified versions of the Software to carry prominent notices stating that Licensee changed the Software. Licensee must ensure that the License and all required notices are provided in a clear, conspicuous, and accessible manner at every point of distribution, download, or access, including but not limited to websites, repositories, and physical media.
- **[5.2.] Modified Software:**
Licensee is free to create modifications of the Software and distribute only the modified portion created by Licensee, however, any derivative work stemming from the Software or its code must be distributed pursuant to this License, including this Notice provision.
- **[5.3.] Recipients as Licensees:**
Any individual or entity that uses, copies, modifies, reproduces, distributes, or prepares derivative work based upon the Software, all or part of the Softwares code, or a derivative work developed by using the Software, including a portion of its code, is a Licensee as defined above and is subject to the terms and conditions of this License.
---
## [6.] REPRESENTATIONS AND WARRANTIES
- **[6.1.] Disclaimer of Warranty:**
TO THE FULL EXTENT ALLOWED BY LAW, THIS SOFTWARE COMES “AS IS,” WITHOUT ANY WARRANTY, EXPRESS OR IMPLIED, AND LICENSOR SHALL NOT BE LIABLE TO ANY PERSON OR ENTITY FOR ANY DAMAGES OR OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THIS LICENSE, UNDER ANY LEGAL CLAIM.
- **[6.2.] Limitation of Liability:**
LICENSEE SHALL HOLD LICENSOR HARMLESS AGAINST ANY AND ALL CLAIMS, DEBTS, DUES, LIABILITIES, LIENS, CAUSES OF ACTION, DEMANDS, OBLIGATIONS, DISPUTES, DAMAGES, LOSSES, EXPENSES, ATTORNEYS' FEES, COSTS, LIABILITIES, AND ALL OTHER CLAIMS OF EVERY KIND AND NATURE WHATSOEVER, WHETHER KNOWN OR UNKNOWN, ANTICIPATED OR UNANTICIPATED, FORESEEN OR UNFORESEEN, ACCRUED OR UNACCRUED, DISCLOSED OR UNDISCLOSED, ARISING OUT OF OR RELATING TO LICENSEES USE OF THE SOFTWARE. NOTHING IN THIS SECTION SHOULD BE INTERPRETED TO REQUIRE LICENSEE TO INDEMNIFY LICENSOR, NOR REQUIRE LICENSOR TO INDEMNIFY LICENSEE.
---
## [7.] TERMINATION
- **[7.1.] Violations of Ethical Standards or Breaching Duty of Care:**
If Licensee violates the Ethical Standards section or Licensee, or any other person or entity within the Supply Chain prior to a good or service reaching the Licensee, breaches its Duty of Care to Supply Chain Impacted Parties, Licensee must remedy the violation or harm caused by Licensee within 30 days of being notified of the violation or harm. If Licensee fails to remedy the violation or harm within 30 days, all rights in the Software granted to Licensee by License will be null and void as between Licensor and Licensee.
- **[7.2.] Failure of Notice:**
If any person or entity notifies Licensee in writing that Licensee has not complied with the Notice section of this License, Licensee can keep this License by taking all practical steps to comply within 30 days after the notice of noncompliance. If Licensee does not do so, Licensees License (and all rights licensed hereunder) will end immediately.
- **[7.3.] Judicial Findings:**
In the event Licensee is found by a civil, criminal, administrative, or other court of competent jurisdiction, or some other adjudicating body with legal authority, to have committed actions which are in violation of the Ethical Standards or Supply Chain Impacted Party sections of this License, all rights granted to Licensee by this License will terminate immediately.
- **[7.4.] Patent Litigation:**
If Licensee institutes patent litigation against any entity (including a cross-claim or counterclaim in a suit) alleging that the Software, all or part of the Softwares code, or a derivative work developed using the Software, including a portion of its code, constitutes direct or contributory patent infringement, then any patent license, along with all other rights, granted to Licensee under this License will terminate as of the date such litigation is filed.
- **[7.5.] Additional Remedies:**
Termination of the License by failing to remedy harms in no way prevents Licensor or Supply Chain Impacted Party from seeking appropriate remedies at law or in equity.
---
## [8.] MISCELLANEOUS
- **[8.1.] Conditions:**
Sections 3, 4.1, 5.1, 5.2, 7.1, 7.2, 7.3, and 7.4 are conditions of the rights granted to Licensee in the License.
- **[8.2.] Equitable Relief:**
Licensor and any Supply Chain Impacted Party shall be entitled to equitable relief, including injunctive relief or specific performance of the terms hereof, in addition to any other remedy to which they are entitled at law or in equity.
- **[8.3.] Copyleft:**
All modified software, source code, or other derivative work must be licensed exclusively under the exact same conditions as this License, and may not be dual-licensed or sublicensed under any other terms.
- **[8.4.] Severability:**
If any term or provision of this License is determined to be invalid, illegal, or unenforceable by a court of competent jurisdiction, any such determination of invalidity, illegality, or unenforceability shall not affect any other term or provision of this License or invalidate or render unenforceable such term or provision in any other jurisdiction. If the determination of invalidity, illegality, or unenforceability by a court of competent jurisdiction pertains to the terms or provisions contained in the Ethical Standards section of this License, all rights in the Software granted to Licensee shall be deemed null and void as between Licensor and Licensee.
- **[8.5.] Section Titles:**
Section titles are solely written for organizational purposes and should not be used to interpret the language within each section.
- **[8.6.] Citations:**
Citations are solely written to provide context for the source of the provisions in the Ethical Standards.
- **[8.7.] Section Summaries:**
Some sections have a brief _italicized description_ which is provided for the sole purpose of briefly describing the section and should not be used to interpret the terms of the License.
- **[8.8.] Entire License:**
This is the entire License between the Licensor and Licensee with respect to the claims released herein and that the consideration stated herein is the only consideration or compensation to be paid or exchanged between them for this License. This License cannot be modified or amended except in a writing signed by Licensor and Licensee.
- **[8.9.] Successors and Assigns:**
This License shall be binding upon and inure to the benefit of the Licensors and Licensees respective heirs, successors, and assigns. Any transfer or assignment of rights under this License is void unless the transferee or assignee agrees in writing to be bound by all terms and conditions of this License.
- **[8.10.] Jurisdiction and Venue:**
Any dispute arising under or in connection with this License shall be resolved exclusively in the courts of **Romania**, and the parties consent to the personal jurisdiction and venue of such courts. In addition to the exclusive jurisdiction of the courts of Romania, the Licensor and Licensee agree that any judgment or order issued by such courts may be enforced in any jurisdiction in which the Licensee operates or has assets. Alternatively, at the Licensors sole discretion, any dispute may be resolved by binding arbitration under the rules of the International Chamber of Commerce.
- **[8.11.] No Waiver of Rights:**
Failure by the Licensor to enforce any provision of this License shall not constitute a waiver of the Licensors rights to enforce such provision or any other provision of this License. No waiver of any provision of this License shall be effective unless in writing and signed by the Licensor.

40
manifest.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "Monochrome Music",
"short_name": "Monochrome",
"description": "A minimalist music streaming application",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"orientation": "portrait-primary",
"icons": [
{
"src": "https://prigoana.com/favicon.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "https://prigoana.com/favicon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["music", "entertainment"],
"screenshots": [],
"shortcuts": [
{
"name": "Search",
"short_name": "Search",
"description": "Search for music",
"url": "/#search",
"icons": [
{
"src": "https://prigoana.com/favicon.png",
"sizes": "192x192"
}
]
}
]
}

1082
styles.css Normal file

File diff suppressed because it is too large Load diff

181
sw.js
View file

@ -1,166 +1,43 @@
const API_CACHE_VERSION = 'v3';
const IMAGE_CACHE_VERSION = 'v1';
const AUDIO_CACHE_VERSION = 'v1';
const STATIC_CACHE_VERSION = 'v1';
const CACHE_NAME = 'monochrome-v1';
const urlsToCache = [
'/',
'/index.html',
'/styles.css',
'/js/app.js',
'/js/api.js',
'/js/player.js',
'/js/storage.js',
'/js/ui.js',
'/js/utils.js',
'/js/cache.js',
'/manifest.json'
];
const API_CACHE = `monochrome-api-${API_CACHE_VERSION}`;
const IMAGE_CACHE = `monochrome-images-${IMAGE_CACHE_VERSION}`;
const AUDIO_CACHE = `monochrome-audio-${AUDIO_CACHE_VERSION}`;
const STATIC_CACHE = `monochrome-static-${STATIC_CACHE_VERSION}`;
const ALL_CACHES = [API_CACHE, IMAGE_CACHE, AUDIO_CACHE, STATIC_CACHE];
let cacheDuration = 'infinite';
let apiInstances = [];
const isApiRequest = (url) => apiInstances.some(instance => url.startsWith(instance));
const isImageRequest = (url) => url.startsWith('https://resources.tidal.com/') || url.startsWith('https://picsum.photos/');
const addTimestampToResponse = async (response) => {
const body = await response.blob();
const headers = new Headers(response.headers);
headers.set('sw-cache-timestamp', Date.now());
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: headers
});
};
const isCacheExpired = (response) => {
if (cacheDuration === 'infinite') return false;
const timestampHeader = response.headers.get('sw-cache-timestamp');
if (!timestampHeader) return true;
const timestamp = parseInt(timestampHeader, 10);
const maxAge = parseInt(cacheDuration, 10) * 24 * 60 * 60 * 1000;
return (Date.now() - timestamp) > maxAge;
};
const fetchAndCache = async (request, cacheName) => {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
let responseToCache = response.clone();
if (cacheName === API_CACHE) {
responseToCache = await addTimestampToResponse(response.clone());
}
await cache.put(request, responseToCache);
}
return response;
} catch (error) {
console.error(`[SW] Fetch failed for ${request.url}:`, error);
throw error;
}
};
self.addEventListener('install', (event) => {
self.addEventListener('install', event => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
return cache.addAll(['./', './index.html']);
}).then(() => {
self.skipWaiting();
})
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
self.addEventListener('activate', (event) => {
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then((cacheNames) => {
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map((cacheName) => {
if (!ALL_CACHES.includes(cacheName)) {
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
}).then(() => {
return self.clients.claim();
})
);
});
self.addEventListener('message', (event) => {
if (event.data.action === 'update_setting') {
if (event.data.key === 'cacheDuration') {
cacheDuration = event.data.value;
}
if (event.data.key === 'apiInstances') {
apiInstances = event.data.value;
}
} else if (event.data.action === 'clear_caches') {
event.waitUntil(
Promise.all(ALL_CACHES.map(cacheName => caches.delete(cacheName)))
.then(() => event.source.postMessage({ status: 'caches_cleared' }))
.catch(err => {
console.error('Cache clearing failed:', err);
event.source.postMessage({ status: 'cache_clear_failed', error: err.message });
})
);
}
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== 'GET') {
return;
}
if (isApiRequest(url.href)) {
event.respondWith(
caches.open(API_CACHE).then(async (cache) => {
const cachedResponse = await cache.match(request);
if (cachedResponse && !isCacheExpired(cachedResponse)) {
return cachedResponse;
}
return fetchAndCache(request, API_CACHE);
})
);
return;
}
if (isImageRequest(url.href)) {
event.respondWith(
caches.open(IMAGE_CACHE).then(async (cache) => {
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
return fetchAndCache(request, IMAGE_CACHE);
})
);
return;
}
if (request.headers.get('range')) {
event.respondWith(
caches.open(AUDIO_CACHE).then(async (cache) => {
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200) {
await cache.put(request, networkResponse.clone());
}
return networkResponse;
})
);
return;
}
event.respondWith(
caches.match(request).then((cachedResponse) => {
return cachedResponse || fetch(request).then(response => {
if (response.ok && (url.pathname.endsWith('.js') || url.pathname.endsWith('.css') || url.pathname.endsWith('.html') || url.pathname === '/')) {
const cache = caches.open(STATIC_CACHE);
cache.put(request, response.clone());
}
return response;
});
})
);
});