commit 96b1ecf7e63669f090950596af93c3d6c75b81de Author: Ole-Morten Duesund Date: Tue Mar 17 16:54:31 2026 +0100 Initial commit: Pay2Play! satirical music player Paywalled music player where every feature costs money — pause, resume, skip, volume, even closing the app. Built with React 18 and Tone.js via CDN, zero build step. Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1d9024c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +Pay2Play! is a satirical music player where every interaction is paywalled. It's part of [donothireus.com](https://donothireus.com). The humor comes from absurd micro-charges for basic actions (pause, resume, volume, skip, etc.). + +## Development + +There is no build step, no package manager, and no dependencies to install. A static file server is required (Babel standalone fetches `app.js` via XHR). + +**Run locally:** +```bash +cd public/ && python3 -m http.server 8080 +``` + +Any static file server works (Caddy, nginx, etc.). + +## Architecture + +The app is split across three files in `public/`: +- `index.html` — HTML shell with CDN script tags and meta/OG tags +- `style.css` — animations, range input styling, hover/focus/active states +- `app.js` — all React components and logic (JSX compiled in-browser by Babel standalone) + +**CDN dependencies (no local install):** +- React 18 + ReactDOM (production UMD builds) +- Tone.js 14.8 (procedural synth audio) +- Babel standalone 7.26 (in-browser JSX → JS) + +**Key data structures:** +- `SONGS[]` — track definitions. Each song is either procedural (has `gen`, `wave`, `bpm` fields) or MP3-based (has `url` field). The two modes use different playback engines. +- `SUBS{}` — defines every paywalled feature (`pause`, `resume`, `skip`, `volume`, `lyrics`, etc.) with subscription price, one-time micro-transaction price, and description. +- `LYRICS_DATA[][]` — word arrays per song, shown redacted (every 3rd word visible) unless the user pays for the lyrics subscription. +- `AD_COPY[]` — rotating fake ad strings shown during the unskippable exit countdown. + +**Audio playback has two modes:** +1. **Procedural (default):** Tone.js synth loops driven by each song's `gen()` callback. No audio files needed. +2. **MP3:** When a song has a `url` property, HTML5 `Audio` is used instead. Songs can mix modes. + +**Core component:** `PayPlay` is the single top-level React component containing all state and logic. Sub-components are `WelcomeBanner` (onboarding overlay), `Modal` (3-phase paywall: choose/insufficient/success), `ExitAd` (unskippable countdown with rotating ad copy), and `CBtn` (icon button with aria-label). + +**Paywall flow:** User clicks a feature → `tryAct(key, fn)` checks if subscribed → if not, opens `Modal` → user chooses subscribe (full price) or one-time micro-payment → balance is deducted with cha-ching sound → success celebration → action executes. If insufficient funds, the modal stays open with a top-up button. The 3-second free listening limit is enforced via a `useEffect` watching `prog`, with audio degradation at 2s and a visual countdown badge. + +**Theme toggling:** The "Dark Mode" button is a joke — paying for it toggles the app to a painfully bright light mode. All colors are derived from `bright` state via local variables (`bg`, `card`, `text`, `muted`, `accent`), so the theme affects the entire player. + +## Deployment + +Serve the `public/` directory. See README.md for Caddy config examples, including subpath mounting at `/payplay`. + +## Adding songs + +- **Procedural:** Add entry to `SONGS[]` with `gen(synth, time)` callback, `wave` type, `bpm`, `duration`, and `color`. +- **MP3:** Put file in `public/audio/`, add entry with `url: "/audio/filename.mp3"` and `duration` (in seconds). Use `ffprobe` to get duration. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2403265 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# Pay2Play! — The Worst Music Player + +A satirical music player where every interaction is paywalled. +Pause? That's $0.01. Resume? Separate charge. Turn off repeat? +That costs *more* than turning it on. + +Part of [donothireus.com](https://donothireus.com). + +## Quick start + +The entire player is a single HTML file with no build step. + +```bash +cd public/ +python3 -m http.server 8080 +# open http://localhost:8080 +``` + +Or with any other static file server (caddy, nginx, etc). + +## Project structure + +``` +public/ +├── index.html # The complete player (React + Tone.js via CDN) +└── audio/ # Drop MP3 files here for real music + └── .gitkeep +``` + +## Deploying to donothireus.com + +Just serve the `public/` directory. If you're using Caddy +(which I know you are), something like: + +``` +donothireus.com { + root * /srv/donothireus/public + file_server + encode gzip +} +``` + +Or to put it at a subpath like `/payplay`: + +``` +donothireus.com { + handle /payplay/* { + root * /srv/donothireus/payplay/public + uri strip_prefix /payplay + file_server + } +} +``` + +## Audio: procedural vs real music + +By default the player uses Tone.js to generate procedural synth +loops — no external audio files needed. This is funny on its own +("even the songs are cheaply made") but you can swap in real +CC-licensed tracks. + +### Switching to self-hosted MP3s + +1. Download CC-BY licensed MP3s (see sources below) +2. Put them in `public/audio/` +3. Edit the `SONGS` array in `index.html`, adding a `url` property: + +```javascript +// Before (procedural): +{ title: "Bureaucratic Sunrise", artist: "The Paywalls", + duration: 90, bpm: 120, + gen: (s,t) => { ... }, + wave: "triangle", color: "#e74c3c" }, + +// After (real audio): +{ title: "Sneaky Snitch", artist: "Kevin MacLeod", + duration: 120, + url: "/audio/sneaky-snitch.mp3", + color: "#e74c3c" }, +``` + +When a song has a `url` property, the player uses HTML5 Audio +instead of Tone.js. You can mix and match — some songs procedural, +some real MP3s. + +Set `duration` to the actual track length in seconds. The color +is used for the progress bar and visualizer. + +### Where to get CC-BY music + +All of these are free to use with attribution (CC-BY or CC0): + +**Kevin MacLeod / Incompetech** (CC-BY 4.0) +- https://incompetech.com/music/royalty-free/ +- Thousands of tracks, well-known, easy to search by mood/genre +- Attribution: "Title" Kevin MacLeod (incompetech.com) + Licensed under Creative Commons: By Attribution 4.0 +- Also mirrored on archive.org: https://archive.org/details/Incompetech + +**Free Music Archive** (various CC licenses — filter for CC-BY) +- https://freemusicarchive.org/ +- Filter by license type, download MP3s directly +- Check each track's specific license + +**SampleSwap** (CC-BY-NC-SA for most tracks) +- https://sampleswap.org/mp3/creative-commons/free-music.php +- 320kbps MP3s, various genres + +**Pixabay Music** (Pixabay License — free, no attribution required) +- https://pixabay.com/music/ +- No API key needed for downloads, but no hotlinking + +### Download and host workflow + +```bash +# Example: grab a Kevin MacLeod track +cd public/audio/ +wget -O sneaky-snitch.mp3 "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Sneaky%20Snitch.mp3" + +# Get the duration in seconds (needs ffprobe) +ffprobe -v quiet -show_entries format=duration -of csv=p=0 sneaky-snitch.mp3 +# output: 120.123456 (use 120) +``` + +Then update the SONGS array in index.html with the title, artist, +duration, url, and color. + +### Attribution + +If using CC-BY music, add attribution. The fine print at the +bottom of the player is a good place, or add a separate +credits section. Kevin MacLeod's required format: + +> "Track Title" Kevin MacLeod (incompetech.com) +> Licensed under Creative Commons: By Attribution 4.0 +> https://creativecommons.org/licenses/by/4.0/ + +## How it works + +- React 18 loaded from CDN, JSX compiled by Babel standalone +- Tone.js for procedural synth audio (no build step needed) +- HTML5 Audio API for self-hosted MP3 playback +- Zero dependencies to install, zero build tools +- All state is client-side, nothing persisted + +The starting wallet is randomized between $0.50 and $10.00 +each page load. The +$10 button lets people keep exploring +all the paywalls. + +## License + +Do whatever you want with this. It's a joke. diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..0b6b532 --- /dev/null +++ b/public/app.js @@ -0,0 +1,589 @@ +const { useState, useEffect, useRef, useCallback } = React; + +// ============================================================ +// SONG DEFINITIONS +// +// MODE 1 (default): Procedurally generated via Tone.js +// MODE 2 (production): Self-hosted MP3s in /audio/ +// +// To switch to real audio, set url: "/audio/yourfile.mp3" +// on each song and the player will use HTML5 Audio instead. +// See README.md for details on sourcing CC-BY music. +// ============================================================ +const SONGS = [ + { title: "Bureaucratic Sunrise", artist: "The Paywalls", duration: 90, bpm: 120, + gen: (s,t) => { ["C4","E4","G4","B4","C5","B4","G4","E4"].forEach((n,i) => s.triggerAttackRelease(n,"8n",t+i*0.25)); }, + wave: "triangle", color: "#e74c3c" }, + { title: "Terms of Sorrow", artist: "Subscription Model", duration: 105, bpm: 72, + gen: (s,t) => { ["A3","C4","E4","A4","G4","E4","D4","C4"].forEach((n,i) => s.triggerAttackRelease(n,"4n",t+i*0.5)); }, + wave: "sine", color: "#9b59b6" }, + { title: "Micro Transaction Blues", artist: "Nickel & Dime", duration: 75, bpm: 140, + gen: (s,t) => { ["E3","G3","A3","B3","D4","B3","A3","G3","E3","D3","E3","G3"].forEach((n,i) => s.triggerAttackRelease(n,"16n",t+i*0.18)); }, + wave: "sawtooth", color: "#f39c12" }, + { title: "404 Feeling Not Found", artist: "Errorcode", duration: 80, bpm: 100, + gen: (s,t) => { ["D4","F#4","A4","D5","C#5","A4","F#4","D4","C#4","D4"].forEach((n,i) => s.triggerAttackRelease(n,"8n.",t+i*0.35)); }, + wave: "square", color: "#3498db" }, + { title: "EULA Lullaby", artist: "Fine Print", duration: 120, bpm: 56, + gen: (s,t) => { ["G3","B3","D4","G4","F#4","D4","B3","A3","G3","B3"].forEach((n,i) => s.triggerAttackRelease(n,"2n",t+i*0.7)); }, + wave: "sine", color: "#1abc9c" }, + { title: "Wallet on Fire", artist: "DJ Overdraft", duration: 68, bpm: 155, + gen: (s,t) => { ["C3","Eb3","G3","Bb3","C4","Eb4","G4","Eb4","C4","Bb3","G3","Eb3","C3"].forEach((n,i) => s.triggerAttackRelease(n,"16n",t+i*0.15)); }, + wave: "sawtooth", color: "#e67e22" }, +]; + +const SUBS = { + pause: { name:"Pause Plus™", price:"$2.99/mo", micro:0.01, desc:"The freedom to stop. Whenever you want.", color:"#e74c3c", dismiss:"I like surprises" }, + resume: { name:"Resume Rights™", price:"$2.49/mo", micro:0.02, desc:"You paused. Now un-pause. Two different things.", color:"#e67e22", dismiss:"Silence suits me" }, + repeat: { name:"Loop Loyalty™", price:"$5.99/mo", micro:0.03, desc:"Hear it again. And again. And again.", color:"#9b59b6", dismiss:"Once was enough" }, + unrepeat: { name:"Premium Loop Loyalty™", price:"$8.99/mo", micro:0.05, desc:"Escape the loop. If you dare.", color:"#8e44ad", dismiss:"I'll loop forever" }, + skip: { name:"Skip Sprint™", price:"$4.99/mo", micro:0.05, desc:"Life's too short. Songs shouldn't be.", color:"#3498db", dismiss:"I'll wait it out" }, + prev: { name:"Nostalgia Pass™", price:"$6.99/mo", micro:0.10, desc:"Go back. Premium memories cost more.", color:"#1abc9c", dismiss:"The past is gone" }, + volume: { name:"Volume Freedom™", price:"$3.99/mo", micro:0.08, desc:"Break free from the 23-47% corridor.", color:"#f39c12", dismiss:"35% is fine actually" }, + seek: { name:"Precision Seek™", price:"$2.99/mo", micro:0.04, desc:"Go where you mean to go. Radical concept.", color:"#e74c3c", dismiss:"I'll guess" }, + shuffle: { name:"Smart Shuffle™", price:"$6.99/mo", micro:0.06, desc:"Actually random. Not weighted against you.", color:"#2ecc71", dismiss:"Order is fine" }, + queue: { name:"Queue Capacity Pack™", price:"$1.49/3 slots",micro:0.50, desc:"Your queue is full (1 song max). Expand it.", color:"#e74c3c", dismiss:"One song is plenty" }, + dark: { name:"Dark Mode™", price:"$0.99/mo", micro:0.99, desc:"Easy on the eyes. Hard on the wallet.", color:"#34495e", dismiss:"I love bright screens" }, + lyrics: { name:"Full Verse™", price:"$3.49/mo", micro:0.07, desc:"See ALL the words. Not just some of them.", color:"#e74c3c", dismiss:"I'll guess the lyrics" }, + continue: { name:"Continuity Plan™", price:"$4.99/mo", micro:0.02, desc:"Hear beyond 3 seconds. Revolutionary.", color:"#16a085", dismiss:"3 seconds is enough" }, + exit: { name:"Silent Exit™", price:"$1.99/mo", micro:0.25, desc:"Close the app without an unskippable ad.", color:"#c0392b", dismiss:"I love ads actually" }, + eq: { name:"EQ Preset Pack™", price:"$0.50/preset",micro:0.50, desc:"Shape your sound. Shape your spending.", color:"#d35400", dismiss:"Flat is a vibe" }, + bluetooth: { name:"Wireless Freedom Pass™", price:"$2.99/mo", micro:0.15, desc:"Cut the cord. Not the cost.", color:"#2980b9", dismiss:"Wires are retro" }, +}; + +const LYRICS_DATA = [ + [["We","charge","for","every","click"],["Your","wallet","feels","the","pain"],["Subscribe","or","live","in","silence"],["The","music","never","stops"],["Unless","you","pay","to","pause"]], + [["Somewhere","deep","inside","the","EULA"],["A","clause","awaits","your","soul"],["Terms","conditions","fine","print","sorrow"],["Accept","decline","accept","forever"],["Your","data","sings","for","us"]], + [["Micro","transactions","falling","like","rain"],["A","penny","here","a","dollar","there"],["Your","balance","slowly","fading"],["But","music","keeps","on","playing"],["Three","seconds","at","a","time"]], + [["Error","four","oh","four","feeling"],["Not","found","inside","this","app"],["Pay","twenty","five","cents","learn"],["What","went","wrong","this","time"],["Spoiler","it","was","you"]], + [["Hush","little","user","don't","say","word"],["Papa's","gonna","buy","premium","tier"],["And","if","that","premium","runs","out"],["Papa's","gonna","charge","card","again"],["Sleep","tight","wallet","light"]], + [["Fire","fire","wallet","burning","bright"],["Every","button","costs","a","fee"],["The","flames","of","monetization"],["Light","the","path","to","bankruptcy"],["Dance","dance","overdraft","dance"]], +]; + +const AD_COPY = [ + "TIRED OF AFFORDABLE MUSIC? Try Pay2Play! Premium Ultra™ — now with 40% more paywalls!", + "Did you know? The average Pay2Play! user spends $847/year. Be above average.", + "EXCLUSIVE: Unlock the ability to unlock abilities with our Meta-Unlock Bundle™!", + "Pay $19.99/mo to remove this ad. Or $29.99/mo to also remove the next one.", + "Your listening data has been sold to 47 advertisers. Upgrade to Privacy Plus™ ($12.99/mo) to make it 46.", + "Pay2Play! Kids™ — Teach your children the value of money. One paywall at a time.", + "RATE US 5 STARS and receive absolutely nothing in return!", + "Pay2Play! was voted #1 Music Player by people who had no other choice.", +]; + +const fmt = s => `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,"0")}`; + +// Cha-ching sound effect via Tone.js +const playChaChing = async () => { + try { + await Tone.start(); + const s = new Tone.Synth({ + oscillator: { type: "triangle" }, + envelope: { attack: 0.001, decay: 0.12, sustain: 0, release: 0.08 }, + volume: -10 + }).toDestination(); + const now = Tone.now(); + s.triggerAttackRelease("C7", "32n", now); + s.triggerAttackRelease("E7", "32n", now + 0.08); + setTimeout(() => s.dispose(), 500); + } catch(e) {} +}; + +// ============================================================ +// COMPONENTS +// ============================================================ + +function WelcomeBanner({ balance, onStart }) { + return ( +
+
+
🎵💸
+

Pay2Play!

+

+ Every feature costs money. Pause, skip, volume — all paywalled. + Your complimentary balance: ${balance.toFixed(2)} +

+ +
Part of donothireus.com
+
+
+ ); +} + +function Modal({ sub, phase, successText, onSub, onMicro, onClose, onTopUp }) { + if (!sub) return null; + const s = SUBS[sub]; + return ( +
+
+ + {phase === "success" ? ( + <> +
+
Congratulations!
+
{successText}
+ + ) : phase === "broke" ? ( + <> +
💸
+
Insufficient Funds
+
Even your wallet is on a free tier.
+ + + + ) : ( + <> +
⚡ FEATURE LOCKED ⚡
+
{s.name}
+
{s.desc}
+ + + + + )} + +
+
+ ); +} + +function ExitAd({ onDone }) { + const [c, setC] = useState(15); + const [adIdx, setAdIdx] = useState(() => Math.floor(Math.random() * AD_COPY.length)); + const onDoneR = useRef(onDone); + onDoneR.current = onDone; + useEffect(() => { if(c<=0){onDoneR.current();return;} const t=setTimeout(()=>setC(x=>x-1),1000); return()=>clearTimeout(t); }, [c]); + useEffect(() => { const t=setInterval(()=>setAdIdx(i=>(i+1)%AD_COPY.length),3500); return()=>clearInterval(t); }, []); + return ( +
+
UNSKIPPABLE AD
+
{c}s
+
{AD_COPY[adIdx]}
+
Subscribe to Silent Exit™ ($1.99/mo) to skip this
+
+ ); +} + +function CBtn({ onClick, color, size=28, children, label }) { + return ( + + ); +} + +// ============================================================ +// MAIN APP +// ============================================================ + +function PayPlay() { + const [ci, setCi] = useState(0); + const [on, setOn] = useState(false); + const [prog, setProg] = useState(0); + const [vol, setVol] = useState(35); + const [rep, setRep] = useState(false); + const [shuf, setShuf] = useState(false); + const [modal, setModal] = useState(null); + const [modalPhase, setModalPhase] = useState("choose"); + const [successText, setSuccessText] = useState(""); + const [bal, setBal] = useState(() => +(0.50 + Math.random() * 9.50).toFixed(2)); + const [subs, setSubs_] = useState({}); + const [toastMsg, setToastMsg] = useState(""); + const [toastColor, setToastColor] = useState("#00ee44"); + const [toastVis, setToastVis] = useState(false); + const [lyr, setLyr] = useState(false); + const [showExit, setShowExit] = useState(false); + const [showAd, setShowAd] = useState(false); + const [spent, setSpent] = useState(0); + const [acts, setActs] = useState(0); + const [toneOk, setToneOk] = useState(false); + const [bars, setBars] = useState(Array(16).fill(2)); + const [welcome, setWelcome] = useState(true); + const [bright, setBright] = useState(false); + const [balKey, setBalKey] = useState(0); + + const synthR = useRef(null); + const loopR = useRef(null); + const progT = useRef(null); + const visT = useRef(null); + const hitLimit = useRef(false); + const audioR = useRef(null); + const milestoneR = useRef(new Set()); + + const song = SONGS[ci]; + const has = k => subs[k]; + + // Theme-aware colors (toggle when Dark Mode joke triggers bright mode) + const bg = bright ? "#f5f5f0" : "#0d0d1a"; + const card = bright ? "#e8e8f0" : "#1c1c32"; + const text = bright ? "#111" : "#fff"; + const muted = bright ? "#555" : "#7788aa"; + const accent= bright ? "#00aa33" : "#00ee44"; + + // Sync body background with theme + useEffect(() => { document.body.style.background = bg; }, [bright]); + + // Spending milestones + useEffect(() => { + const checks = [[1,"Over $1 spent. Getting started."],[5,"$5 down. Commitment issues?"],[10,"$10 gone. You could buy actual music."],[20,"$20. Seek professional help."]]; + checks.forEach(([threshold, msg]) => { + if (spent >= threshold && !milestoneR.current.has(threshold)) { + milestoneR.current.add(threshold); + setTimeout(() => flash(msg, "#f39c12"), 600); + } + }); + }, [spent]); + + // Color-coded toasts: red for charges, green for unlocks, yellow for info + const flash = useCallback((msg, color = "#00ee44") => { + setToastMsg(msg); setToastColor(color); setToastVis(true); + setTimeout(() => setToastVis(false), 2500); + }, []); + + // Charge with cha-ching sound + balance flash animation + const charge = useCallback((a, l) => { + setBal(b => Math.max(0, +(b - a).toFixed(2))); + setSpent(t => +(t + a).toFixed(2)); + setActs(x => x + 1); + setBalKey(k => k + 1); + flash(`-$${a.toFixed(2)} • ${l}`, "#e74c3c"); + playChaChing(); + }, [flash]); + + const tryAct = (k, fn) => { + if (has(k)) { fn(); return; } + setModal({ key: k, action: fn }); + setModalPhase("choose"); + }; + + // Modal: subscribe (full price) + const onSubClick = () => { + if (!modal) return; + const s = SUBS[modal.key], p = parseFloat(s.price.replace(/[^0-9.]/g, "")); + if (bal < p) { setModalPhase("broke"); return; } + charge(p, s.name); + setSubs_(prev => ({ ...prev, [modal.key]: true })); + setSuccessText(`${s.name} is yours. Was it worth it? (It wasn't.)`); + setModalPhase("success"); + setTimeout(() => { modal.action(); setModal(null); setModalPhase("choose"); }, 1800); + // Humor: pause and resume are separate products + if (modal.key === "pause") setTimeout(() => flash("Note: unpausing requires Resume Rights™", "#f39c12"), 2500); + }; + + // Modal: one-time micro-transaction + const onMicroClick = () => { + if (!modal) return; + const s = SUBS[modal.key]; + if (bal < s.micro) { setModalPhase("broke"); return; } + charge(s.micro, `${s.name} (1x)`); + setSuccessText(`One-time access. That's $${s.micro.toFixed(2)} you'll never see again.`); + setModalPhase("success"); + setTimeout(() => { modal.action(); setModal(null); setModalPhase("choose"); }, 1500); + }; + + // Modal: top-up from insufficient-funds screen (with fee) + const handleModalTopUp = () => { + setBal(b => +(b + 9.50).toFixed(2)); + setSpent(s => +(s + 0.50).toFixed(2)); + setActs(x => x + 1); + setBalKey(k => k + 1); + flash("+$9.50 ($0.50 processing fee)", "#f39c12"); + playChaChing(); + setModalPhase("choose"); + }; + + // ---- Audio engine ---- + + const initTone = useCallback(async () => { if (!toneOk) { await Tone.start(); setToneOk(true); } }, [toneOk]); + + const stopAudio = useCallback(() => { + if (audioR.current) { audioR.current.pause(); audioR.current.currentTime = 0; audioR.current = null; } + if (loopR.current) { loopR.current.stop(); loopR.current.dispose(); loopR.current = null; } + if (synthR.current) { synthR.current.dispose(); synthR.current = null; } + try { Tone.getTransport().stop(); } catch(e) {} + clearInterval(progT.current); clearInterval(visT.current); + setBars(Array(16).fill(2)); + }, []); + + const startAudio = useCallback(async (idx, from = 0) => { + stopAudio(); hitLimit.current = false; + const s = SONGS[idx]; + + // HTML5 Audio for self-hosted MP3s + if (s.url) { + const audio = new Audio(s.url); + audio.currentTime = from; + audio.volume = vol / 100; + audioR.current = audio; + audio.play().catch(() => {}); + let p = from; + progT.current = setInterval(() => { + p = Math.floor(audio.currentTime); + setProg(p); + if (p >= s.duration || audio.ended) { clearInterval(progT.current); stopAudio(); setOn(false); setProg(0); } + }, 1000); + visT.current = setInterval(() => setBars(b => b.map(() => 2 + Math.random() * 28)), 150); + return; + } + + // Tone.js procedural synth + await initTone(); + const synth = new Tone.Synth({ oscillator: { type: s.wave }, envelope: { attack: 0.05, decay: 0.2, sustain: 0.4, release: 0.8 }, volume: -30 + vol * 0.3 }).toDestination(); + synthR.current = synth; + const loop = new Tone.Loop(time => s.gen(synth, time), 2.5); + loopR.current = loop; + Tone.getTransport().bpm.value = s.bpm; + Tone.getTransport().start(); loop.start(0); + let p = from; + progT.current = setInterval(() => { p += 1; setProg(p); if (p >= s.duration) { clearInterval(progT.current); stopAudio(); setOn(false); setProg(0); } }, 1000); + visT.current = setInterval(() => setBars(b => b.map(() => 2 + Math.random() * 28)), 150); + }, [initTone, stopAudio, vol]); + + useEffect(() => () => stopAudio(), [stopAudio]); + + // 3-second free limit with audio degradation at 2s + useEffect(() => { + if (!on || has("continue")) return; + // Muffle audio 1 second before cutoff + if (prog === 2) { + if (synthR.current) synthR.current.volume.rampTo(-40, 0.8); + if (audioR.current) audioR.current.volume = 0.1; + } + // Hard cutoff at 3 seconds + if (prog >= 3 && !hitLimit.current) { + hitLimit.current = true; stopAudio(); setOn(false); + setModal({ key: "continue", action: () => { hitLimit.current = false; setOn(true); startAudio(ci, prog); } }); + setModalPhase("choose"); + } + }, [prog, on, subs]); + + // ---- User actions ---- + + const handlePlay = async () => { + if (on) { tryAct("pause", () => { stopAudio(); setOn(false); }); } + else { + if (prog > 0 && !has("continue")) { + tryAct("resume", () => { setOn(true); startAudio(ci, prog); }); + } else { + if (!SONGS[ci].url) await initTone(); + hitLimit.current = false; setOn(true); startAudio(ci, prog); + } + } + }; + + const changeSong = idx => { stopAudio(); setCi(idx); setProg(0); setOn(false); hitLimit.current = false; setBars(Array(16).fill(2)); }; + + const handleSeek = e => { + const r = e.currentTarget.getBoundingClientRect(), pct = (e.clientX - r.left) / r.width, tgt = Math.floor(pct * song.duration); + if (has("seek")) { setProg(tgt); if (on) { stopAudio(); startAudio(ci, tgt); } } + else { setProg(Math.max(0, Math.min(song.duration, tgt + Math.floor(Math.random() * 40) - 15))); flash("Seek landed somewhere in the general vicinity.", "#f39c12"); } + }; + + const handleSeekKey = e => { + let tgt = prog; + if (e.key === "ArrowRight") tgt = Math.min(song.duration, prog + 5); + else if (e.key === "ArrowLeft") tgt = Math.max(0, prog - 5); + else return; + e.preventDefault(); + if (has("seek")) { setProg(tgt); if (on) { stopAudio(); startAudio(ci, tgt); } } + else { setProg(Math.max(0, Math.min(song.duration, tgt + Math.floor(Math.random() * 40) - 15))); flash("Seek precision is approximate. Very approximate.", "#f39c12"); } + }; + + const handleVol = e => { + const v = parseInt(e.target.value); + if (!has("volume") && (v < 23 || v > 47)) { tryAct("volume", () => { setVol(v); if (synthR.current) synthR.current.volume.value = -30 + v * 0.3; if (audioR.current) audioR.current.volume = v / 100; }); return; } + setVol(v); if (synthR.current) synthR.current.volume.value = -30 + v * 0.3; if (audioR.current) audioR.current.volume = v / 100; + }; + + const shareReceipt = () => { + const lines = [ + "Pay2Play! Receipt", "", + `Total spent: $${spent.toFixed(2)}`, + `Actions: ${acts} (${acts > 0 ? `$${(spent/acts).toFixed(2)}/action` : "free"})`, + `Subscriptions: ${Object.keys(subs).length}`, + Object.keys(subs).length > 0 ? Object.keys(subs).map(k => SUBS[k].name).join(", ") : "None (cheapskate)", + "", "Try it: donothireus.com/payplay" + ]; + if (navigator.clipboard) { + navigator.clipboard.writeText(lines.join("\n")).then(() => flash("Receipt copied! Share your shame.", accent)); + } + }; + + // Volume slider gradient: shows free zone (23-47%) when not subscribed + const volBg = has("volume") + ? `linear-gradient(to right,${song.color} ${vol}%,${card} ${vol}%)` + : `linear-gradient(to right,${card} 0%,${card} 23%,${song.color} 23%,${song.color} ${vol}%,${song.color}33 ${vol}%,${song.color}33 47%,${card} 47%)`; + + const curLyrics = LYRICS_DATA[ci] || LYRICS_DATA[0]; + const toastTextColor = ["#e74c3c","#f39c12"].includes(toastColor) ? "#fff" : "#000"; + + // ============================================================ + // RENDER + // ============================================================ + return ( + <> + {welcome && setWelcome(false)} />} + {showAd && { setShowAd(false); setShowExit(true); }} />} + + {/* Exit / receipt screen */} + {showExit && ( +
+
👋
+
Thanks for listening!
+
Total spent: ${spent.toFixed(2)}
+
{acts} actions • ${acts>0?(spent/acts).toFixed(2):"0.00"}/action
+
{Object.keys(subs).length} subscription{Object.keys(subs).length !== 1 ? "s" : ""}
+
+ + +
+
+ )} + + { setModal(null); setModalPhase("choose"); }} onTopUp={handleModalTopUp} /> + + {/* Toast notification */} +
{toastMsg}
+ + {/* ---- Main player ---- */} +
+ + {/* Sticky header with balance */} +
+
+
PAY2PLAY!
+
The Worst Music Player
+
+
+
0?"balFlash 0.6s ease":"none",transition:"background 0.3s ease"}}>💰 ${bal.toFixed(2)}
+ +
+
+ + {/* Active subscriptions marquee (speed scales with count) */} + {Object.keys(subs).length > 0 && ( +
+
+ {Object.keys(subs).map(k => ✓ {SUBS[k].name})} +
+
+ )} + + {/* Visualizer (responsive width) */} +
+ {bars.map((h, i) =>
)} + {/* Vinyl disc — freezes in place on pause */} +
+
+
+
FREE TIER
+ {/* Free-listening countdown badge */} + {on && !has("continue") && prog < 3 && ( +
+ FREE {3 - prog}s +
+ )} + {!toneOk && !SONGS[0].url &&
Click play to start audio
} +
+ + {/* Song info */} +
+

{song.title}

+
{song.artist}
+
+ + {/* Seek bar — larger touch target, keyboard accessible */} +
+
+
+
+
+
+
+ {fmt(prog)} + + {!has("seek") && 🔒 approx.} + {fmt(song.duration)} + +
+
+ + {/* Transport controls */} +
+ tryAct("shuffle", () => setShuf(s => !s))} color={shuf ? accent : muted} label="Shuffle">🔀 + tryAct("prev", () => changeSong((ci - 1 + SONGS.length) % SONGS.length))} color={muted} size={36} label="Previous">⏮ + + tryAct("skip", () => changeSong((ci + 1) % SONGS.length))} color={muted} size={36} label="Next">⏭ + rep ? tryAct("unrepeat", () => setRep(false)) : tryAct("repeat", () => setRep(true))} color={rep ? accent : muted} label="Repeat">🔁 +
+ + {/* Volume slider with free-zone indicator */} +
+ 🔈 + + {vol}% + {!has("volume") && 🔒 23-47%} +
+ + {/* Feature buttons (responsive grid) */} +
+ {[ + { l:"Lyrics", i:"📝", a:() => tryAct("lyrics", () => setLyr(x => !x)) }, + { l:"EQ", i:"🎛️", a:() => tryAct("eq", () => flash("EQ: Flat ($0.50). Bass Boost: another $0.50. Each.", "#f39c12")) }, + { l:"Bluetooth", i:"📡", a:() => tryAct("bluetooth", () => flash("Bluetooth connected... for now.", "#3498db")) }, + { l:"Dark Mode", i:"🌙", a:() => tryAct("dark", () => { setBright(b => !b); flash(!bright ? "Welcome to Light Mode™. Suffering." : "Dark Mode restored. You paid for the default.", !bright ? "#f39c12" : "#00ee44"); }) }, + { l:"Queue +", i:"📋", a:() => tryAct("queue", () => flash("Queue expanded to 4 slots! (3 are decorative)", "#00ee44")) }, + ].map(f => )} +
+ + {/* Lyrics panel — animated slide */} +
+
+
LYRICS {!has("lyrics") && "— REDACTED VERSION"}
+ {curLyrics.map((line, i) => ( +
+ {has("lyrics") ? line.join(" ") : line.map((w, j) => {j % 3 === 0 ? w : █████}{" "})} +
+ ))} +
+
+ + {/* Up Next — card styled, with price on lock icons */} +
+

UP NEXT

+
+ {SONGS.map((s, i) => i !== ci && ( +
tryAct("skip", () => changeSong(i))} style={{display:"flex",alignItems:"center",gap:12,padding:"10px 20px",cursor:"pointer",borderBottom:`1px solid ${bright?"#ddd":muted+"11"}`}}> +
+
+
{s.title}
+
{s.artist}
+
+
{fmt(s.duration)}
+
🔒 ${SUBS.skip.micro.toFixed(2)}
+
+ ))} +
+
+ + {/* Session Economics */} +
+

SESSION ECONOMICS

+ {[["Spent",`$${spent.toFixed(2)}`,"#e74c3c"],["Subscriptions",Object.keys(subs).length,"#f39c12"],["Actions",acts,text],["Cost/action",acts>0?`$${(spent/acts).toFixed(2)}`:"—","#e74c3c"]].map(([l,v,c]) => ( +
{l}{v}
+ ))} +
+ + + +
+ Pay2Play! v6.6.6 • Music procedurally generated — even the songs are cheaply made • + Refunds available for $4.99 processing fee • © 2026 Suffering Inc. +
+
+ + ); +} + +ReactDOM.createRoot(document.getElementById("root")).render(); diff --git a/public/audio/.gitkeep b/public/audio/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..a7ee018 --- /dev/null +++ b/public/index.html @@ -0,0 +1,26 @@ + + + + + +Pay2Play! — The Worst Music Player + + + + + + + + + + + + +
+ + + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..362f3be --- /dev/null +++ b/public/style.css @@ -0,0 +1,29 @@ +/* Pay2Play! — Base */ +* { box-sizing: border-box; margin: 0; padding: 0; } +body { background: #0d0d1a; font-family: 'IBM Plex Mono', monospace; } + +/* Animations */ +@keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:.5 } } +@keyframes spin { from { transform:rotate(0) } to { transform:rotate(360deg) } } +@keyframes marquee { 0% { transform:translateX(100%) } 100% { transform:translateX(-100%) } } +@keyframes modalIn { from { opacity:0; transform:scale(0.9) } to { opacity:1; transform:scale(1) } } +@keyframes balFlash { 0% { color:#00ee44 } 20% { color:#e74c3c } 50% { color:#ff6b6b } 100% { color:#00ee44 } } +@keyframes countPulse { 0%,100% { transform:scale(1) } 50% { transform:scale(1.1) } } +@keyframes successPop { 0% { transform:scale(0.5);opacity:0 } 50% { transform:scale(1.2) } 100% { transform:scale(1);opacity:1 } } + +/* Range input — larger thumb for touch targets */ +input[type=range] { -webkit-appearance:none; appearance:none; height:6px; border-radius:3px; outline:none; } +input[type=range]::-webkit-slider-thumb { + -webkit-appearance:none; width:28px; height:28px; border-radius:50%; + background:#00ee44; cursor:pointer; box-shadow:0 0 8px rgba(0,238,68,0.3); +} + +/* Interactive states for all buttons */ +button { transition: opacity 0.15s ease, transform 0.1s ease; } +button:hover { opacity: 0.85 !important; } +button:active { transform: scale(0.96) !important; } +:focus-visible { outline: 2px solid #00ee44; outline-offset: 2px; } + +/* Queue item hover */ +.q-item { transition: background 0.15s ease; } +.q-item:hover { background: rgba(255,255,255,0.04) !important; }