Tag: free stock images

  • Bulk image search and download from free image websites

    Free Asset Downloader: A Single-File Tool for Grabbing Images, Music & SFX

    ToolsWeb DevContent Creation

    I Built a Free Asset Downloader for Images, Music & SFX (No Sign-ups Needed)

    A single self-contained HTML page that searches multiple free stock-asset APIs at once, lets you preview results, and batch-downloads whatever you pick — all client-side, no backend, no account required.

    Why I Made This

    Every time I needed a quick background photo, a bit of royalty-free music, or a UI sound effect for a project, I found myself bouncing between five different websites, creating accounts, and copy-pasting URLs. So I put together a small tool that does the searching for me: one search box, multiple free sources queried in parallel, and instant previews before you commit to downloading anything.

    What It Does

    • Multi-source search — searches several free, largely keyless APIs at once and merges the results into one grid.
    • Comma-separated queries — type dog, cat, fish and it runs each term as its own search, so you get a batch of unrelated assets in one go.
    • Live previews — play audio clips right in the results list before downloading, and see image thumbnails up front.
    • One-click and batch download — grab a single asset or select a pile of them and download everything at once.
    • Runs entirely in the browser — it’s one HTML file. No server, no build step, no install.

    Sources It Pulls From

    TypeSourceAPI key needed?
    ImagesOpenverse (CC-licensed photos & illustrations)No
    ImagesPixabay / PexelsOptional, for higher volume
    MusicWikimedia Commons AudioNo
    SFXWikimedia Commons AudioNo
    Audio (fallback)Bensound / Mixkit curated samplesNo
    Audio (optional)FreesoundOptional
    Note on matching: some sources (like Bensound/Mixkit) are small curated packs rather than real search engines, so they only appear as a labeled fallback when a query doesn’t match anything from the real search APIs — that way you always know whether a result actually matches what you searched for.

    How to Use It

    1. Open the HTML file in any browser — no installation needed.
    2. Pick a type: image, music, or SFX.
    3. Type one search term, or several separated by commas.
    4. Preview results, then download individually or in bulk.

    The Code

    It’s a single self-contained HTML file — just save it as asset_downloader.html and open it in your browser. No dependencies, no build tools.

    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Asset Downloader</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
    <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh}
    .app{max-width:1200px;margin:0 auto;padding:24px}
    h1{font-size:22px;font-weight:600;margin-bottom:4px;color:#f1f5f9}
    .subtitle{font-size:13px;color:#64748b;margin-bottom:24px}
    .search-panel{background:#1a1d27;border:1px solid #2d3148;border-radius:12px;padding:20px;margin-bottom:16px}
    .row{display:flex;gap:10px;margin-bottom:12px;flex-wrap:wrap;align-items:flex-start}
    .row:last-child{margin-bottom:0}
    textarea{flex:1;min-width:200px;background:#0f1117;border:1px solid #2d3148;border-radius:8px;color:#e2e8f0;padding:10px;font-size:13px;resize:vertical;min-height:52px;font-family:inherit}
    textarea::placeholder{color:#475569}
    textarea:focus{outline:none;border-color:#6366f1}
    select{background:#0f1117;border:1px solid #2d3148;border-radius:8px;color:#e2e8f0;padding:9px 12px;font-size:13px;cursor:pointer;height:40px}
    select:focus{outline:none;border-color:#6366f1}
    button{border-radius:8px;padding:9px 18px;font-size:13px;font-weight:500;cursor:pointer;border:none;transition:all .15s}
    .btn-primary{background:#6366f1;color:#fff;height:40px}
    .btn-primary:hover{background:#5457d4}
    .btn-primary:disabled{background:#2d3148;color:#475569;cursor:not-allowed}
    .btn-ghost{background:transparent;color:#94a3b8;border:1px solid #2d3148}
    .btn-ghost:hover{background:#1e2235;color:#e2e8f0}
    .btn-success{background:#065f46;color:#6ee7b7}
    .btn-success:hover{background:#047857}
    .api-section{background:#1a1d27;border:1px solid #2d3148;border-radius:12px;padding:16px;margin-bottom:16px}
    .api-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#94a3b8;user-select:none}
    .api-toggle:hover{color:#e2e8f0}
    .api-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:14px}
    .api-field label{font-size:11px;color:#64748b;display:block;margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
    .api-field input{width:100%;background:#0f1117;border:1px solid #2d3148;border-radius:6px;color:#e2e8f0;padding:8px 10px;font-size:12px;font-family:monospace}
    .api-field input:focus{outline:none;border-color:#6366f1}
    .api-field input::placeholder{color:#334155}
    .status-bar{background:#1a1d27;border:1px solid #2d3148;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:12px;color:#94a3b8;display:flex;align-items:center;gap:8px}
    .dot{width:8px;height:8px;border-radius:50%;background:#334155;flex-shrink:0}
    .dot.loading{background:#f59e0b;animation:pulse 1s infinite}
    .dot.done{background:#10b981}
    .dot.error{background:#ef4444}
    .results-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap;gap:8px}
    .results-header h2{font-size:15px;font-weight:500;color:#94a3b8}
    .selection-actions{display:flex;gap:8px;align-items:center}
    .count-badge{background:#1e2235;border:1px solid #2d3148;border-radius:6px;padding:3px 10px;font-size:12px;color:#94a3b8}
    .results-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;margin-bottom:100px}
    .card{background:#1a1d27;border:1px solid #2d3148;border-radius:10px;overflow:hidden;cursor:pointer;transition:border-color .15s,transform .1s;position:relative;display:flex;flex-direction:column}
    .card:hover{border-color:#6366f1;transform:translateY(-1px)}
    .card.selected{border-color:#6366f1;background:#1b1c36}
    .card-check{position:absolute;top:8px;right:8px;width:22px;height:22px;background:#6366f1;border-radius:5px;display:none;align-items:center;justify-content:center;font-size:13px;color:#fff;font-weight:700;z-index:5;pointer-events:none}
    .card.selected .card-check{display:flex}
    .img-wrap{width:100%;height:130px;background:#12111a;overflow:hidden;position:relative;flex-shrink:0}
    .img-wrap img{width:100%;height:100%;object-fit:cover;display:block;opacity:0;transition:opacity .4s}
    .img-wrap img.loaded{opacity:1}
    .img-placeholder{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:30px;color:#2d3148}
    /* PNG preview: checkerboard bg to show transparency */
    .png-wrap{width:100%;height:130px;background:
      repeating-conic-gradient(#1e1e2e 0% 25%,#2a2a3e 0% 50%)
      0 0/20px 20px;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative;flex-shrink:0}
    .png-wrap img{max-width:90%;max-height:110px;object-fit:contain;opacity:0;transition:opacity .3s}
    .png-wrap img.loaded{opacity:1}
    .icon-wrap{width:100%;height:130px;background:#12111a;display:flex;align-items:center;justify-content:center;flex-shrink:0}
    .icon-wrap img{width:72px;height:72px;object-fit:contain;opacity:0;transition:opacity .3s}
    .icon-wrap img.loaded{opacity:1}
    .audio-wrap{width:100%;height:130px;background:#12111a;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;flex-shrink:0}
    .play-btn{width:52px;height:52px;border-radius:50%;background:#6366f1;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,transform .1s}
    .play-btn:hover{background:#5457d4;transform:scale(1.06)}
    .play-btn:active{transform:scale(.95)}
    .play-icon{width:22px;height:22px;fill:#fff;margin-left:3px}
    .pause-icon{width:22px;height:22px;fill:#fff}
    .audio-dur{font-size:11px;color:#475569}
    .prog-bar{width:80%;height:4px;background:#2d3148;border-radius:2px;cursor:pointer;overflow:hidden}
    .prog-inner{height:100%;background:#6366f1;width:0%;pointer-events:none;transition:width .15s linear}
    .card-info{padding:10px;flex:1}
    .card-title{font-size:12px;font-weight:500;color:#e2e8f0;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .card-meta{font-size:11px;color:#475569}
    .card-source{display:inline-block;font-size:10px;background:#1e2235;border:1px solid #2d3148;border-radius:4px;padding:1px 6px;color:#64748b;margin-top:4px}
    /* PNG source badge – distinct purple tint */
    .card-source.png{background:#1e1b36;border-color:#4338a0;color:#a5b4fc}
    .tag-pills{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
    .pill{font-size:11px;padding:3px 10px;border-radius:20px;background:#1e2235;border:1px solid #2d3148;color:#64748b;cursor:pointer}
    .pill:hover{border-color:#6366f1;color:#a5b4fc}
    .error-msg{background:#1a0a0a;border:1px solid #7f1d1d;border-radius:8px;padding:10px 14px;font-size:12px;color:#fca5a5;margin-bottom:12px}
    .dl-bar{position:fixed;bottom:0;left:0;right:0;background:#0f1117;border-top:1px solid #2d3148;padding:14px 24px;display:flex;gap:10px;align-items:center;justify-content:flex-end;z-index:100}
    .dl-bar.hidden{display:none}
    .empty{text-align:center;padding:60px 20px;color:#475569;grid-column:1/-1}
    @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
    </style>
    </head>
    <body>
    <div class="app">
      <h1>🎨 Asset Downloader</h1>
      <p class="subtitle">Search images, PNGs, icons, music &amp; SFX from free sources. Multi-select → download as ZIP or save to folder.</p>
    
      <div class="search-panel">
        <div class="row">
          <textarea id="searchInput" placeholder='Comma-separated: fire, water, tech&#10;Or JSON: [{"query":"fire","type":"sfx"},{"query":"arrow","type":"png"}]' rows="2"></textarea>
          <select id="typeFilter">
            <option value="all">All types</option>
            <option value="image">🖼 Images</option>
            <option value="png">🟣 PNG (transparent)</option>
            <option value="icon">🔷 Icons</option>
            <option value="music">🎵 Music</option>
            <option value="sfx">🔊 SFX</option>
          </select>
          <button class="btn-primary" onclick="runSearch()" id="searchBtn">Search</button>
        </div>
        <div class="tag-pills" id="recentTags"></div>
      </div>
    
      <div class="api-section">
        <div class="api-toggle" onclick="toggleApi()">
          <span id="apiArrow">▶</span>
          <span>API keys — optional, enables more sources &amp; results</span>
        </div>
        <div id="apiPanel" style="display:none">
          <div class="api-grid">
            <div class="api-field"><label>Unsplash Access Key</label><input type="password" id="keyUnsplash" placeholder="paste key..."></div>
            <div class="api-field"><label>Pexels API Key</label><input type="password" id="keyPexels" placeholder="paste key..."></div>
            <div class="api-field"><label>Pixabay API Key</label><input type="password" id="keyPixabay" placeholder="paste key... (also used for PNGs)"></div>
            <div class="api-field"><label>Freesound API Key</label><input type="password" id="keyFreesound" placeholder="paste key..."></div>
          </div>
          <p style="font-size:11px;color:#475569;margin-top:10px">Keys stored in localStorage only. Never sent to any server. App always falls back to no-key sources.</p>
        </div>
      </div>
    
      <!-- PNG Sources info box -->
      <div style="background:#12111a;border:1px solid #2d3148;border-radius:10px;padding:14px 18px;margin-bottom:16px;font-size:12px;color:#64748b;line-height:1.7">
        <span style="color:#a5b4fc;font-weight:600">🟣 Free PNG Sources</span> &nbsp;·&nbsp;
        <strong style="color:#94a3b8">OpenClipart</strong> — free API, no key needed, clipart &amp; illustrations &nbsp;·&nbsp;
        <strong style="color:#94a3b8">Wikimedia Commons</strong> — free API, no key needed, huge transparent PNG library &nbsp;·&nbsp;
        <strong style="color:#94a3b8">Pixabay PNG filter</strong> — requires Pixabay API key above, high-quality photos + cutouts &nbsp;·&nbsp;
        <strong style="color:#94a3b8">Sticker.guru</strong> — free, no key, sticker-style PNGs
      </div>
      <div style="background:#12111a;border:1px solid #2d3148;border-radius:10px;padding:14px 18px;margin-bottom:16px;font-size:12px;color:#64748b;line-height:1.7">
        <span style="color:#6ee7b7;font-weight:600">🎵 Free Audio &amp; 🖼 Extra Image Sources</span> &nbsp;·&nbsp;
        <strong style="color:#94a3b8">Openverse</strong> — free API, no key needed, CC-licensed photos &amp; illustrations, query-matched &nbsp;·&nbsp;
        <strong style="color:#94a3b8">Wikimedia Commons Audio</strong> — free API, no key needed, real query-matched music &amp; SFX &nbsp;·&nbsp;
        <strong style="color:#94a3b8">Bensound / Mixkit samples</strong> — curated fallback, only used (and labeled) when no query match is found
      </div>
    
      <div id="statusBar" class="status-bar" style="display:none">
        <div class="dot" id="statusDot"></div>
        <span id="statusText">Searching...</span>
      </div>
      <div id="errorMsg" class="error-msg" style="display:none"></div>
    
      <div id="resultsSection" style="display:none">
        <div class="results-header">
          <h2 id="resultsCount">0 results</h2>
          <div class="selection-actions">
            <span class="count-badge" id="selectedCount">0 selected</span>
            <button class="btn-ghost" style="padding:5px 12px;font-size:12px" onclick="selectAll()">Select all</button>
            <button class="btn-ghost" style="padding:5px 12px;font-size:12px" onclick="clearSel()">Clear</button>
          </div>
        </div>
        <div class="results-grid" id="resultsGrid"></div>
      </div>
    
      <div class="dl-bar hidden" id="dlBar">
        <span style="font-size:13px;color:#94a3b8" id="dlCount">0 selected</span>
        <button class="btn-success" onclick="downloadZip()">⬇ Download ZIP</button>
        <button class="btn-ghost" onclick="saveToFolder()">📁 Save to Folder</button>
      </div>
    </div>
    
    <script>
    let results=[], selected=new Set(), activeAudio=null, activePlayBtn=null, activeIdx=-1;
    let recent=JSON.parse(localStorage.getItem('adl_recent')||'[]');
    
    function gk(k){return localStorage.getItem('adl_'+k)||document.getElementById(k)?.value?.trim()||''}
    function saveKeys(){['keyUnsplash','keyPexels','keyPixabay','keyFreesound'].forEach(k=>{const v=document.getElementById(k).value.trim();if(v)localStorage.setItem('adl_'+k,v)})}
    function loadKeys(){['keyUnsplash','keyPexels','keyPixabay','keyFreesound'].forEach(k=>{const v=localStorage.getItem('adl_'+k);if(v)document.getElementById(k).value=v})}
    
    let apiOpen=false;
    function toggleApi(){
      apiOpen=!apiOpen;
      document.getElementById('apiPanel').style.display=apiOpen?'block':'none';
      document.getElementById('apiArrow').textContent=apiOpen?'▼':'▶';
    }
    
    function parseInput(raw){
      raw=raw.trim();
      if(raw.startsWith('[')){try{return JSON.parse(raw)}catch(e){}}
      const t=document.getElementById('typeFilter').value;
      return raw.split(',').map(q=>q.trim()).filter(Boolean).map(q=>({query:q,type:t}));
    }
    
    function setStatus(state,msg){
      document.getElementById('statusBar').style.display='flex';
      document.getElementById('statusText').textContent=msg;
      document.getElementById('statusDot').className='dot '+(state==='loading'?'loading':state==='done'?'done':'error');
    }
    function showErr(m){const e=document.getElementById('errorMsg');e.style.display='block';e.textContent=m}
    function hideErr(){document.getElementById('errorMsg').style.display='none'}
    
    async function runSearch(){
      const raw=document.getElementById('searchInput').value;
      if(!raw.trim()){showErr('Enter search terms.');return}
      saveKeys();hideErr();stopAudio();
      results=[];selected=new Set();
      document.getElementById('resultsSection').style.display='none';
      document.getElementById('dlBar').classList.add('hidden');
      setStatus('loading','Searching...');
      document.getElementById('searchBtn').disabled=true;
      addRecent(raw);
      const queries=parseInput(raw);
      let all=[];
      for(const q of queries){
        const types=(!q.type||q.type==='all')?['image','png','icon','music','sfx']:[q.type];
        for(const t of types){
          try{const r=await fetchType(q.query,t);all=all.concat(r)}catch(e){console.warn(t,e)}
        }
      }
      results=all;
      renderResults();
      document.getElementById('searchBtn').disabled=false;
      setStatus('done',results.length+' assets found');
    }
    
    async function fetchType(q,type){
      let items=[];
      if(type==='image') items=await fetchImages(q);
      else if(type==='png')   items=await fetchPNGs(q);
      else if(type==='icon')  items=await fetchIcons(q);
      else if(type==='music'||type==='sfx') items=await fetchAudio(q,type);
      /* Rename every file to just the search query + counter.
         e.g. query "apple red" → apple_red.jpg, apple_red_2.jpg, apple_red_3.jpg */
      const base=san(q);
      items.forEach((item,i)=>{
        const ext=item.ext||'jpg';
        item.filename=i===0?`${base}.${ext}`:`${base}_${i+1}.${ext}`;
      });
      return items;
    }
    
    /* ── IMAGES ─────────────────────────── */
    async function fetchImages(q){
      const items=[];
      /* Openverse – free, no key, huge CC-licensed photo/illustration search that
         actually matches the query text. This is now the primary keyless source. */
      try{
        const d=await apiFetch(`https://api.openverse.org/v1/images/?q=${enc(q)}&page_size=12`);
        (d.results||[]).forEach(h=>{
          const ext=(h.url.split('.').pop()||'jpg').split('?')[0].slice(0,4);
          items.push({id:'ov_'+h.id,title:h.title||q,thumb:h.thumbnail||h.url,downloadUrl:h.url,source:'Openverse',type:'image',ext,filename:san(h.title||q)+'_openverse_'+h.id+'.'+ext});
        });
      }catch(e){console.warn('Openverse',e)}
      const pxk=gk('keyPixabay');
      if(pxk){
        try{
          const d=await apiFetch(`https://pixabay.com/api/?key=${pxk}&q=${enc(q)}&image_type=photo&per_page=9&safesearch=true`);
          (d.hits||[]).forEach(h=>items.push({id:'px_'+h.id,title:h.tags||q,thumb:h.previewURL,downloadUrl:h.largeImageURL,source:'Pixabay',type:'image',ext:'jpg',filename:san(q)+'_pixabay_'+h.id+'.jpg'}));
        }catch(e){}
      }
      const usk=gk('keyUnsplash');
      if(usk){
        try{
          const d=await apiFetch(`https://api.unsplash.com/search/photos?query=${enc(q)}&per_page=9`,{Authorization:'Client-ID '+usk});
          (d.results||[]).forEach(h=>items.push({id:'us_'+h.id,title:h.alt_description||q,thumb:h.urls.small,downloadUrl:h.urls.regular,source:'Unsplash',type:'image',ext:'jpg',filename:san(q)+'_unsplash_'+h.id+'.jpg'}));
        }catch(e){}
      }
      const pxlk=gk('keyPexels');
      if(pxlk){
        try{
          const d=await apiFetch(`https://api.pexels.com/v1/search?query=${enc(q)}&per_page=9`,{Authorization:pxlk});
          (d.photos||[]).forEach(h=>items.push({id:'pxl_'+h.id,title:h.alt||q,thumb:h.src.medium,downloadUrl:h.src.large,source:'Pexels',type:'image',ext:'jpg',filename:san(q)+'_pexels_'+h.id+'.jpg'}));
        }catch(e){}
      }
      /* Picsum is just random placeholder photos keyed by seed number — it does
         NOT search by keyword, so it never actually matches the query. Only use
         it as a last-resort filler when every real search above found nothing,
         and label it clearly so it's not mistaken for a real match. */
      if(items.length===0){
        const seed=encodeURIComponent(q);
        for(let i=0;i<6;i++){
          const s=seed+i;
          items.push({id:'picsum_'+s,title:q+' (random placeholder, not query-matched)',thumb:`https://picsum.photos/seed/${s}/400/300`,downloadUrl:`https://picsum.photos/seed/${s}/1200/900`,source:'Picsum (random)',type:'image',ext:'jpg',filename:san(q)+'_placeholder_'+i+'.jpg'});
        }
      }
      return items;
    }
    
    /* ── PNGs (transparent) ─────────────── */
    async function fetchPNGs(q){
      const items=[];
    
      /* 1. OpenClipart – free REST API, no key, SVG+PNG clipart */
      try{
        const d=await apiFetch(`https://openclipart.org/search/json/?query=${enc(q)}&amount=12&offset=0`);
        (d.payload||[]).forEach(clip=>{
          /* thumbnail is SVG-rendered PNG via their CDN */
          const thumb=clip.svg?.png_2400||clip.svg?.png_480||'';
          const dlUrl=clip.svg?.png_2400||clip.svg?.url||'';
          if(!thumb) return;
          items.push({
            id:'oc_'+clip.id,
            title:clip.title||q,
            thumb,
            downloadUrl:dlUrl,
            source:'OpenClipart',
            type:'png',
            ext: dlUrl.endsWith('.svg')?'svg':'png',
            filename:san(clip.title||q)+'_openclipart_'+clip.id+(dlUrl.endsWith('.svg')?'.svg':'.png'),
            isPng:true
          });
        });
      }catch(e){console.warn('OpenClipart',e)}
    
      /* 2. Wikimedia Commons – free MediaWiki API, no key */
      try{
        const url=`https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrnamespace=6&gsrsearch=${enc(q)}+filetype:png&gsrlimit=12&prop=imageinfo&iiprop=url|mime|size|thumburl&iiurlwidth=300&format=json&origin=*`;
        const d=await apiFetch(url);
        const pages=Object.values(d?.query?.pages||{});
        pages.forEach(p=>{
          const ii=p.imageinfo?.[0];
          if(!ii||ii.mime!=='image/png') return;
          items.push({
            id:'wm_'+p.pageid,
            title:p.title?.replace('File:','')||q,
            thumb:ii.thumburl||ii.url,
            downloadUrl:ii.url,
            source:'Wikimedia',
            type:'png',
            ext:'png',
            filename:san(p.title?.replace('File:','')||q)+'.png',
            isPng:true
          });
        });
      }catch(e){console.warn('Wikimedia',e)}
    
      /* 3. Pixabay PNG filter (transparent clipart/vector) – needs key */
      const pxk=gk('keyPixabay');
      if(pxk){
        try{
          const d=await apiFetch(`https://pixabay.com/api/?key=${pxk}&q=${enc(q)}&image_type=clipart&colors=transparent&per_page=10&safesearch=true`);
          (d.hits||[]).forEach(h=>{
            items.push({
              id:'pxpng_'+h.id,
              title:h.tags||q,
              thumb:h.previewURL,
              downloadUrl:h.largeImageURL,
              source:'Pixabay PNG',
              type:'png',
              ext:'png',
              filename:san(q)+'_pixabay_png_'+h.id+'.png',
              isPng:true
            });
          });
        }catch(e){console.warn('Pixabay PNG',e)}
      }
    
      /* 4. Sticker.guru – free, no-key, sticker PNG CDN via search */
      try{
        /* They expose a simple JSON endpoint used by their site */
        const d=await apiFetch(`https://sticker.guru/api/search?q=${enc(q)}&limit=8`);
        (d.results||d.stickers||[]).slice(0,8).forEach((s,i)=>{
          const thumb=s.thumbnail||s.url||s.image||'';
          const dlUrl=s.url||s.image||thumb;
          if(!thumb) return;
          items.push({
            id:'sg_'+i+'_'+q,
            title:s.title||q+' sticker',
            thumb,
            downloadUrl:dlUrl,
            source:'Sticker.guru',
            type:'png',
            ext:'png',
            filename:san(s.title||q)+'_sticker_'+i+'.png',
            isPng:true
          });
        });
      }catch(e){/* sticker.guru may 404 if endpoint changes – silent */}
    
      return items;
    }
    
    /* ── ICONS ─────────────────────────── */
    async function fetchIcons(q){
      const items=[];
      try{
        const d=await apiFetch(`https://api.iconify.design/search?query=${enc(q)}&limit=24`);
        (d.icons||[]).slice(0,20).forEach(icon=>{
          const [prefix,name]=icon.split(':');
          const thumbUrl=`https://api.iconify.design/${prefix}/${name}.svg?color=%236366f1`;
          const dlUrl=`https://api.iconify.design/${prefix}/${name}.svg`;
          items.push({id:'ify_'+icon,title:name+' ('+prefix+')',thumb:thumbUrl,downloadUrl:dlUrl,source:'Iconify',type:'icon',ext:'svg',filename:san(name)+'.svg'});
        });
      }catch(e){}
      return items;
    }
    
    /* ── AUDIO ─────────────────────────── */
    async function fetchAudio(q,subtype){
      const items=[];
      const fsk=gk('keyFreesound');
      if(fsk){
        try{
          const filter=subtype==='music'?'&filter=tag:music':'';
          const d=await apiFetch(`https://freesound.org/apiv2/search/text/?query=${enc(q)}${filter}&fields=id,name,previews,duration&token=${fsk}&page_size=10`);
          (d.results||[]).forEach(s=>{
            const prev=s.previews?.['preview-lq-mp3']||'';
            if(!prev)return;
            items.push({id:'fs_'+s.id,title:s.name,previewUrl:prev,downloadUrl:prev,source:'Freesound',type:subtype,ext:'mp3',duration:Math.round(s.duration),filename:san(s.name)+'.mp3'});
          });
        }catch(e){console.warn('Freesound',e)}
      }
    
      /* Wikimedia Commons audio – free, no key, and actually matches the query
         (searches ogg/mp3/wav/flac files whose title/description contains q) */
      try{
        const url=`https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrnamespace=6&gsrsearch=${enc(q)}+(filetype:ogg+OR+filetype:mp3+OR+filetype:wav+OR+filetype:flac)&gsrlimit=12&prop=imageinfo&iiprop=url|mime|size|duration&format=json&origin=*`;
        const d=await apiFetch(url);
        const pages=Object.values(d?.query?.pages||{});
        pages.forEach(p=>{
          const ii=p.imageinfo?.[0];
          if(!ii||!/^audio\//.test(ii.mime||'')) return;
          const ext=(ii.url.split('.').pop()||'mp3').toLowerCase();
          const title=p.title?.replace('File:','')||q;
          items.push({id:'wma_'+p.pageid,title,previewUrl:ii.url,downloadUrl:ii.url,source:'Wikimedia',type:subtype,ext,duration:ii.duration?Math.round(ii.duration):undefined,filename:san(title)+'.'+ext});
        });
      }catch(e){console.warn('Wikimedia audio',e)}
    
      /* Curated sample packs (Bensound/Mixkit) only kick in as a fallback when
         nothing above actually matched the query — and only if a track's own
         name loosely matches the search term, so they don't get mixed into
         unrelated queries. */
      if(items.length===0){
        const ql=q.toLowerCase();
        if(subtype==='music'){
          const tracks=[
            {t:'Ukulele',u:'https://www.bensound.com/bensound-music/bensound-ukulele.mp3'},
            {t:'Sunny',u:'https://www.bensound.com/bensound-music/bensound-sunny.mp3'},
            {t:'Acoustic Breeze',u:'https://www.bensound.com/bensound-music/bensound-acousticbreeze.mp3'},
            {t:'Creative Minds',u:'https://www.bensound.com/bensound-music/bensound-creativeminds.mp3'},
            {t:'Epic',u:'https://www.bensound.com/bensound-music/bensound-epic.mp3'},
          ];
          const matched=tracks.filter(t=>t.t.toLowerCase().includes(ql)||ql.includes(t.t.toLowerCase()));
          const pool=matched.length?matched:tracks;
          pool.forEach((t,i)=>items.push({id:'bs_'+i+'_'+san(q),title:'Bensound – '+t.t+(matched.length?'':' (sample, not query-matched)'),previewUrl:t.u,downloadUrl:t.u,source:'Bensound',type:'music',ext:'mp3',filename:'bensound_'+san(t.t)+'.mp3'}));
        }
        if(subtype==='sfx'){
          const sfx=[
            {t:'Notification Bell',u:'https://assets.mixkit.co/sfx/preview/mixkit-software-interface-start-2574.mp3'},
            {t:'Button Click',u:'https://assets.mixkit.co/sfx/preview/mixkit-select-click-1109.mp3'},
            {t:'Success Chime',u:'https://assets.mixkit.co/sfx/preview/mixkit-achievement-bell-600.mp3'},
            {t:'Alert Tone',u:'https://assets.mixkit.co/sfx/preview/mixkit-alarm-clock-beep-988.mp3'},
            {t:'Game Win',u:'https://assets.mixkit.co/sfx/preview/mixkit-winning-chimes-2015.mp3'},
            {t:'Swoosh',u:'https://assets.mixkit.co/sfx/preview/mixkit-fast-small-sweep-transition-166.mp3'},
          ];
          const matched=sfx.filter(s=>s.t.toLowerCase().includes(ql)||ql.includes(s.t.toLowerCase()));
          const pool=matched.length?matched:sfx;
          pool.forEach((s,i)=>items.push({id:'mx_'+i+'_'+san(q),title:'Mixkit – '+s.t+(matched.length?'':' (sample, not query-matched)'),previewUrl:s.u,downloadUrl:s.u,source:'Mixkit',type:'sfx',ext:'mp3',filename:'mixkit_'+san(s.t)+'.mp3'}));
        }
      }
      return items;
    }
    
    async function apiFetch(url,headers={}){
      const r=await fetch(url,{headers:{...headers}});
      if(!r.ok) throw new Error('HTTP '+r.status);
      return r.json();
    }
    
    /* ── RENDER ─────────────────────────── */
    function renderResults(){
      const grid=document.getElementById('resultsGrid');
      grid.innerHTML='';
      document.getElementById('resultsSection').style.display='block';
      document.getElementById('resultsCount').textContent=results.length+' results';
      if(!results.length){
        grid.innerHTML='<div class="empty"><p>No results. Try different terms or add API keys for more sources.</p></div>';
        return;
      }
      results.forEach((item,idx)=>{
        const card=document.createElement('div');
        card.className='card';
        card.dataset.idx=idx;
        let preview='';
        if(item.type==='png'){
          /* checkerboard bg to reveal transparency */
          preview=`<div class="png-wrap">
            <img src="${esc(item.thumb)}" loading="lazy" decoding="async"
              onload="this.classList.add('loaded')"
              onerror="this.outerHTML='<span style=\'font-size:36px\'>🖼️</span>'"
              alt="${esc(item.title)}">
          </div>`;
        } else if(item.type==='image'){
          preview=`<div class="img-wrap">
            <div class="img-placeholder">🖼</div>
            <img src="${esc(item.thumb)}" loading="lazy" decoding="async"
              onload="this.classList.add('loaded');this.previousElementSibling.style.display='none'"
              onerror="this.remove()"
              alt="${esc(item.title)}">
          </div>`;
        } else if(item.type==='icon'){
          preview=`<div class="icon-wrap">
            <img src="${esc(item.thumb)}" loading="lazy" decoding="async"
              onload="this.classList.add('loaded')"
              onerror="this.outerHTML='<span style=\'font-size:40px;color:#6366f1\'>🔷</span>'"
              alt="${esc(item.title)}" style="width:72px;height:72px">
          </div>`;
        } else {
          const dur=item.duration?fmtDur(item.duration):'';
          preview=`<div class="audio-wrap">
            <button class="play-btn" onclick="togglePlay(event,${idx})" aria-label="Play preview">
              <svg class="play-icon" viewBox="0 0 24 24"><polygon points="6,3 20,12 6,21" fill="white"/></svg>
              <svg class="pause-icon" viewBox="0 0 24 24" style="display:none"><rect x="5" y="3" width="4" height="18" fill="white"/><rect x="15" y="3" width="4" height="18" fill="white"/></svg>
            </button>
            ${dur?`<div class="audio-dur">${dur}</div>`:''}
            <div class="prog-bar" onclick="seekAudio(event,${idx})">
              <div class="prog-inner" id="prog_${idx}"></div>
            </div>
          </div>`;
        }
        const sourceCls=item.isPng?'card-source png':'card-source';
        card.innerHTML=`
          <div class="card-check">✓</div>
          ${preview}
          <div class="card-info">
            <div class="card-title" title="${esc(item.title)}">${esc(item.title)}</div>
            <div class="card-meta" title="${esc(item.filename||'')}">${(item.ext||'').toUpperCase()}${item.isPng?' · transparent':''}<br><span style="color:#6366f1;font-size:10px;word-break:break-all">${esc((item.filename||'').slice(0,32))}${(item.filename||'').length>32?'…':''}</span></div>
            <span class="${sourceCls}">${item.source}</span>
          </div>`;
        card.addEventListener('click',e=>{
          if(e.target.closest('.play-btn')||e.target.closest('.prog-bar')) return;
          toggleSel(idx,card);
        });
        grid.appendChild(card);
      });
      updateUI();
    }
    
    /* ── AUDIO PLAYBACK ─────────────────── */
    function togglePlay(e,idx){
      e.stopPropagation();
      const item=results[idx];
      if(!item?.previewUrl) return;
      const btn=e.currentTarget;
      if(activeIdx===idx&&activeAudio){
        if(activeAudio.paused){activeAudio.play();setPlayState(btn,true)}
        else{activeAudio.pause();setPlayState(btn,false)}
        return;
      }
      stopAudio();
      const audio=new Audio(item.previewUrl);
      audio.preload='auto';
      audio.addEventListener('timeupdate',()=>{
        if(!audio.duration) return;
        const bar=document.getElementById('prog_'+idx);
        if(bar) bar.style.width=(audio.currentTime/audio.duration*100).toFixed(2)+'%';
      });
      audio.addEventListener('ended',()=>{setPlayState(btn,false);const bar=document.getElementById('prog_'+idx);if(bar)bar.style.width='0%';activeIdx=-1});
      audio.addEventListener('error',()=>{setPlayState(btn,false);activeIdx=-1});
      audio.play().then(()=>setPlayState(btn,true)).catch(()=>setPlayState(btn,false));
      activeAudio=audio;activePlayBtn=btn;activeIdx=idx;
    }
    function seekAudio(e,idx){
      if(!activeAudio||activeIdx!==idx) return;
      const rect=e.currentTarget.getBoundingClientRect();
      activeAudio.currentTime=((e.clientX-rect.left)/rect.width)*activeAudio.duration;
    }
    function stopAudio(){
      if(activeAudio){activeAudio.pause();activeAudio.src='';activeAudio=null}
      if(activePlayBtn){setPlayState(activePlayBtn,false);activePlayBtn=null}
      document.querySelectorAll('.prog-inner').forEach(b=>b.style.width='0%');
      activeIdx=-1;
    }
    function setPlayState(btn,playing){
      const pi=btn.querySelector('.play-icon'),pa=btn.querySelector('.pause-icon');
      if(pi)pi.style.display=playing?'none':'block';
      if(pa)pa.style.display=playing?'block':'none';
    }
    
    /* ── SELECTION ──────────────────────── */
    function toggleSel(idx,card){selected.has(idx)?selected.delete(idx):selected.add(idx);card.classList.toggle('selected',selected.has(idx));updateUI()}
    function selectAll(){results.forEach((_,i)=>selected.add(i));rerenderSel()}
    function clearSel(){selected.clear();rerenderSel()}
    function rerenderSel(){document.querySelectorAll('.card').forEach(c=>c.classList.toggle('selected',selected.has(+c.dataset.idx)));updateUI()}
    function updateUI(){
      const n=selected.size;
      document.getElementById('selectedCount').textContent=n+' selected';
      document.getElementById('dlCount').textContent=n+' selected';
      document.getElementById('dlBar').classList.toggle('hidden',n===0);
    }
    
    /* ── DOWNLOAD ───────────────────────── */
    async function downloadZip(){
      const items=[...selected].map(i=>results[i]);
      if(!items.length) return;
      setStatus('loading','Packaging '+items.length+' files into ZIP...');
      const zip=new JSZip(),seen={};
      for(const item of items){
        try{
          let fn=item.filename||san(item.title)+'.'+item.ext;
          if(seen[fn]){seen[fn]++;fn=fn.replace(/(\.\w+)$/,'_'+seen[fn]+'$1')}else seen[fn]=1;
          const r=await fetch(item.downloadUrl,{mode:'cors'});
          if(!r.ok) throw new Error('HTTP '+r.status);
          zip.file(fn,await r.blob());
        }catch(er){console.warn('Skipped',item.title,er.message)}
      }
      const blob=await zip.generateAsync({type:'blob',compression:'DEFLATE'});
      const a=document.createElement('a');
      a.href=URL.createObjectURL(blob);
      a.download='assets_'+Date.now()+'.zip';
      a.click();
      setStatus('done','ZIP downloaded!');
    }
    async function saveToFolder(){
      if(!window.showDirectoryPicker){showErr('Save to Folder requires Chrome/Edge. Use ZIP download instead.');return}
      const items=[...selected].map(i=>results[i]);
      if(!items.length) return;
      try{
        const dir=await window.showDirectoryPicker({mode:'readwrite'});
        setStatus('loading','Saving '+items.length+' files...');
        const seen={};
        for(const item of items){
          try{
            let fn=item.filename||san(item.title)+'.'+item.ext;
            if(seen[fn]){seen[fn]++;fn=fn.replace(/(\.\w+)$/,'_'+seen[fn]+'$1')}else seen[fn]=1;
            const r=await fetch(item.downloadUrl,{mode:'cors'});
            if(!r.ok) throw new Error('HTTP '+r.status);
            const fh=await dir.getFileHandle(fn,{create:true});
            const w=await fh.createWritable();
            await w.write(await r.blob());
            await w.close();
          }catch(er){console.warn('Skipped',item.title,er.message)}
        }
        setStatus('done','Files saved!');
      }catch(er){if(er.name!=='AbortError')showErr('Folder save failed: '+er.message)}
    }
    
    /* ── HELPERS ────────────────────────── */
    function san(s){return (s||'file').replace(/[^a-zA-Z0-9_\-]/g,'_').slice(0,40)}
    function enc(s){return encodeURIComponent(s||'')}
    function esc(s){return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
    function fmtDur(s){const m=Math.floor(s/60);return m+':'+(s%60<10?'0':'')+Math.round(s%60)}
    function addRecent(q){recent=recent.filter(r=>r!==q);recent.unshift(q);recent=recent.slice(0,8);localStorage.setItem('adl_recent',JSON.stringify(recent));renderRecent()}
    function renderRecent(){
      const c=document.getElementById('recentTags');c.innerHTML='';
      recent.slice(0,6).forEach(q=>{const p=document.createElement('span');p.className='pill';p.textContent=q.length>30?q.slice(0,28)+'…':q;p.onclick=()=>{document.getElementById('searchInput').value=q;runSearch()};c.appendChild(p)});
    }
    
    loadKeys();renderRecent();
    document.getElementById('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&(e.ctrlKey||e.metaKey))runSearch()});
    ['keyUnsplash','keyPexels','keyPixabay','keyFreesound'].forEach(k=>document.getElementById(k).addEventListener('change',saveKeys));
    </script>
    </body>
    </html>
    

    Copy the code above into a file named asset_downloader.html, or grab the file directly if you downloaded it from this post.

    What’s Next

    Planning to add a few more free, keyless sources over time, plus better duplicate detection across sources. If you have a favorite free asset API that doesn’t require sign-up, let me know in the comments.