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">&#x23EE;</button>
 159        <button class="player-btn play-btn" id="play-btn">&#x25B6;</button>
 160        <button class="player-btn" id="next-btn">&#x23ED;</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\/[^"&]+(?:&amp;[^"&]+)*/);
 457            if (!match) return null;
 458            var url = match[0].replace(/&amp;/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