bandcamp.html raw
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
6 <meta name="apple-mobile-web-app-capable" content="yes">
7 <meta name="mobile-web-app-capable" content="yes">
8 <title>campion</title>
9 <style>
10 *{margin:0;padding:0;box-sizing:border-box}
11 :root{
12 --bg:#111;--fg:#eee;--dim:#888;--accent:#ff6b35;
13 --card:#1a1a1a;--border:#333;--hover:#252525;
14 }
15 html,body{height:100%;overflow:hidden}
16 body{
17 font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
18 background:var(--bg);color:var(--fg);
19 display:flex;flex-direction:column;
20 }
21 input[type=text]{
22 width:100%;padding:12px 16px;border:1px solid var(--border);
23 border-radius:8px;background:var(--card);color:var(--fg);
24 font-size:16px;outline:none;
25 }
26 input[type=text]:focus{border-color:var(--accent)}
27 input[type=text]::placeholder{color:var(--dim)}
28
29 .header{padding:12px 16px;border-bottom:1px solid var(--border);flex-shrink:0}
30 .header h1{font-size:18px;margin-bottom:8px;color:var(--accent)}
31
32 .content{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch}
33
34 .tabs{
35 display:flex;border-bottom:1px solid var(--border);flex-shrink:0;
36 }
37 .tab{
38 flex:1;padding:10px;text-align:center;font-size:13px;
39 color:var(--dim);cursor:pointer;border-bottom:2px solid transparent;
40 }
41 .tab.active{color:var(--accent);border-bottom-color:var(--accent)}
42
43 .results{padding:8px}
44 .result-item{
45 display:flex;align-items:center;padding:10px;
46 border-radius:8px;cursor:pointer;gap:12px;
47 }
48 .result-item:hover{background:var(--hover)}
49 .result-item.playing{background:var(--hover)}
50 .result-img{
51 width:48px;height:48px;border-radius:4px;object-fit:cover;
52 background:var(--card);flex-shrink:0;
53 }
54 .result-info{flex:1;min-width:0}
55 .result-title{font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
56 .result-artist{font-size:12px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
57 .result-album{font-size:11px;color:var(--dim);opacity:.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
58 .result-action{
59 width:32px;height:32px;border-radius:50%;border:none;
60 background:var(--accent);color:#fff;font-size:16px;cursor:pointer;
61 display:flex;align-items:center;justify-content:center;flex-shrink:0;
62 }
63
64 .empty{padding:40px 20px;text-align:center;color:var(--dim);font-size:14px}
65 .loading{padding:20px;text-align:center;color:var(--dim)}
66
67 .playlist-item{
68 display:flex;align-items:center;padding:10px;
69 border-radius:8px;cursor:pointer;gap:12px;
70 }
71 .playlist-item:hover{background:var(--hover)}
72 .playlist-item.active{background:var(--hover);border-left:3px solid var(--accent)}
73 .pl-remove{
74 width:28px;height:28px;border-radius:50%;border:1px solid var(--border);
75 background:transparent;color:var(--dim);font-size:14px;cursor:pointer;
76 display:flex;align-items:center;justify-content:center;flex-shrink:0;
77 }
78 .pl-remove:hover{color:#e44;border-color:#e44}
79
80 .player{
81 border-top:1px solid var(--border);background:var(--card);
82 padding:8px 16px;flex-shrink:0;
83 }
84 .player-top{display:flex;align-items:center;gap:12px;margin-bottom:6px}
85 .player-img{width:40px;height:40px;border-radius:4px;object-fit:cover;flex-shrink:0}
86 .player-info{flex:1;min-width:0}
87 .player-title{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
88 .player-artist{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
89 .player-controls{display:flex;align-items:center;gap:8px}
90 .player-btn{
91 width:36px;height:36px;border-radius:50%;border:none;
92 background:transparent;color:var(--fg);font-size:18px;cursor:pointer;
93 display:flex;align-items:center;justify-content:center;
94 }
95 .player-btn:hover{background:var(--hover)}
96 .play-btn{background:var(--accent) !important;width:40px;height:40px}
97 .play-btn:hover{opacity:.9;background:var(--accent) !important}
98
99 .progress-row{display:flex;align-items:center;gap:8px}
100 .progress-bar{
101 flex:1;height:4px;background:var(--border);border-radius:2px;
102 cursor:pointer;position:relative;
103 }
104 .progress-fill{
105 height:100%;background:var(--accent);border-radius:2px;
106 transition:width .1s linear;
107 }
108 .progress-time{font-size:10px;color:var(--dim);min-width:36px;text-align:center}
109
110 .player-links{
111 display:flex;gap:12px;justify-content:center;padding:4px 0 2px;
112 }
113 .player-links a{
114 font-size:10px;color:var(--accent);text-decoration:none;opacity:.8;
115 }
116 .player-links a:hover{opacity:1;text-decoration:underline}
117
118 .footer-note{
119 text-align:center;padding:6px;font-size:9px;color:#555;
120 border-top:1px solid var(--border);flex-shrink:0;
121 }
122
123 .hidden{display:none}
124 </style>
125 </head>
126 <body>
127
128 <div class="header">
129 <h1>Bandcamp Player</h1>
130 <input type="text" id="search-input" placeholder="search bandcamp..." autocomplete="off">
131 </div>
132
133 <div class="tabs">
134 <div class="tab active" data-tab="search">search</div>
135 <div class="tab" data-tab="playlist">playlist</div>
136 </div>
137
138 <div class="content" id="content">
139 <div id="search-view">
140 <div class="empty" id="search-empty">type to search bandcamp</div>
141 <div class="loading hidden" id="search-loading">searching...</div>
142 <div class="results" id="search-results"></div>
143 </div>
144 <div id="playlist-view" class="hidden">
145 <div class="empty" id="playlist-empty">playlist is empty — search and add tracks</div>
146 <div class="results" id="playlist-results"></div>
147 </div>
148 </div>
149
150 <div class="player" id="player" style="display:none">
151 <div class="player-top">
152 <img class="player-img" id="player-img" src="" alt="">
153 <div class="player-info">
154 <div class="player-title" id="player-title"></div>
155 <div class="player-artist" id="player-artist"></div>
156 </div>
157 <div class="player-controls">
158 <button class="player-btn" id="prev-btn">⏮</button>
159 <button class="player-btn play-btn" id="play-btn">▶</button>
160 <button class="player-btn" id="next-btn">⏭</button>
161 </div>
162 </div>
163 <div class="progress-row">
164 <span class="progress-time" id="time-current">0:00</span>
165 <div class="progress-bar" id="progress-bar">
166 <div class="progress-fill" id="progress-fill" style="width:0"></div>
167 </div>
168 <span class="progress-time" id="time-total">0:00</span>
169 </div>
170 <div class="player-links" id="player-links"></div>
171 </div>
172
173 <div class="footer-note">made because a paying youtube premium subscriber is being trolled</div>
174
175 <script>
176 (function(){
177 // Detect if running from file:// or hosted
178 // When hosted, use same-origin proxy paths
179 // When local (file:// or localhost without proxy), use corsproxy.io as fallback
180 var isHosted = location.protocol === 'https:' || location.protocol === 'http:';
181 var proxyBase = ''; // same-origin when hosted
182 var CORS_PROXY = 'https://corsproxy.io/?';
183
184 var playlist = JSON.parse(localStorage.getItem('campion-playlist') || '[]');
185 var currentIndex = -1;
186 var isPlaying = false;
187 var audio = new Audio();
188 var streamCache = {};
189 var searchTimeout = null;
190 var currentTab = 'search';
191 var lastSearchResults = [];
192
193 var $searchInput = document.getElementById('search-input');
194 var $searchResults = document.getElementById('search-results');
195 var $searchEmpty = document.getElementById('search-empty');
196 var $searchLoading = document.getElementById('search-loading');
197 var $playlistResults = document.getElementById('playlist-results');
198 var $playlistEmpty = document.getElementById('playlist-empty');
199 var $searchView = document.getElementById('search-view');
200 var $playlistView = document.getElementById('playlist-view');
201 var $player = document.getElementById('player');
202 var $playerLinks = document.getElementById('player-links');
203 var $playerImg = document.getElementById('player-img');
204 var $playerTitle = document.getElementById('player-title');
205 var $playerArtist = document.getElementById('player-artist');
206 var $playBtn = document.getElementById('play-btn');
207 var $prevBtn = document.getElementById('prev-btn');
208 var $nextBtn = document.getElementById('next-btn');
209 var $progressBar = document.getElementById('progress-bar');
210 var $progressFill = document.getElementById('progress-fill');
211 var $timeCurrent = document.getElementById('time-current');
212 var $timeTotal = document.getElementById('time-total');
213
214 // --- DOM helpers ---
215 function el(tag, cls, text) {
216 var e = document.createElement(tag);
217 if (cls) e.className = cls;
218 if (text !== undefined) e.textContent = text;
219 return e;
220 }
221
222 function artUrl(art_id, size) {
223 if (!art_id) return '';
224 return 'https://f4.bcbits.com/img/a' + art_id + '_' + (size || 3) + '.jpg';
225 }
226
227 function fmtTime(s) {
228 var m = Math.floor(s / 60);
229 var sec = Math.floor(s % 60);
230 return m + ':' + (sec < 10 ? '0' : '') + sec;
231 }
232
233 // --- Tabs ---
234 document.querySelectorAll('.tab').forEach(function(tab) {
235 tab.addEventListener('click', function() {
236 document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
237 tab.classList.add('active');
238 currentTab = tab.dataset.tab;
239 $searchView.classList.toggle('hidden', currentTab !== 'search');
240 $playlistView.classList.toggle('hidden', currentTab !== 'playlist');
241 if (currentTab === 'playlist') renderPlaylist();
242 });
243 });
244
245 // --- Search ---
246 $searchInput.addEventListener('input', function() {
247 clearTimeout(searchTimeout);
248 var q = $searchInput.value.trim();
249 if (!q) {
250 clearChildren($searchResults);
251 $searchEmpty.classList.remove('hidden');
252 $searchLoading.classList.add('hidden');
253 return;
254 }
255 $searchEmpty.classList.add('hidden');
256 $searchLoading.classList.remove('hidden');
257 searchTimeout = setTimeout(function() { doSearch(q); }, 500);
258 });
259
260 function doSearch(q) {
261 var body = JSON.stringify({
262 search_text: q,
263 search_filter: 't',
264 full_page: false,
265 fan_id: null
266 });
267
268 var p;
269 if (isHosted) {
270 // Use same-origin proxy
271 p = fetch('/bc-search', {
272 method: 'POST',
273 headers: {'content-type': 'application/json'},
274 body: body
275 });
276 } else {
277 // Fallback: CORS proxy
278 p = fetch(CORS_PROXY + encodeURIComponent('https://bandcamp.com/api/bcsearch_public_api/1/autocomplete_elastic'), {
279 method: 'POST',
280 headers: {'content-type': 'application/json'},
281 body: body
282 });
283 }
284
285 p.then(function(res) { return res.json(); })
286 .then(function(data) {
287 var results = (data.auto && data.auto.results) || [];
288 lastSearchResults = results;
289 renderSearchResults(results);
290 })
291 .catch(function() {
292 clearChildren($searchResults);
293 $searchResults.appendChild(el('div', 'empty', 'search failed \u2014 check connection'));
294 })
295 .finally(function() {
296 $searchLoading.classList.add('hidden');
297 });
298 }
299
300 function clearChildren(node) {
301 while (node.firstChild) node.removeChild(node.firstChild);
302 }
303
304 function renderSearchResults(results) {
305 clearChildren($searchResults);
306 if (!results.length) {
307 $searchResults.appendChild(el('div', 'empty', 'no results'));
308 return;
309 }
310 results.forEach(function(r, i) {
311 var row = el('div', 'result-item');
312
313 var img = document.createElement('img');
314 img.className = 'result-img';
315 img.src = artUrl(r.art_id, 3);
316 img.alt = '';
317 img.loading = 'lazy';
318 row.appendChild(img);
319
320 var info = el('div', 'result-info');
321 info.appendChild(el('div', 'result-title', r.name));
322 info.appendChild(el('div', 'result-artist', r.band_name));
323 if (r.album_name) info.appendChild(el('div', 'result-album', r.album_name));
324 row.appendChild(info);
325
326 var btn = el('button', 'result-action', '+');
327 btn.title = 'add to playlist';
328 btn.addEventListener('click', function(e) {
329 e.stopPropagation();
330 addToPlaylist(r);
331 });
332 row.appendChild(btn);
333
334 row.addEventListener('click', function() {
335 addToPlaylist(r);
336 playTrack(playlist.length - 1);
337 });
338
339 $searchResults.appendChild(row);
340 });
341 }
342
343 function addToPlaylist(r) {
344 if (playlist.some(function(t) { return t.id === r.id; })) return;
345 playlist.push({
346 id: r.id,
347 name: r.name,
348 artist: r.band_name,
349 album: r.album_name || '',
350 url: r.item_url_path,
351 img: artUrl(r.art_id, 3),
352 art_id: r.art_id
353 });
354 savePlaylist();
355 }
356
357 // --- Playlist ---
358 function renderPlaylist() {
359 clearChildren($playlistResults);
360 if (!playlist.length) {
361 $playlistEmpty.classList.remove('hidden');
362 return;
363 }
364 $playlistEmpty.classList.add('hidden');
365 playlist.forEach(function(t, i) {
366 var row = el('div', 'playlist-item' + (i === currentIndex ? ' active' : ''));
367
368 var img = document.createElement('img');
369 img.className = 'result-img';
370 img.src = t.img;
371 img.alt = '';
372 img.loading = 'lazy';
373 row.appendChild(img);
374
375 var info = el('div', 'result-info');
376 info.appendChild(el('div', 'result-title', t.name));
377 info.appendChild(el('div', 'result-artist', t.artist));
378 row.appendChild(info);
379
380 var rm = el('button', 'pl-remove', '\u00d7');
381 rm.title = 'remove';
382 (function(idx) {
383 rm.addEventListener('click', function(e) {
384 e.stopPropagation();
385 playlist.splice(idx, 1);
386 if (idx < currentIndex) currentIndex--;
387 else if (idx === currentIndex) { audio.pause(); currentIndex = -1; updatePlayerUI(); }
388 savePlaylist();
389 renderPlaylist();
390 });
391 })(i);
392 row.appendChild(rm);
393
394 (function(idx) {
395 row.addEventListener('click', function() { playTrack(idx); });
396 })(i);
397
398 $playlistResults.appendChild(row);
399 });
400 }
401
402 function savePlaylist() {
403 localStorage.setItem('campion-playlist', JSON.stringify(playlist));
404 }
405
406 // --- Player ---
407 function playTrack(idx) {
408 if (idx < 0 || idx >= playlist.length) return;
409 currentIndex = idx;
410 var track = playlist[idx];
411
412 $player.style.display = '';
413 $playerTitle.textContent = track.name;
414 $playerArtist.textContent = track.artist;
415 $playerImg.src = track.img;
416 $playBtn.textContent = '\u23F3';
417 updatePlayerLinks(track);
418
419 if (currentTab === 'playlist') renderPlaylist();
420
421 getStreamUrl(track.url).then(function(streamUrl) {
422 if (!streamUrl) throw new Error('no stream');
423 audio.src = streamUrl;
424 audio.play();
425 isPlaying = true;
426 $playBtn.textContent = '\u23F8';
427 updateMediaSession(track);
428 }).catch(function(e) {
429 $playBtn.textContent = '\u2715';
430 console.error('stream error:', e);
431 });
432 }
433
434 function getStreamUrl(trackUrl) {
435 var cached = streamCache[trackUrl];
436 if (cached && Date.now() - cached.ts < 3600000) {
437 return Promise.resolve(cached.url);
438 }
439
440 if (isHosted) {
441 // Use same-origin proxy
442 return fetch('/bc-stream?url=' + encodeURIComponent(trackUrl))
443 .then(function(res) { return res.json(); })
444 .then(function(data) {
445 if (data.url) {
446 streamCache[trackUrl] = { url: data.url, ts: Date.now() };
447 return data.url;
448 }
449 return null;
450 });
451 } else {
452 // Fallback: fetch track page through CORS proxy, extract stream URL client-side
453 return fetch(CORS_PROXY + encodeURIComponent(trackUrl))
454 .then(function(res) { return res.text(); })
455 .then(function(html) {
456 var match = html.match(/https:\/\/[a-z0-9]+\.bcbits\.com\/stream\/[^"&]+(?:&[^"&]+)*/);
457 if (!match) return null;
458 var url = match[0].replace(/&/g, '&');
459 streamCache[trackUrl] = { url: url, ts: Date.now() };
460 return url;
461 });
462 }
463 }
464
465 $playBtn.addEventListener('click', function() {
466 if (currentIndex < 0) return;
467 if (isPlaying) { audio.pause(); isPlaying = false; $playBtn.textContent = '\u25B6'; }
468 else { audio.play(); isPlaying = true; $playBtn.textContent = '\u23F8'; }
469 });
470
471 $prevBtn.addEventListener('click', function() {
472 if (!playlist.length) return;
473 playTrack(currentIndex <= 0 ? playlist.length - 1 : currentIndex - 1);
474 });
475
476 $nextBtn.addEventListener('click', function() {
477 if (!playlist.length) return;
478 playTrack(currentIndex >= playlist.length - 1 ? 0 : currentIndex + 1);
479 });
480
481 audio.addEventListener('ended', function() {
482 if (!playlist.length) return;
483 playTrack(currentIndex >= playlist.length - 1 ? 0 : currentIndex + 1);
484 });
485
486 audio.addEventListener('timeupdate', function() {
487 if (!audio.duration) return;
488 $progressFill.style.width = (audio.currentTime / audio.duration * 100) + '%';
489 $timeCurrent.textContent = fmtTime(audio.currentTime);
490 $timeTotal.textContent = fmtTime(audio.duration);
491 });
492
493 $progressBar.addEventListener('click', function(e) {
494 if (!audio.duration) return;
495 var rect = $progressBar.getBoundingClientRect();
496 audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
497 });
498
499 function updatePlayerUI() {
500 if (currentIndex < 0 || !playlist.length) {
501 $player.style.display = 'none';
502 }
503 }
504
505 function updatePlayerLinks(track) {
506 clearChildren($playerLinks);
507 if (!track.url) return;
508 // track URL: https://artist.bandcamp.com/track/name
509 // album URL: https://artist.bandcamp.com/album/name (from search data)
510 // discography: https://artist.bandcamp.com/music
511 var parts = track.url.split('/');
512 // parts = ["https:", "", "artist.bandcamp.com", "track", "name"]
513 var artistRoot = parts.slice(0, 3).join('/');
514
515 var buyLink = document.createElement('a');
516 buyLink.href = track.url;
517 buyLink.target = '_blank';
518 buyLink.rel = 'noopener';
519 buyLink.textContent = 'buy track';
520 $playerLinks.appendChild(buyLink);
521
522 if (track.album) {
523 // Derive album URL from artist root + album name slug
524 // The search API gives us item_url_root, and we stored item_url_path as url
525 // Album URL pattern: replace /track/X with /album/ALBUM
526 // But we don't have the album slug. Use the artist root + /music as fallback
527 // Actually, we can build it: the search result has album_id but not album_url
528 // Safest: link to artist page which shows all albums
529 var albumLink = document.createElement('a');
530 albumLink.href = artistRoot + '/music';
531 albumLink.target = '_blank';
532 albumLink.rel = 'noopener';
533 albumLink.textContent = 'album: ' + track.album;
534 $playerLinks.appendChild(albumLink);
535 }
536
537 var discoLink = document.createElement('a');
538 discoLink.href = artistRoot;
539 discoLink.target = '_blank';
540 discoLink.rel = 'noopener';
541 discoLink.textContent = 'discography';
542 $playerLinks.appendChild(discoLink);
543 }
544
545 function updateMediaSession(track) {
546 if (!('mediaSession' in navigator)) return;
547 navigator.mediaSession.metadata = new MediaMetadata({
548 title: track.name,
549 artist: track.artist,
550 album: track.album,
551 artwork: track.art_id ? [
552 { src: 'https://f4.bcbits.com/img/a' + track.art_id + '_2.jpg', sizes: '256x256', type: 'image/jpeg' },
553 { src: 'https://f4.bcbits.com/img/a' + track.art_id + '_5.jpg', sizes: '512x512', type: 'image/jpeg' }
554 ] : []
555 });
556 navigator.mediaSession.setActionHandler('play', function() { audio.play(); isPlaying = true; $playBtn.textContent = '\u23F8'; });
557 navigator.mediaSession.setActionHandler('pause', function() { audio.pause(); isPlaying = false; $playBtn.textContent = '\u25B6'; });
558 navigator.mediaSession.setActionHandler('previoustrack', function() { $prevBtn.click(); });
559 navigator.mediaSession.setActionHandler('nexttrack', function() { $nextBtn.click(); });
560 }
561 })();
562 </script>
563 </body>
564 </html>
565