diff --git a/CLAUDE.md b/CLAUDE.md index 465894c..7d91e99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -296,8 +296,43 @@ v2026.08.26.002 Pre-fix charts self-repair at render. The .21.004 cleanup runs monospace) for guitar-tab rows. **Slide rewrites must stay LENGTH-PRESERVING** (`slide`→5 spaces, `s`→1 space) in BOTH paths: `pairToSegs` aligns chords by column, so a shortened chord line drops every chord onto the first word. +v2026.08.26.003 Share a song as a LINK. `shareSong()` packs `{app,kind:"song",version:1,song}` into + the URL **fragment** (never sent to a server, so a shared chart never reaches a + Pages access log — do NOT "simplify" this to a `?query=`), deflate-raw'd via + `CompressionStream` (`z.` tag; `j.` = plain-JSON fallback). A full chart ≈800 chars. + Share from `#dockSheet` or the long-press menu. **Two receive paths, both needed:** + `maybeImportFromLink()` in `boot()` reads a `#s=` fragment (always confirms, then + strips the hash so a reload can't re-prompt), and ≡ → "Add a song from a link" + takes a pasted URL — on iOS an installed A2HS app has storage SEPARATE from + Safari's, so a *tapped* link lands in a copy of the app the owner never opens, and + pasting is the only route that reaches the installed one. Incoming songs are + untrusted input: `mergeSetImport`'s coerce/clamp/fresh-id shape. `index.html` now + redirects in script — a meta refresh DROPS the fragment. +v2026.08.26.004 Showcase restored: House of the Rising Sun grows from a 4-line stub to a full + arrangement (intro/solo/outro bar rows, five verses, `{soc}` chorus, section + headings, banter). `retireShowcase()` dropped Danny Boy in .26.001 and left the app + with **no** worked example. One-time `upgradeShowcase()` (guard `state.showcaseV2`) + reaches existing libraries but replaces the body ONLY if it still matches the old + stub exactly — an owner-edited copy is never overwritten. Also: `restoreData`'s + `toast("Restored")` had been swallowed into a trailing `//` comment and never ran. +v2026.08.26.005 Fix the screen sleeping on a song (owner: "when I took away gig mode my power + settings changed" — correct, and it was a bug, not a setting). A + `WakeLockSentinel` is **auto-released by the browser every time the page hides**, + but `wakeLock` kept pointing at the dead sentinel, so the guard + `if(!wakeLock)requestWake()` was never true again — from the FIRST background/ + foreground cycle the app held no lock at all, on every screen. Masked until + .21.001, when `show()` still re-requested per navigation and gig mode held its own; + going app-wide left the broken guard as the only re-acquire path. `requestWake()` + now tests `.released`, nulls the handle on the sentinel's `release` event and + self-guards; `visibilitychange` re-requests unconditionally; `openCurrent()` and + `startScroll()` re-assert. Dead `releaseWake()` and the orphaned gig-mode comment + removed. Reproduced against a stubbed `wakeLock` first: 8 failing, then 11 passing. ``` +> **Note:** the changelog comment at the top of `setlist69.html` is missing entries +> `v2026.08.21.001` – `v2026.08.26.002`; they live only in this file. Backfill from here if you +> want the two in sync. + A GitHub Actions workflow (`.github/workflows/check.yml`) enforces the version discipline on every push: it syntax-checks the extracted inline script and `sw.js`, **fails if the `` brand version ≠ `sw.js` CACHE version**, and fails on duplicate element ids in the markup. ----- @@ -359,7 +394,7 @@ Single-page app with a manual screen router. Layout is a flex column (`.app`): a | `pasteModal` | Search-and-paste lyrics assistant (opens chord sites, parses pasted text). | | `dockSheet` | ⚙ song-controls sheet: transpose, ♯/♭, font/fit, edit (edit hidden in stage mode). | -**Navigation** is driven by `show(name)` where `name` ∈ `setlists | setSongs | song`. It toggles `.show`, swaps the header, manages the wake lock, and stops auto-scroll on leave. `goBack()` implements contextual back. +**Navigation** is driven by `show(name)` where `name` ∈ `setlists | setSongs | song`. It toggles `.show`, swaps the header, and stops auto-scroll on leave. (It no longer touches the wake lock — that has been app-wide since v2026.08.21.001; see §9.) `goBack()` implements contextual back. **Navigation state globals:** @@ -527,6 +562,18 @@ Filename: `setlist69-backup-YYYY-MM-DD.json`. Payload: full `state`. **Not supported:** `.onsongarchive` / `.onsongbook` (proprietary binary), `.backup` (ZIP but SQLite inside). Bulk OnSong export path: export individual songs as `.onsong`, zip them, import the zip. +**Share one song as a link** — `shareSong(id)` → `songToLink(song)` builds +`#s=.`, where the payload is `{app:"setlist69",kind:"song",version:1,song}` +and `tag` is `z` (deflate-raw via `CompressionStream`) or `j` (plain JSON when that API is missing). +**It must stay a fragment** — a fragment is never transmitted to the server, so a shared chart never +lands in a GitHub Pages access log; a `?query=` would break the no-server constraint in spirit. +Received two ways, and **both are load-bearing**: `maybeImportFromLink()` (called from `boot()`) +handles a `#s=` fragment on open, and ≡ → *Add a song from a link* accepts a pasted URL — on iOS an +installed A2HS app has storage separate from Safari's, so a tapped link imports into a copy of the +app the owner never uses. Either way `offerSharedSong()` confirms first and `addSharedSong()` +sanitises (coerce every field, clamp `capo`/`defaultTranspose`, fresh `newSongId()`, payload id +ignored). Unlike a file import, a link carries **`banter`** — `parseImport` has no field for it. + **Search & paste modal** (`pasteModal`) — editor has a "Find chords" button that opens this. User types a song name, opens one of 6 linked sites in a new tab (Chordie, E-Chords, Cifraclub, AZLyrics, Genius, Google), copies the result, pastes into the textarea, taps Import. `parseImport()` handles the rest. There is no server-side proxy — CORS prevents fetching arbitrary external sites from the browser; the manual copy-paste step is intentional. ----- @@ -553,7 +600,9 @@ Globals: `transpose` (semitones, 0 on song open), `preferFlats` (bool), `fontSiz **Gesture debug overlay (v2026.08.13.002):** 5 quick taps on the version tag (`.brand small`) toggle `#gestureDbg`, a fixed, `pointer-events:none` readout of the last ~6 touch events seen at document level (type, target, dx/dy) plus screen name and rendered column count. Session-only, never persisted. Exists so a dead gesture on a real device can be reported with the actual event stream — ask the owner to 5-tap, swipe once, and read back the overlay. -**Wake lock (v2026.08.21.001 — app-wide):** acquired at `boot()` and re-acquired on `visibilitychange→visible` on ANY screen; never released between screens (the OS drops it on hide). The owner runs the app on a stand all night — it must not sleep anywhere. +**Wake lock (app-wide; sentinel trap fixed v2026.08.26.005):** acquired at `boot()`, re-requested on every `visibilitychange→visible` on ANY screen, and re-asserted by `openCurrent()` and `startScroll()`. The owner runs the app on a stand all night — it must not sleep anywhere. + +> **Never test the handle for null.** `navigator.wakeLock.request()` returns a `WakeLockSentinel` that the browser **auto-releases whenever the page hides**, leaving `wakeLock` pointing at a dead object. The original guard `if(!wakeLock)requestWake()` was therefore never true after the first background cycle, and the app silently held no lock at all. `requestWake()` now checks **`wakeLock.released`**, attaches a `release` listener that nulls the handle, and self-guards so redundant calls are free. There is no `releaseWake()` — nothing releases it deliberately; the OS does. Regression cover: `test-wake.js` stubs `navigator.wakeLock` with a sentinel that auto-releases on hide. **Dock layout (v2026.07.04.004):** the dock keeps only the between-songs-frequent controls with ≥2.6rem targets — prev/pos/next, play/pause (`scrollBtn`), speed −/+, and a `⚙` (`dockMore`) button. Transpose, `♯/♭`, font (`A−`/`fit`/`A+`) and `✎ Edit` live in `#dockSheet`, a `.modal.bottom` opened by `⚙`. **The controls kept their original ids**, so all handlers, boot wiring, and `openCurrent`'s set-nav hide still work after the DOM move — don't rename them. `openDockSheet()` hides `#editSong` when `stageMode` is on. @@ -615,11 +664,13 @@ Router/nav: show goBack List renders: renderSetlists renderSetSongs renderAllSongs renderPicker renderAdder Song view: openSongInSet openSongStandalone openCurrent reRender libraryIds nextSong prevSong startScroll stopScroll updateScrollProg - requestWake releaseWake openDockSheet closeDockSheet nudgeFont + requestWake openDockSheet closeDockSheet nudgeFont Editor: openEditor closeEditor saveSong Context/dialogs: openSongContext closeSongContext openAdder closeAdder showPrompt showConfirm closeModal loadPlayed savePlayed Backup/share: saveJsonFile exportData shareApp shareSetlist restoreData mergeSetImport +Song links: b64urlEnc b64urlDec streamBytes songToLink linkToSong shareSong + addSharedSong offerSharedSong maybeImportFromLink Import: parseImport expandFiles unzip parseOpenSong PWA: showUpdateBanner maybeShowUpdatePill maybeShowInstallBanner isStandalone dismissInstall ``` @@ -648,7 +699,23 @@ No test framework — verification is manual: **Shipped:** the **per-song `banter` field** (v2026.07.19.003) — `song.banter`, an editor input (`#fBanter`), and a `.svbanter` line (accent, italic) under the title in the performance view (`renderSheet` reads `song.banter`, not `meta`). The Doubloon Bayou Band interim workaround (banter stuffed into `sub`) is migrated by the one-time, idempotent `migrateBanter()` in `boot()` — guarded by `state.bantersMigrated`, it **moves** (not deletes) `sub`→`banter` for that set's songs when `banter` is empty, and no-ops on any device without that set. Safe to delete once every device has booted past it. -**Showcase song (v2026.07.19.005):** `addShowcaseSong()` (guarded by `state.showcaseAdded`, called once from `boot()`) adds a perfectly-formatted demo song — **"Danny Boy"** (id `danny`) — plus a `★ Showcase` set (id `showcase`). **Legal basis:** it is public domain — Frederic Weatherly's 1913 lyrics + the traditional "Londonderry Air" (composer unknown) — the only kind of full song safe to ship. Do **not** hardcode a copyrighted song here. Idempotent and delete-safe (the flag stops re-adding, so removing the song/set sticks); reaches existing libraries as well as fresh installs. +**Showcase song (v2026.08.26.004):** the one fully-worked example in the app is +**House of the Rising Sun** (seed id `hr`) — traditional, **public domain**, which is the only kind +of song safe to ship in a repo that is public and served publicly by GitHub Pages. It exists to show +what a well-formatted chart looks like: `{c:}` section headings, bar-only rows for intro/solo/outro, +a `{soc}`/`{eoc}` chorus, a `banter` line, and chords that all resolve to fingering diagrams. + +`upgradeShowcase()` (one-time, guarded by `state.showcaseV2`, called from `boot()`) carries it to +libraries that already exist, since `seed()` only runs on a fresh install. It replaces the body +**only when it still matches `HR_STUB_BODY` exactly** — if the owner edited their copy, theirs +wins. Never make this migration unconditional; overwriting someone's edited chart breaks design +priority #1. + +*History:* the previous showcase was Danny Boy (v2026.07.19.005), also public domain, retired by +`retireShowcase()` in v2026.08.26.001 — which left the app with no example at all until .26.004. + +**Do not hardcode a copyrighted song here.** Requests to do so are answered with a **song link** +(§8): the chart travels in the link's own payload and never enters the repo. **Explicitly out of scope (by design):** cloud sync and any built-in online song catalog. Moving songs between devices is handled by backup-to-file. diff --git a/README.md b/README.md index 5ee3756..01d14aa 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@
[![CI](https://github.com/cdburgess75/SetList69/actions/workflows/check.yml/badge.svg)](https://github.com/cdburgess75/SetList69/actions/workflows/check.yml) -[![Version](https://img.shields.io/badge/version-2026.08.26.002-informational?style=flat-square)](#versioning) +[![Version](https://img.shields.io/badge/version-2026.08.26.005-informational?style=flat-square)](#versioning) [![PWA](https://img.shields.io/badge/PWA-installable-5A0FC8?style=flat-square)](#save-it-to-your-phone) [![Offline](https://img.shields.io/badge/offline-first-2ea043?style=flat-square)](#features) [![Runtime deps](https://img.shields.io/badge/runtime_dependencies-0-f5a623?style=flat-square)](#tech-stack) @@ -86,6 +86,19 @@ The whole thing is a single `setlist69.html` file. No framework, no build step, --- +## Sending a song to a bandmate + +Open a song → **⚙** → **Share this song** (or long-press it in the library). You get a link with the +whole chart — chords, sections, key, stage banter — packed into it. Text it, email it, AirDrop it. +Whoever opens it is asked whether to add it to their library. + +Nothing is uploaded anywhere: the song rides in the URL's `#fragment`, which browsers never send to +a server. There's no account and no backend to sync through. + +> **On an installed iPhone/iPad app,** open **≡ → Add a song from a link** and paste the link there. +> Tapping a link opens Safari, and iOS gives an installed home-screen app its own separate storage — +> so a tapped link would land in a copy of the app you don't use. + ## Tech stack ![HTML5](https://img.shields.io/badge/HTML5-E34F26?style=for-the-badge&logo=html5&logoColor=white) diff --git a/index.html b/index.html index 537a8ac..8117ee6 100644 --- a/index.html +++ b/index.html @@ -1,3 +1,7 @@ - +SetList69 + + + diff --git a/setlist69.html b/setlist69.html index beb7e64..c254c0f 100644 --- a/setlist69.html +++ b/setlist69.html @@ -216,6 +216,40 @@ the setlist "Share" button now draw the universal box-with-arrow-up glyph as inline SVG (stroke:currentColor, themes with the button; aria-hidden — the buttons keep their labels). No Unicode codepoint exists for it, hence SVG. + v2026.08.26.003 Share a song as a LINK (owner: one song took a whole session to install, via a + file they still had to save and pick out of Files). shareSong() packs the song + into the URL FRAGMENT — never sent to a server, so a shared chart never lands in + a Pages access log — as {app,kind:"song",version:1,song} JSON, deflate-raw'd via + CompressionStream ("z." tag; "j." = plain-JSON fallback). A full chart fits in + ~800 chars. Share from the ⚙ sheet or the long-press menu; receive by opening the + link (maybeImportFromLink in boot, always confirms, then strips the fragment) OR + by pasting it into ≡ → "Add a song from a link". BOTH receive paths are needed: + an installed iOS A2HS app has storage separate from Safari's, so a tapped link + lands in a copy of the app you never use — pasting is the only route there. + Incoming songs are untrusted input (mergeSetImport's coerce/clamp/fresh-id shape). + index.html now redirects in script, because a meta refresh DROPS the fragment. + v2026.08.26.004 Showcase song restored: House of the Rising Sun (traditional, PUBLIC DOMAIN — the + only kind safe to ship, CLAUDE.md §14) expands from a 4-line stub to a full + arrangement — intro/solo/outro bars, five verses, a marked {soc} chorus, section + headings and a banter line. retireShowcase() dropped Danny Boy in .26.001 and + left the app with NO worked example. One-time upgradeShowcase() reaches existing + libraries, replacing the body only if it still matches the old stub exactly, so + an owner-edited copy is never overwritten. Also: restoreData's toast("Restored") + had been swallowed into a trailing // comment and never ran. + v2026.08.26.005 Fix the screen going to sleep on a song (owner: "when I took away gig mode my + power settings changed"). Correct diagnosis: navigator.wakeLock.request() hands + back a WakeLockSentinel that the browser AUTO-RELEASES whenever the page hides, + but `wakeLock` kept pointing at the dead sentinel - so the guard + `if(!wakeLock)requestWake()` was never true again and, from the FIRST background/ + foreground cycle on, the app held no lock at all on every screen. Masked until + v2026.08.21.001 because show() used to re-request on each navigation and gig mode + held its own; making the lock app-wide left the broken guard as the only path. + requestWake() now tests `.released`, nulls the handle on the sentinel's release + event, and self-guards (so a redundant call is a no-op); visibilitychange + re-requests unconditionally; openCurrent() and startScroll() re-assert it. + Also removed: dead releaseWake() (no call sites) and the orphaned gig-mode + comment block. Reproduced with a stubbed wakeLock first - 8 failing assertions + before, 11 passing after. --> @@ -549,7 +583,7 @@
-
SetList69v2026.08.26.002
+
SetList69v2026.08.26.005
@@ -707,6 +741,7 @@

Songs

Font size
+ @@ -735,6 +770,7 @@

Songs

+ @@ -811,11 +847,18 @@

Songs

} /* ===== seed (public-domain songs) ===== */ +/* The showcase song: the one fully-worked example in the app, demonstrating section headings, + a marked chorus, stage banter, and chords that resolve to fingering diagrams. Traditional / + PUBLIC DOMAIN - the only kind of song safe to ship, see CLAUDE.md 14. Replaced Danny Boy, + which retireShowcase() removed in v2026.08.26.001 without a successor. */ +const HR_SHOWCASE_BODY="{c: Intro - let it breathe, 8 bars}\n[Am] [C] [D] [F] [Am] [C] [E] [E]\n\n{c: Verse 1}\n[Am]There [C]is a [D]house in [F]New Or[Am]leans\nThey [C]call the [E]Rising [E]Sun\nAnd it's [Am]been the [C]ruin of [D]many a poor [F]boy\nAnd [Am]God I [E]know I'm [Am]one\n\n{c: Verse 2}\n[Am]My [C]mother [D]was a [F]tailor\nShe [Am]sewed my [C]new blue [E]jeans\nMy [Am]father [C]was a [D]gamblin' [F]man\nDown [Am]in [E]New Or[Am]leans\n\n{c: Verse 3}\n[Am]Now the [C]only [D]thing a [F]gambler [Am]needs\nIs a [C]suitcase and a [E]trunk\nAnd the [Am]only [C]time he's [D]satis[F]fied\nIs [Am]when he's [E]on a [Am]drunk\n\n{soc}\n{c: Chorus}\nOh [Am]mother [C]tell your [D]chil[F]dren\nNot to [Am]do what [C]I have [E]done\n[Am]Spend your [C]lives in [D]sin and [F]misery\nIn the [Am]House of the [E]Rising [Am]Sun\n{eoc}\n\n{c: Organ solo - same changes, 8 bars}\n[Am] [C] [D] [F] [Am] [C] [E] [E]\n\n{c: Verse 4}\n[Am]Well I got [C]one foot [D]on the [F]platform\nThe [Am]other [C]foot on the [E]train\nI'm [Am]goin' [C]back to [D]New Or[F]leans\nTo [Am]wear that [E]ball and [Am]chain\n\n{c: Verse 5 - drop to almost nothing, then build}\n[Am]Well there [C]is a [D]house in [F]New Or[Am]leans\nThey [C]call the [E]Rising [E]Sun\nAnd it's [Am]been the [C]ruin of [D]many a poor [F]boy\nAnd [Am]God I [E]know I'm [Am]one\n\n{c: Outro - ritard on the last Am}\n[Am] [C] [D] [F] [Am] [E] [Am]"; +const HR_SHOWCASE_BANTER="Last verse: voice and arpeggio only until the final line, then everyone back in."; function seed(){ const S=(id,title,sub,key,body)=>({id,title,sub,key,body}); const songs=[ S("am","Amazing Grace","Traditional","G","[G]Amazing [G7]grace how [C]sweet the [G]sound\nThat [G]saved a [Em]wretch like [D]me\nI [G]once was [G7]lost but [C]now am [G]found\nWas [Em]blind but [D]now I [G]see"), - S("hr","House of the Rising Sun","Traditional","Am","[Am]There [C]is a [D]house in [F]New Or[Am]leans\nThey [Am]call the [C]Rising [E]Sun\nAnd it's [Am]been the [C]ruin of [D]many a poor [F]boy\nAnd [Am]God I [E]know I'm [Am]one"), + {id:"hr",title:"House of the Rising Sun",sub:"Traditional",key:"Am", + banter:HR_SHOWCASE_BANTER,body:HR_SHOWCASE_BODY}, S("wf","Wayfaring Stranger","Traditional","Em","[Em]I am a poor way[Am]faring [Em]stranger\nWhile [Em]traveling through this [B7]world of [Em]woe\nYet [Em]there's no sickness, [Am]toil nor [Em]danger\nIn [Em]that bright land to [B7]which I [Em]go"), S("wc","Will the Circle Be Unbroken","Traditional","G","[G]Will the circle [G7]be un[C]broken\n[G]By and by, Lord, [D]by and [G]by\n[G]There's a better [G7]home a[C]waiting\n[G]In the sky, Lord, [D]in the [G]sky"), S("sf","Scarborough Fair","Traditional","Am","[Am]Are you going to [G]Scarborough [Am]Fair?\n[Am]Parsley, [C]sage, rose[G]mary and [Am]thyme\n[Am]Remember [G]me to one who [Am]lives there\n[C]She once [Am]was a [G]true love of [Am]mine"), @@ -1131,6 +1174,7 @@

Songs

function openSongStandalone(id){curIndex=-1;curSongId=id;curSetTranspose=0;openCurrent();} function openCurrent(){ const s=state.songs.find(x=>x.id===curSongId);if(!s)return; + requestWake(); // never sleep on a song - idempotent, no-ops when the lock is already live capoVal=s.capo||0; transpose=(s.defaultTranspose||0)+curSetTranspose-capoVal;stopScroll();document.getElementById("trVal").textContent=(transpose>0?"+":"")+transpose; renderSheet(s);show("song");document.getElementById("songView").scrollTop=0; @@ -1172,7 +1216,7 @@

Songs

} /* ===== auto-scroll ===== */ -function startScroll(){scrolling=true;scrollAccum=0;const b=document.getElementById("scrollBtn");b.classList.add("playing");b.textContent="❚❚"; +function startScroll(){scrolling=true;scrollAccum=0;requestWake();const b=document.getElementById("scrollBtn");b.classList.add("playing");b.textContent="❚❚"; const v=document.getElementById("songView");let last=performance.now(); function step(t){if(!scrolling)return;const dt=t-last;last=t;scrollAccum+=(scrollSpeed*0.012)*dt; if(scrollAccum>=1){const px=Math.floor(scrollAccum);v.scrollTop+=px;scrollAccum-=px;} @@ -1207,22 +1251,30 @@

Songs

return kick; })(); -/* ===== wake lock ===== */ -async function requestWake(){try{if("wakeLock"in navigator)wakeLock=await navigator.wakeLock.request("screen");}catch(e){}} -function releaseWake(){try{if(wakeLock){wakeLock.release();wakeLock=null;}}catch(e){}} +/* ===== wake lock ===== + Wide awake: the whole app holds the screen-wake lock while visible (owner: a stand-mounted device + must never sleep, whichever screen it sits on). + + THE TRAP (fixed v2026.08.26.005): navigator.wakeLock.request() returns a WakeLockSentinel, and the + browser AUTO-RELEASES it every time the page hides - but our variable keeps pointing at the dead + sentinel. The old guard was `if(!wakeLock)requestWake()`, which was therefore never true again, so + from the first background/foreground cycle onward the app held NO lock and the screen slept on + every screen, song view included. Test `.released`, never the handle for null. */ +async function requestWake(){ + try{ + if(!("wakeLock" in navigator))return; + if(wakeLock&&!wakeLock.released)return; // already holding a LIVE lock - no-op + wakeLock=await navigator.wakeLock.request("screen"); + wakeLock.addEventListener("release",()=>{wakeLock=null;}); // OS took it back; let the next request run + }catch(e){} // refused (low battery / no permission) - carry on unlocked +} document.addEventListener("visibilitychange",()=>{ - /* Wide awake: the whole app holds the screen-wake lock while visible (owner: a stand-mounted - device must never sleep, whichever screen it sits on). The OS releases it on hide. */ - if(document.visibilityState==="visible"){if(!wakeLock)requestWake();} - else flushPersist(); // backgrounded — commit any pending save before the OS can freeze us + // requestWake() self-guards now, so re-request unconditionally rather than testing the handle. + if(document.visibilityState==="visible")requestWake(); + else flushPersist(); // backgrounded - commit any pending save before the OS can freeze us }); window.addEventListener("pagehide",flushPersist); -/* ===== fullscreen "gig mode" ===== - Immersive performance view: request browser fullscreen where supported (Android/desktop/iPad — - an installed iOS PWA is already fullscreen), hide the header strip for extra lyric space, and hold - the wake lock. The existing swipe ‹ › still moves through the set. Exit via ⛶, Esc, or leaving. */ - /* ===== editor ===== */ function openEditor(id,ret){editingId=id;editorReturn=ret||screen; const s=id?state.songs.find(x=>x.id===id):null; @@ -1326,6 +1378,91 @@

Songs

try{await navigator.clipboard.writeText(url);toast("Link copied");} catch(_){toast(url);} } +/* ===== share ONE SONG as a link ===== + The song rides in the URL FRAGMENT. Browsers never send a fragment to the server, so a shared + song never lands in a GitHub Pages access log - a "?song=" query would ship the owner's chart to + GitHub on every tap, which breaks the no-server constraint in spirit. Keep it a hash. + Payload shape mirrors shareSetlist's: {app,kind,version,song}. The tag before the base64 says how + it was packed: "z." = deflate-raw (the same native stream unzip() already relies on), "j." = plain + JSON for browsers without CompressionStream. A typical chart is ~1.2KB raw, ~800 chars deflated. */ +function b64urlEnc(bytes){let s="";for(let i=0;i(typeof v==="string"?v:v==null?"":String(v)); + const num=(v,lo,hi)=>{const n=parseInt(v,10);return Number.isFinite(n)?Math.min(hi,Math.max(lo,n)):0;}; + const id=newSongId(); + state.songs.push({id,title:str(raw.title).trim()||"Untitled",sub:str(raw.sub),banter:str(raw.banter), + key:str(raw.key),capo:num(raw.capo,0,11),defaultTranspose:num(raw.defaultTranspose,-11,11), + body:str(raw.body)}); + persist();renderSetlists();renderAllSongs(); + return id; +} +/* Never write to someone's library straight off a link - always confirm. Duplicate identity is + title+sub case-folded, the same convention mergeSetImport uses. */ +function offerSharedSong(raw){ + const str=v=>(typeof v==="string"?v:v==null?"":String(v)); + const title=str(raw.title).trim()||"Untitled",sub=str(raw.sub).trim(); + const NULSEP=String.fromCharCode(0); // NOT a space - see CLAUDE.md 8: "a b"+"c" would collide with "a"+"b c" + const keyOf=(t,u)=>t.trim().toLowerCase()+NULSEP+u.trim().toLowerCase(); + const dupe=state.songs.some(s=>keyOf(str(s.title),str(s.sub))===keyOf(title,sub)); + showConfirm(dupe?"Already in your library":"Add song", + '"'+title+'"'+(sub?" — "+sub:"")+(dupe?" is already here. Add another copy?":" — add it to your library?"), + dupe?"Add copy":"Add", + ()=>{const id=addSharedSong(raw);openSongStandalone(id);toast("Song added");}); +} +async function maybeImportFromLink(){ + if(!/#s=/.test(location.hash))return; + const raw=await linkToSong(location.hash); + // Strip the fragment either way, so a reload doesn't re-prompt for a song already added. + try{history.replaceState(null,"",location.href.split("#")[0]);}catch(_){} + if(!raw){toast("That song link couldn't be read");return;} + offerSharedSong(raw); +} +async function shareSong(id){ + const s=state.songs.find(x=>x.id===id);if(!s)return; + const url=await songToLink(s); + if(url.length>4000)toast("Long song — some apps may trim the link"); + if(navigator.share){ + try{await navigator.share({title:s.title, + text:s.title+(s.sub?" — "+s.sub:"")+" · open in SetList69:",url});} + catch(_){/* sheet dismissed */} + return; + } + try{await navigator.clipboard.writeText(url);toast("Song link copied");}catch(_){toast(url);} +} async function shareSetlist(id){ const sl=state.setlists.find(s=>s.id===id);if(!sl)return; const songs=sl.songIds.map(sid=>state.songs.find(s=>s.id===sid)).filter(Boolean); @@ -1381,7 +1518,8 @@

Songs

if(state.chordChart!=null){chordChart=state.chordChart;document.getElementById("chartBtn").classList.toggle("on",chordChart);} playedSongs=new Set();persist();applyTheme(); renderSetlists();renderAllSongs();show("setlists"); - requestWake(); // wide awake from launch — see the visibilitychange notetoast("Restored"); + requestWake(); // wide awake from launch — see the visibilitychange note + toast("Restored"); }); }catch(e){toast("⚠ Not a valid backup file");}};r.readAsText(file);} @@ -1750,6 +1888,20 @@

Songs

}); document.getElementById("exportBtn").addEventListener("click",exportData); document.getElementById("importBtn").addEventListener("click",()=>document.getElementById("restoreInput").click()); +document.getElementById("shareSongBtn").addEventListener("click",()=>{closeDockSheet();if(curSongId)shareSong(curSongId);}); +/* Paste path for song links. On iOS an installed (A2HS) app has storage SEPARATE from Safari's, so + tapping a link opens Safari and would add the song to a copy of the app the owner never uses. + Pasting the link into the installed app is the only reliable route there - this is NOT redundant + with maybeImportFromLink(); do not drop it in favour of the hash handler alone. */ +document.getElementById("linkImportBtn").addEventListener("click",()=>{ + document.getElementById("toolsSheet").classList.remove("show"); + showPrompt("Add a song from a link","Paste the song link here","",async v=>{ + if(!v||!v.trim())return; + const raw=await linkToSong(v); + if(!raw){toast("That song link couldn't be read");return;} + offerSharedSong(raw); + }); +}); document.getElementById("restoreInput").addEventListener("change",e=>{if(e.target.files[0])restoreData(e.target.files[0]);e.target.value="";}); document.getElementById("trUp").addEventListener("click",()=>{transpose++;document.getElementById("trVal").textContent=(transpose>0?"+":"")+transpose;const _s=state.songs.find(x=>x.id===curSongId);if(_s){_s.defaultTranspose=transpose+capoVal-curSetTranspose;persist();}retuneSheet();}); @@ -1938,6 +2090,7 @@

Songs

body.appendChild(b); } mkBtn("✎","Edit song",false,()=>openEditor(songId,fromScreen)); + mkBtn("🔗","Share song as a link",false,()=>shareSong(songId)); if(fromScreen==="setSongs"){ const played=playedSongs.has(songId); mkBtn(played?"↺":"✓",played?"Mark unplayed":"Mark as played",false,()=>{ @@ -2106,6 +2259,20 @@

Songs

it gone — the app has their real catalogue in it now). Only touches the reserved seed id, runs once via state.showcaseRetired, and anything the owner created is untouched. A copy is always recoverable from a backup file if it is ever wanted back. */ +/* One-time: promote the old four-line House of the Rising Sun stub to the full showcase + arrangement. seed() only runs on a fresh install, so without this the example never reaches + a library that already exists. Replaces the body ONLY if it still matches the old seed string + exactly - if the owner edited their copy, theirs wins. Silently overwriting someone's chart + would break design priority #1 (never lose a song). */ +const HR_STUB_BODY="[Am]There [C]is a [D]house in [F]New Or[Am]leans\nThey [Am]call the [C]Rising [E]Sun\nAnd it's [Am]been the [C]ruin of [D]many a poor [F]boy\nAnd [Am]God I [E]know I'm [Am]one"; +function upgradeShowcase(){ + if(state.showcaseV2)return false; + state.showcaseV2=true; + const s=state.songs.find(x=>x.id==="hr"); + if(!s||s.body!==HR_STUB_BODY)return false; // absent, or edited by the owner - leave it alone + s.body=HR_SHOWCASE_BODY;s.banter=HR_SHOWCASE_BANTER; + return true; +} function retireShowcase(){ if(state.showcaseRetired)return false; const had=state.songs.some(s=>s.id==="danny"); @@ -2132,11 +2299,13 @@

Songs

if(state.chordChart!=null)chordChart=state.chordChart; if(!state.bantersMigrated){migrateBanter();persist();} if(!state.showcaseRetired){retireShowcase();persist();} + if(!state.showcaseV2){upgradeShowcase();persist();} applyTheme();document.getElementById("speedVal").textContent=scrollSpeed; document.getElementById("flatBtn").classList.toggle("on",preferFlats); document.getElementById("nashBtn").classList.toggle("on",nashville); document.getElementById("chartBtn").classList.toggle("on",chordChart); renderSetlists();renderAllSongs();show("setlists"); + maybeImportFromLink(); // a "#s=" fragment carries a shared song requestWake(); // wide awake from launch — see the visibilitychange note if(!canPersist)setTimeout(()=>toast("Preview mode — use Back up to a file to keep songs"),700); setTimeout(maybeShowInstallBanner,600); diff --git a/sw.js b/sw.js index b694108..53f38ff 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'setlist69-v2026.08.26.002'; +const CACHE = 'setlist69-v2026.08.26.005'; const PRECACHE = [ './', './index.html',