diff --git a/Music/OldMusicandMVs-music.md b/Music/OldMusicandMVs-music.md
index 953c73b..231eba5 100644
--- a/Music/OldMusicandMVs-music.md
+++ b/Music/OldMusicandMVs-music.md
@@ -45,3 +45,6 @@
- **Shortstraw \- Cold Shoulder \[Music Video 720p\] \- YouTube**
- URL: https://www.youtube.com/watch?v=pIGIW3h9LpU
+- **Dammit - Dwonlaod \ - Free On File Browser**
+ - URL: https://files.exopraxist.org/share/KeQTyi9r
+
diff --git a/Music/build.py b/Music/build.py
new file mode 100644
index 0000000..9242cde
--- /dev/null
+++ b/Music/build.py
@@ -0,0 +1,769 @@
+#!/usr/bin/env python3
+"""
+Music build script.
+
+For each *-music.md in this folder:
+ - Parse title, optional first-load lightbox, and track entries
+ - Two entry types detected by URL:
+ YouTube → youtube.com/watch?v=VIDEO_ID → video embed card
+ Audio → files.exopraxist.org/share/TOKEN → audio card + floating player
+ - Generate a self-contained HTML page
+
+Then update music-hub.html subtitle with current totals.
+
+Output filenames: strip '-music' suffix, lowercase the stem.
+ OldMusicandMVs-music.md → oldmusicandmvs.html
+
+Run after adding or editing any *-music.md file.
+"""
+
+import re
+from pathlib import Path
+from html import escape
+
+HERE = Path(__file__).parent
+HUB = HERE / "music-hub.html"
+
+ALLOW = (
+ "accelerometer; autoplay; clipboard-write; "
+ "encrypted-media; gyroscope; picture-in-picture; web-share"
+)
+
+
+# ── MD parsing ────────────────────────────────────────────────────────────────
+
+def slug_from_path(path: Path) -> str:
+ stem = path.stem
+ stem = re.sub(r'-music$', '', stem, flags=re.IGNORECASE)
+ return stem.lower().replace('-', '').replace('_', '').replace(' ', '')
+
+
+def parse_md(path: Path) -> dict:
+ text = path.read_text(encoding='utf-8')
+
+ m = re.search(r'^#\s+(.+)$', text, re.MULTILINE)
+ title = m.group(1).strip() if m else path.stem
+
+ m = re.search(r'>>on-first-load\s+-\s+lightbox\s+-\s+"([^"]+)"', text)
+ lightbox = m.group(1).strip() if m else None
+
+ entries = []
+ for m in re.finditer(
+ r'-\s+\*\*(.+?)\*\*\s*\n\s+-\s+URL:\s+(https://[^\s]+)',
+ text
+ ):
+ name = m.group(1).strip()
+ # Strip trailing \- Source label from name (e.g. "Title \- YouTube")
+ name = re.sub(r'\s*\\-\s*.+$', '', name).strip()
+ url = m.group(2).strip()
+
+ if 'youtube.com/watch' in url:
+ vid = re.search(r'[?&]v=([A-Za-z0-9_-]+)', url)
+ if vid:
+ entries.append({'type': 'youtube', 'name': name,
+ 'video_id': vid.group(1)})
+ elif 'files.exopraxist.org/share/' in url:
+ token = url.rstrip('/').split('/')[-1]
+ entries.append({'type': 'audio', 'name': name,
+ 'share_url': url,
+ 'direct_url': f"https://files.exopraxist.org/api/public/dl/{token}"})
+
+ return {'title': title, 'lightbox': lightbox, 'entries': entries}
+
+
+# ── HTML blocks ───────────────────────────────────────────────────────────────
+
+def build_lightbox_html(text: str, slug: str) -> tuple[str, str]:
+ if not text:
+ return '', ''
+
+ # Preserve — JL attribution if present at the end
+ body = text
+ attribution = ''
+ m = re.search(r'\s*-\s*JL\s*$', text)
+ if m:
+ body = text[:m.start()].strip()
+ attribution = '
— JL
'
+
+ safe_body = escape(body)
+
+ div = f"""
+
+
+
×
+
{safe_body}
+ {attribution}
+
CLICK ANYWHERE TO DISMISS
+
+
"""
+
+ js = f"""
+ const lightbox = document.getElementById('lightbox');
+ if (!localStorage.getItem('music-{slug}-seen')) {{
+ lightbox.style.display = 'flex';
+ localStorage.setItem('music-{slug}-seen', '1');
+ }}
+ const closeLightbox = () => {{ lightbox.style.display = 'none'; }};
+ lightbox.addEventListener('click', (e) => {{
+ if (e.target === lightbox) closeLightbox();
+ }});
+ document.querySelector('.lightbox-close').addEventListener('click', closeLightbox);
+ document.addEventListener('keydown', (e) => {{
+ if (e.key === 'Escape') closeLightbox();
+ }});
+"""
+ return div, js
+
+
+def build_video_card(entry: dict) -> str:
+ embed_url = f"https://www.youtube.com/embed/{entry['video_id']}"
+ title_esc = escape(entry['name'])
+ return f"""
+ """
+
+
+def build_audio_card(entry: dict, audio_index: int) -> str:
+ title_esc = escape(entry['name'])
+ direct_url = entry['direct_url']
+ share_url = entry['share_url']
+ return f"""
+ """
+
+
+def build_entries_html(entries: list) -> str:
+ parts = []
+ audio_idx = 1
+ for e in entries:
+ if e['type'] == 'youtube':
+ parts.append(build_video_card(e))
+ else:
+ parts.append(build_audio_card(e, audio_idx))
+ audio_idx += 1
+ return '\n'.join(parts)
+
+
+def build_stagger_css(count: int) -> str:
+ lines = []
+ for i in range(1, min(count, 12) + 1):
+ lines.append(
+ f" .card:nth-child({i}) {{ animation-delay: {(i-1)*50}ms; }}"
+ )
+ return '\n'.join(lines)
+
+
+# ── Full page generation ──────────────────────────────────────────────────────
+
+def generate_html(data: dict, slug: str) -> str:
+ title = data['title']
+ entries = data['entries']
+ count = len(entries)
+ has_audio = any(e['type'] == 'audio' for e in entries)
+ title_upper = title.upper()
+ count_label = f"{count} TRACK{'S' if count != 1 else ''}"
+
+ lightbox_div, lightbox_js = build_lightbox_html(data['lightbox'], slug)
+ entries_html = build_entries_html(entries)
+ stagger_css = build_stagger_css(count)
+
+ lightbox_css = """
+ /* --- Lightbox --- */
+ #lightbox {
+ position: fixed;
+ top: 0; left: 0;
+ width: 100%; height: 100%;
+ background: rgba(4, 6, 11, 0.9);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 2rem;
+ cursor: pointer;
+ }
+ .lightbox-content {
+ background: #0c0a08;
+ border: 2px solid var(--mu-red);
+ border-top: 8px solid var(--mu-orange);
+ max-width: 600px;
+ width: 100%;
+ max-height: 90vh;
+ overflow-y: auto;
+ padding: 2.5rem;
+ position: relative;
+ cursor: default;
+ }
+ .lightbox-close {
+ position: absolute;
+ top: 0.5rem; right: 0.75rem;
+ font-size: 2rem;
+ color: var(--mu-orange);
+ cursor: pointer;
+ line-height: 1;
+ }
+ .lightbox-quote {
+ font-style: italic;
+ font-size: 1.1rem;
+ color: var(--text-warm);
+ margin-bottom: 1.5rem;
+ padding-right: 1.5rem;
+ }
+ .lightbox-attribution {
+ font-weight: 700;
+ text-transform: uppercase;
+ color: var(--mu-orange);
+ text-align: right;
+ }
+ .lightbox-hint {
+ display: block;
+ text-align: center;
+ margin-top: 2rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ font-size: 0.65rem;
+ letter-spacing: 0.1em;
+ color: rgba(232, 213, 184, 0.4);
+ }
+""" if data['lightbox'] else ''
+
+ audio_css = """
+ /* --- Audio Card --- */
+ .audio-card {
+ padding: 1.5rem;
+ }
+ .audio-controls {
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ }
+ .play-btn {
+ background: none;
+ border: 2px solid var(--mu-orange);
+ color: var(--mu-orange);
+ width: 48px;
+ height: 48px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.2rem;
+ cursor: pointer;
+ transition: var(--transition);
+ font-family: inherit;
+ }
+ .play-btn:hover {
+ background: var(--mu-orange);
+ color: var(--bg-void);
+ }
+ .audio-info { flex-grow: 1; }
+ .download-link {
+ font-weight: 700;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ color: var(--mu-orange);
+ text-decoration: none;
+ display: inline-block;
+ margin-bottom: 0.5rem;
+ transition: var(--transition);
+ }
+ .download-link:hover { text-decoration: underline; }
+ .progress-container {
+ height: 4px;
+ background: var(--mu-red);
+ width: 100%;
+ cursor: pointer;
+ }
+ .progress-bar {
+ height: 100%;
+ background: var(--mu-orange);
+ width: 0%;
+ transition: width 100ms linear;
+ }
+ .duration-meta {
+ font-weight: 700;
+ text-transform: uppercase;
+ font-size: 0.7rem;
+ color: rgba(232, 213, 184, 0.5);
+ margin-top: 0.25rem;
+ display: flex;
+ justify-content: space-between;
+ }
+
+ /* --- Floating Player --- */
+ .floating-player {
+ position: fixed;
+ bottom: 0; left: 0;
+ width: 100%;
+ height: 64px;
+ background: var(--bg-void);
+ border-top: 2px solid var(--mu-red);
+ z-index: 500;
+ display: none;
+ align-items: center;
+ padding: 0 1rem;
+ gap: 1rem;
+ }
+ .floating-player.active { display: flex; }
+ .fp-info { flex: 1; min-width: 0; }
+ .fp-title {
+ font-family: 'Rancho', cursive;
+ font-size: 1.2rem;
+ color: var(--mu-orange);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .fp-controls {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ }
+ .fp-btn {
+ background: none;
+ border: none;
+ color: var(--mu-orange);
+ font-family: 'Rambla', sans-serif;
+ font-weight: 700;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: var(--transition);
+ }
+ .fp-btn:hover { color: var(--text-warm); }
+ .fp-progress-wrap {
+ position: absolute;
+ top: -2px; left: 0;
+ width: 100%;
+ height: 2px;
+ background: var(--mu-red);
+ }
+ .fp-progress-fill {
+ height: 100%;
+ background: var(--mu-orange);
+ width: 0%;
+ transition: width 100ms linear;
+ }
+""" if has_audio else ''
+
+ floating_player_html = """
+
+ """ if has_audio else ''
+
+ audio_js = """
+ // --- Audio Logic ---
+ const audio = document.getElementById('main-audio');
+ const floatingPlayer = document.querySelector('.floating-player');
+ const fpPlayBtn = document.getElementById('fp-play');
+ const fpTitle = document.querySelector('.fp-title');
+ const fpFill = document.querySelector('.fp-progress-fill');
+ let currentTrackBtn = null;
+
+ const formatTime = (t) => {
+ const m = Math.floor(t / 60);
+ const s = Math.floor(t % 60);
+ return `${m}:${s.toString().padStart(2, '0')}`;
+ };
+
+ const updateUI = () => {
+ const playing = !audio.paused;
+ const icon = playing ? '▐▐' : '▶';
+ if (currentTrackBtn) currentTrackBtn.textContent = icon;
+ fpPlayBtn.textContent = icon;
+ if (playing) {
+ floatingPlayer.classList.add('active');
+ document.body.classList.add('player-active');
+ }
+ };
+
+ document.querySelectorAll('.play-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const src = btn.dataset.src;
+ const title = btn.dataset.title;
+ if (audio.getAttribute('data-loaded') === src) {
+ if (audio.paused) audio.play(); else audio.pause();
+ } else {
+ if (currentTrackBtn) currentTrackBtn.textContent = '▶';
+ audio.src = src;
+ audio.setAttribute('data-loaded', src);
+ fpTitle.textContent = title;
+ currentTrackBtn = btn;
+ audio.play();
+ }
+ });
+ });
+
+ fpPlayBtn.addEventListener('click', () => {
+ if (audio.paused) audio.play(); else audio.pause();
+ });
+
+ audio.addEventListener('play', updateUI);
+ audio.addEventListener('pause', updateUI);
+
+ audio.addEventListener('timeupdate', () => {
+ if (!audio.duration) return;
+ const pct = (audio.currentTime / audio.duration) * 100;
+ if (fpFill) fpFill.style.width = pct + '%';
+ if (currentTrackBtn) {
+ const card = currentTrackBtn.closest('.audio-card');
+ const bar = card.querySelector('.progress-bar');
+ const cur = card.querySelector('.current-time');
+ if (bar) bar.style.width = pct + '%';
+ if (cur) cur.textContent = formatTime(audio.currentTime);
+ }
+ });
+
+ audio.addEventListener('loadedmetadata', () => {
+ if (currentTrackBtn) {
+ const card = currentTrackBtn.closest('.audio-card');
+ const tot = card.querySelector('.total-time');
+ if (tot) tot.textContent = formatTime(audio.duration);
+ }
+ });
+
+ audio.addEventListener('ended', () => {
+ const allBtns = [...document.querySelectorAll('.play-btn')];
+ const idx = allBtns.indexOf(currentTrackBtn);
+ const next = allBtns[idx + 1];
+ if (next) {
+ next.click();
+ } else {
+ audio.pause();
+ if (currentTrackBtn) currentTrackBtn.textContent = '▶';
+ fpPlayBtn.textContent = '▶';
+ floatingPlayer.classList.remove('active');
+ document.body.classList.remove('player-active');
+ }
+ });
+
+ document.getElementById('fp-prev').addEventListener('click', () => {
+ const all = [...document.querySelectorAll('.play-btn')];
+ const prev = all[all.indexOf(currentTrackBtn) - 1];
+ if (prev) prev.click();
+ });
+
+ document.getElementById('fp-next').addEventListener('click', () => {
+ const all = [...document.querySelectorAll('.play-btn')];
+ const next = all[all.indexOf(currentTrackBtn) + 1];
+ if (next) next.click();
+ });
+
+ document.querySelectorAll('.progress-container').forEach(bar => {
+ bar.addEventListener('click', (e) => {
+ const rect = bar.getBoundingClientRect();
+ audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
+ });
+ });
+""" if has_audio else ''
+
+ return f"""
+
+
+
+
+ {escape(title)} | MUSIC
+
+
+
+
+
+
+{lightbox_div}
+
+
+
+ {escape(title.upper())}
+{entries_html}
+
+{floating_player_html}
+
+
+
+"""
+
+
+# ── Hub subtitle update ───────────────────────────────────────────────────────
+
+def update_hub_subtitle(collection_count: int, entry_total: int):
+ hub_text = HUB.read_text(encoding='utf-8')
+ subtitle = (
+ f''
+ f'{collection_count} COLLECTION{"S" if collection_count != 1 else ""}'
+ f' · PERSONAL ARCHIVE'
+ f' · {entry_total} TRACK{"S" if entry_total != 1 else ""}'
+ f' '
+ )
+ hub_text = re.sub(
+ r'.*?',
+ f'{subtitle}',
+ hub_text,
+ flags=re.DOTALL
+ )
+ HUB.write_text(hub_text, encoding='utf-8')
+
+
+# ── Main ──────────────────────────────────────────────────────────────────────
+
+def main():
+ md_files = sorted(HERE.glob('*-music.md'))
+ if not md_files:
+ print("No *-music.md files found.")
+ return
+
+ total_entries = 0
+
+ for md_path in md_files:
+ slug = slug_from_path(md_path)
+ out = HERE / f"{slug}.html"
+ data = parse_md(md_path)
+ count = len(data['entries'])
+
+ if count == 0:
+ print(f" SKIP {md_path.name} — no entries found")
+ continue
+
+ audio_count = sum(1 for e in data['entries'] if e['type'] == 'audio')
+ video_count = count - audio_count
+
+ html = generate_html(data, slug)
+ out.write_text(html, encoding='utf-8')
+ total_entries += count
+ print(f" BUILD {out.name} — {data['title']} ({video_count} videos, {audio_count} audio)")
+
+ update_hub_subtitle(len(md_files), total_entries)
+ print(f"\nUpdated hub — {len(md_files)} collections, {total_entries} tracks total")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/Music/music-hub.html b/Music/music-hub.html
new file mode 100644
index 0000000..5a74a06
--- /dev/null
+++ b/Music/music-hub.html
@@ -0,0 +1,186 @@
+
+
+
+
+
+ Music Hub — Singular Particular Space
+
+
+
+
+
+
+
+ ← SPACE
+ MUSIC
+ 1 COLLECTION · PERSONAL ARCHIVE · 14 TRACKS
+
+
+
+
+
+
+
+
14 ENTRIES
+
An archive of old recordings and music videos directed by JL.
+
+
+
+
+
+
+
+
0 ENTRIES
+
Upcoming recordings from live performances and studio sessions.
+
+
+
+
+
+
+
+
0 ENTRIES
+
Found sounds, field recordings, and sonic experiments.
+
+
+
+
+
diff --git a/Music/music-template.html b/Music/music-template.html
new file mode 100644
index 0000000..fe6db87
--- /dev/null
+++ b/Music/music-template.html
@@ -0,0 +1,703 @@
+
+
+
+
+
+ Old Music and Music Videos — Singular Particular Space
+
+
+
+
+
+
+
+
+
+
×
+
+ I much prefer making music live. But here are some old recordings. Oh and some music videos that I made for better recording artists than me at the time. At time of writing, there are inklings of thinkings about returning to making music. I'm considering it. We'll see how it plays out.
+
+
— JL
+
CLICK ANYWHERE TO DISMISS
+
+
+
+
+
+
+ THE ARCHIVE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Music/oldmusicandmvs.html b/Music/oldmusicandmvs.html
new file mode 100644
index 0000000..4caf875
--- /dev/null
+++ b/Music/oldmusicandmvs.html
@@ -0,0 +1,705 @@
+
+
+
+
+
+ Old Music and Music Videos - The Archive | MUSIC
+
+
+
+
+
+
+
+
+
+
×
+
I much prefer making music live. But here are some old recordings. Oh and some music videos that I made for better recording artists than me at the time. At time of writing, there are inklings of thinkings about returning to making music. I'm considering it. We'll see how it plays out.
+
— JL
+
CLICK ANYWHERE TO DISMISS
+
+
+
+
+
+ OLD MUSIC AND MUSIC VIDEOS - THE ARCHIVE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/index.html b/index.html
index 4e76e21..81acab7 100644
--- a/index.html
+++ b/index.html
@@ -691,7 +691,7 @@ body::after {
var STARS = [
{ id: 'writings', label: 'Writings', sign: '文', x: 25, y: 22, href: '' },
{ id: 'videos', label: 'Videos', sign: '映', x: 68, y: 18, href: 'Videos/index.html' },
- { id: 'music', label: 'Music', sign: '♬', x: 12, y: 44, href: '' },
+ { id: 'music', label: 'Music', sign: '♬', x: 12, y: 44, href: 'Music/music-hub.html' },
{ id: 'images', label: 'Images', sign: '絵', x: 55, y: 35, href: 'Images/images.html' },
{ id: 'playlists', label: 'Playlists', sign: '≡', x: 78, y: 50, href: 'Playlists/playlists.html' },
{ id: 'watchlists', label: 'Watchlists', sign: '視', x: 22, y: 66, href: 'Watchlists/watchlists-hub.html' },