Action

music launcher

Posted by @jsamlarose, Last update about 22 hours ago

A launcher for a list of URLs organised under Markdown headings. The first heading in the list is unfolded… entries under other headings are folded by default. The interface also parses #tags and offers the ability to filter the visible list by those tags, with a search bar for filtering by item titles.

I’m using it primarily as a launcher for music— I listen to music a lot while working, drawn from all the different platforms, and I’m using this as a) a way to keep track of things I’ve discovered recently that I don’t want to lose sight of, and b) an interface to surface music by mood regardless of platform. That said, I imagine it might be useful for any organised list of URLs you might need to access regularly?

Also works for URL schemes, so you could use it as an app/file launcher to pull together links to assets in different applications (if those applications support direct links)?

Update this line and point it to the source draft for your launcher:

const LAUNCHER_DRAFT_TITLE = “::music launcher”;

Oh, and you might want to tweak the font families if you don’t have Hoefler or Roboto Mono installed.

Full screen interface. Hit the x in the top righthand corner to cancel.

See the forum post for a sample source draft.

Steps

  • script

    // ========================================
    // URL LAUNCHER SCRIPT
    // Creates a filterable launcher for URLs from markdown links
    // Supports inline tags for filtering by mood/vibe
    // ========================================
    
    // ---------- CONFIG ----------
    const LAUNCHER_DRAFT_TITLE = "::music launcher";
    const TITLE_FONT = "'Hoefler Text','Baskerville','Garamond','Times New Roman',serif";
    const MONO_FONT = "'Roboto Mono','SF Mono',ui-monospace,Menlo,Consolas,monospace";
    
    // ---------- LOAD LAUNCHER DATA ----------
    const launcherDrafts = Draft.queryByTitle(LAUNCHER_DRAFT_TITLE);
    const launcherDraft = launcherDrafts?.[0];
    
    if (!launcherDraft) {
      alert(`No launcher draft found. Please create a draft titled '${LAUNCHER_DRAFT_TITLE}'`);
      context.fail();
    }
    
    // ---------- PARSE MARKDOWN ----------
    const lines = launcherDraft.content.split("\n");
    const sections = [];
    let currentH1 = null;
    let currentH2 = null;
    const allTags = {};
    
    const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/;
    const tagRegex = /#(\w+)/g;
    
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i].trim();
      
      if (line.startsWith("# ")) {
        currentH1 = line.substring(2).trim();
        currentH2 = null;
      } else if (line.startsWith("## ")) {
        currentH2 = line.substring(3).trim();
        sections.push({
          h1: currentH1,
          h2: currentH2,
          items: []
        });
      } else if (currentH2 && (line.startsWith("+ [") || line.startsWith("- ["))) {
        const linkMatch = line.match(linkRegex);
        if (linkMatch) {
          const label = linkMatch[1];
          const url = linkMatch[2];
          
          const tags = [];
          let tagMatch;
          tagRegex.lastIndex = 0;
          while ((tagMatch = tagRegex.exec(line)) !== null) {
            const tag = tagMatch[1].toLowerCase();
            tags.push(tag);
            allTags[tag] = (allTags[tag] || 0) + 1;
          }
          
          sections[sections.length - 1].items.push({ label, url, tags });
        }
      }
    }
    
    // Sort tags by frequency
    const sortedTags = Object.keys(allTags).sort((a, b) => allTags[b] - allTags[a]);
    
    // ---------- HELPERS ----------
    function esc(s) { 
      return (s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 
    }
    
    function isAppURL(url) {
      return !url.startsWith("http://") && !url.startsWith("https://");
    }
    
    // ---------- CSS ----------
    const css = `
    *{box-sizing:border-box;margin:0;padding:0}
    body{background:#000;color:#fff;font-family:${TITLE_FONT};font-size:16px;line-height:1.6;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
    ::selection{background:rgba(255,255,255,0.12)}
    .page{max-width:700px;margin:0 auto;padding:60px 40px 100px;min-height:100vh;position:relative}
    @media(max-width:640px){.page{padding:40px 30px 80px}}
    .close-btn{position:fixed;top:20px;right:20px;width:36px;height:36px;border-radius:50%;background:#1a1a1a;border:1px solid #333;color:#e06a70;font-size:20px;line-height:34px;text-align:center;cursor:pointer;transition:all .2s ease;z-index:1000}
    .close-btn:hover{background:#e06a70;color:#000;border-color:#e06a70;transform:rotate(90deg)}
    .header{margin-bottom:30px;padding-bottom:20px;border-bottom:1px solid #444}
    .h1{font-size:18px;font-weight:500;letter-spacing:3px;text-transform:uppercase;margin-bottom:10px;color:#fff}
    .subtitle{font-size:13px;color:#888;font-style:italic;margin-bottom:20px}
    .search-container{position:sticky;top:0;background:#000;padding:16px 0 20px;z-index:100;border-bottom:1px solid #222;margin-bottom:20px}
    .search-bar{width:100%;padding:14px 18px;background:#0a0a0a;border:1px solid #333;border-radius:8px;color:#fff;font-size:15px;font-family:${TITLE_FONT};transition:all .2s ease}
    .search-bar:focus{outline:none;border-color:#5AAE91;background:#111}
    .search-bar::placeholder{color:#555}
    .tag-pills{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:30px}
    .tag-pill{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:#0a0a0a;border:1px solid #222;border-radius:16px;font-size:12px;color:#888;cursor:pointer;transition:all .2s ease;font-family:${MONO_FONT}}
    .tag-pill:hover{border-color:#444;color:#aaa}
    .tag-pill.active{background:#5AAE91;border-color:#5AAE91;color:#000}
    .tag-count{opacity:0.6;font-size:11px}
    .section{margin-bottom:30px}
    .section.hidden{display:none}
    .section-header{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:16px;padding-bottom:10px;border-bottom:1px solid #222;cursor:pointer;transition:border-color .2s ease}
    .section-header:hover{border-bottom-color:#444}
    .section-title{font-size:15px;font-weight:500;letter-spacing:1px;text-transform:uppercase;color:#aaa}
    .section-count{font-size:13px;color:#555;font-family:${MONO_FONT}}
    .section-content{max-height:0;overflow:hidden;transition:max-height .3s ease}
    .section.open .section-content{max-height:2000px}
    .h1-label{font-size:11px;letter-spacing:1.2px;text-transform:uppercase;color:#666;margin-bottom:20px;padding-top:10px}
    .launcher-items{display:flex;flex-direction:column;gap:10px}
    .launcher-item{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:#0a0a0a;border:1px solid #1a1a1a;border-radius:6px;cursor:pointer;transition:all .2s ease;text-decoration:none;color:#f5f5f5}
    .launcher-item:hover{background:#1a1a1a;border-color:#333;transform:translateX(4px)}
    .launcher-item.hidden{display:none}
    .launcher-label{font-size:15px;font-weight:400;flex:1}
    .item-tags{display:flex;gap:6px;flex-wrap:wrap}
    .item-tag{font-size:10px;padding:3px 8px;background:#111;border:1px solid #222;border-radius:10px;color:#666;font-family:${MONO_FONT}}
    .no-results{text-align:center;padding:40px 20px;color:#666;font-style:italic;display:none}
    .no-results.visible{display:block}
    `;
    
    // ---------- BUILD HTML ----------
    const totalItems = sections.reduce((sum, sec) => sum + sec.items.length, 0);
    
    let htmlParts = [`<!DOCTYPE html><html><head><meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'><style>${css}</style></head><body>
    <div class='page'>
    <div class='close-btn' onclick='Drafts.cancel()'>×</div>
    <div class='header'>
    <div class='h1'>Launcher</div>
    <div class='subtitle'>${totalItems} items · ${sections.length} sections</div>
    <div class='search-container'>
    <input type='text' class='search-bar' id='searchBar' placeholder='Type to filter...' autocomplete='off' />
    </div>`];
    
    // Tag pills
    if (sortedTags.length > 0) {
      htmlParts.push("<div class='tag-pills' id='tagPills'>");
      sortedTags.forEach(tag => {
        const count = allTags[tag];
        htmlParts.push(`<div class='tag-pill' data-tag='${esc(tag)}' onclick='toggleTag("${esc(tag)}")'>#${esc(tag)}<span class='tag-count'>${count}</span></div>`);
      });
      htmlParts.push("</div>");
    }
    
    htmlParts.push("</div>");
    
    // Group sections by H1
    const h1Groups = {};
    sections.forEach(sec => {
      if (!h1Groups[sec.h1]) h1Groups[sec.h1] = [];
      h1Groups[sec.h1].push(sec);
    });
    
    const h1Keys = Object.keys(h1Groups);
    let firstSection = true;
    
    h1Keys.forEach((h1, h) => {
      const secs = h1Groups[h1];
      
      if (h1 && h1 !== "null") {
        htmlParts.push(`<div class='h1-label'>${esc(h1)}</div>`);
      }
      
      secs.forEach((sec, s) => {
        if (sec.items.length === 0) return;
        
        const secId = `sec-${h}-${s}`;
        const openClass = firstSection ? " open" : "";
        firstSection = false;
        
        htmlParts.push(`<div class='section${openClass}' id='${secId}'>
    <div class='section-header' onclick='toggleSection("${secId}")'>
    <div class='section-title'>${esc(sec.h2)}</div>
    <div class='section-count'>${sec.items.length}</div>
    </div>
    <div class='section-content'>
    <div class='launcher-items'>`);
        
        sec.items.forEach((item, i) => {
          const itemId = `${secId}-item-${i}`;
          const isApp = isAppURL(item.url);
          const tagsData = item.tags.join(" ");
          
          if (isApp) {
            htmlParts.push(`<a href='${esc(item.url)}' class='launcher-item' id='${itemId}' `);
          } else {
            const escapedURL = item.url.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"');
            htmlParts.push(`<a href='#' class='launcher-item' id='${itemId}' onclick="event.preventDefault();openWebURL('${escapedURL}');return false;" `);
          }
          
          htmlParts.push(`data-label='${esc(item.label).toLowerCase()}' data-tags='${esc(tagsData)}' data-section='${secId}'>
    <div class='launcher-label'>${esc(item.label)}</div>`);
          
          if (item.tags.length > 0) {
            htmlParts.push("<div class='item-tags'>");
            item.tags.forEach(tag => {
              htmlParts.push(`<span class='item-tag'>#${esc(tag)}</span>`);
            });
            htmlParts.push("</div>");
          }
          
          htmlParts.push("</a>");
        });
        
        htmlParts.push("</div></div></div>");
      });
    });
    
    htmlParts.push(`<div class='no-results' id='noResults'>No matching items found</div>
    </div>
    <script>
    var activeTags=new Set();
    function toggleSection(id){var sec=document.getElementById(id);if(sec)sec.classList.toggle('open');}
    function openWebURL(url){Drafts.send('launcherURL',JSON.stringify({url:url}));Drafts.continue();}
    function toggleTag(tag){var pill=document.querySelector('.tag-pill[data-tag="'+tag+'"]');if(activeTags.has(tag)){activeTags.delete(tag);pill.classList.remove('active');}else{activeTags.add(tag);pill.classList.add('active');}filterItems();}
    var searchBar=document.getElementById('searchBar');
    var allItems=document.querySelectorAll('.launcher-item');
    var allSections=document.querySelectorAll('.section');
    var noResults=document.getElementById('noResults');
    function filterItems(){var query=searchBar.value.toLowerCase().trim();var hasVisibleItems=false;var sectionVisibility={};allItems.forEach(function(item){var label=item.getAttribute('data-label');var tags=item.getAttribute('data-tags');var sectionId=item.getAttribute('data-section');var matchesSearch=!query||label.indexOf(query)>=0;var matchesTags=activeTags.size===0||Array.from(activeTags).every(function(t){return tags.indexOf(t)>=0;});if(matchesSearch&&matchesTags){item.classList.remove('hidden');sectionVisibility[sectionId]=true;hasVisibleItems=true;}else{item.classList.add('hidden');}});allSections.forEach(function(sec){if(sectionVisibility[sec.id]){sec.classList.remove('hidden');sec.classList.add('open');}else{sec.classList.add('hidden');}});if(hasVisibleItems){noResults.classList.remove('visible');}else{noResults.classList.add('visible');}}
    searchBar.addEventListener('input',filterItems);
    setTimeout(function(){searchBar.focus();},100);
    </script>
    </body></html>`);
    
    const html = htmlParts.join('');
    
    // ---------- SHOW PREVIEW ----------
    const prev = HTMLPreview.create();
    prev.prefersFullScreen = true;
    prev.hideInterface = true;
    prev.show(html);
    
    // ========================================
    // PROCESSOR - Handle URL callbacks
    // ========================================
    const msg = context.previewValues["launcherURL"];
    if (msg) {
      try {
        const data = JSON.parse(msg);
        if (data.url) {
          app.openURL(data.url, false);
        }
      } catch(e) {
        alert(`Error opening URL: ${e.message}`);
      }
    }
    

Options

  • After Success Default
    Notification Error
    Log Level Info
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.