Add new paywalled features, polish, and favicon

New subscriptions: Like ($0.25, does nothing), HD Audio (removes
low-pass filter from synth), Recently Played (shows current song),
Cancellation Processing ($4.99 to unsubscribe).

Polish: welcome banner fades out smoothly, exit ad plays an
annoying jingle via Tone.js, 64kbps Suffering Quality badge
shown when HD Audio not purchased, favicon added.

Moved og-card.svg source to tools/ (not needed in public/).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-18 09:06:36 +01:00
commit 5ba61db478
6 changed files with 46 additions and 5 deletions

View file

@ -48,6 +48,10 @@ const SUBS = {
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" }, 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" }, 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" }, 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" },
like: { name:"Appreciation License™", price:"$1.99/mo", micro:0.25, desc:"Express feelings. For a fee.", color:"#e74c3c", dismiss:"I feel nothing" },
hd: { name:"HD Audio™", price:"$3.99/mo", micro:0.20, desc:"Upgrade from 64kbps Suffering Quality™.", color:"#9b59b6", dismiss:"Lo-fi is a genre" },
history: { name:"Recently Played™", price:"$2.49/mo", micro:0.15, desc:"We know what you listened to. Pay to find out too.", color:"#1abc9c", dismiss:"I'll forget" },
unsub: { name:"Cancellation Processing™",price:"$4.99/unsub", micro:4.99, desc:"Freedom from commitment. Commitment from your wallet.", color:"#c0392b", dismiss:"I'll keep paying" },
}; };
const LYRICS_DATA = [ const LYRICS_DATA = [
@ -88,13 +92,32 @@ const playChaChing = async () => {
} catch(e) {} } catch(e) {}
}; };
// Annoying jingle for the exit ad
const playAdJingle = async () => {
try {
await Tone.start();
const s = new Tone.Synth({
oscillator: { type: "square" },
envelope: { attack: 0.01, decay: 0.1, sustain: 0.3, release: 0.1 },
volume: -15
}).toDestination();
const now = Tone.now();
["C5","E5","G5","C6","G5","E5","C5"].forEach((n, i) =>
s.triggerAttackRelease(n, "16n", now + i * 0.12)
);
setTimeout(() => s.dispose(), 2000);
} catch(e) {}
};
// ============================================================ // ============================================================
// COMPONENTS // COMPONENTS
// ============================================================ // ============================================================
function WelcomeBanner({ balance, onStart }) { function WelcomeBanner({ balance, onStart }) {
const [fading, setFading] = useState(false);
const dismiss = () => { setFading(true); setTimeout(onStart, 400); };
return ( return (
<div style={{position:"fixed",inset:0,background:"rgba(0,0,0,0.95)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:3000,backdropFilter:"blur(8px)"}}> <div style={{position:"fixed",inset:0,background:"rgba(0,0,0,0.95)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:3000,backdropFilter:"blur(8px)",animation:fading?"fadeOut 0.4s ease forwards":"none"}}>
<div style={{textAlign:"center",maxWidth:340,padding:"40px 28px"}}> <div style={{textAlign:"center",maxWidth:340,padding:"40px 28px"}}>
<div style={{fontSize:48,marginBottom:16}}>🎵💸</div> <div style={{fontSize:48,marginBottom:16}}>🎵💸</div>
<h1 style={{fontSize:36,fontWeight:900,fontFamily:"'Anybody',system-ui,sans-serif",color:"#fff",marginBottom:12,letterSpacing:-1}}>Pay2Play!</h1> <h1 style={{fontSize:36,fontWeight:900,fontFamily:"'Anybody',system-ui,sans-serif",color:"#fff",marginBottom:12,letterSpacing:-1}}>Pay2Play!</h1>
@ -102,7 +125,7 @@ function WelcomeBanner({ balance, onStart }) {
Every feature costs money. Pause, skip, volume all paywalled. Every feature costs money. Pause, skip, volume all paywalled.
Your complimentary balance: <span style={{color:"#00ee44",fontWeight:700}}>${balance.toFixed(2)}</span> Your complimentary balance: <span style={{color:"#00ee44",fontWeight:700}}>${balance.toFixed(2)}</span>
</p> </p>
<button onClick={onStart} style={{background:"linear-gradient(135deg,#00ee44,#00aa33)",color:"#000",border:"none",borderRadius:12,padding:"16px 36px",fontSize:16,fontWeight:700,cursor:"pointer",boxShadow:"0 4px 20px rgba(0,238,68,0.3)"}}> <button onClick={dismiss} style={{background:"linear-gradient(135deg,#00ee44,#00aa33)",color:"#000",border:"none",borderRadius:12,padding:"16px 36px",fontSize:16,fontWeight:700,cursor:"pointer",boxShadow:"0 4px 20px rgba(0,238,68,0.3)"}}>
Start Spending Start Spending
</button> </button>
<div style={{fontSize:10,color:"#556677",marginTop:20}}>Part of donothireus.com</div> <div style={{fontSize:10,color:"#556677",marginTop:20}}>Part of donothireus.com</div>
@ -153,6 +176,7 @@ function ExitAd({ onDone }) {
const [adIdx, setAdIdx] = useState(() => Math.floor(Math.random() * AD_COPY.length)); const [adIdx, setAdIdx] = useState(() => Math.floor(Math.random() * AD_COPY.length));
const onDoneR = useRef(onDone); const onDoneR = useRef(onDone);
onDoneR.current = onDone; onDoneR.current = onDone;
useEffect(() => { playAdJingle(); }, []);
useEffect(() => { if(c<=0){onDoneR.current();return;} const t=setTimeout(()=>setC(x=>x-1),1000); return()=>clearTimeout(t); }, [c]); 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); }, []); useEffect(() => { const t=setInterval(()=>setAdIdx(i=>(i+1)%AD_COPY.length),3500); return()=>clearInterval(t); }, []);
return ( return (
@ -202,6 +226,8 @@ function PayPlay() {
const [welcome, setWelcome] = useState(true); const [welcome, setWelcome] = useState(true);
const [bright, setBright] = useState(false); const [bright, setBright] = useState(false);
const [balKey, setBalKey] = useState(0); const [balKey, setBalKey] = useState(0);
const [likes, setLikes] = useState(0);
const [hdAudio, setHdAudio] = useState(false);
const synthR = useRef(null); const synthR = useRef(null);
const loopR = useRef(null); const loopR = useRef(null);
@ -209,6 +235,7 @@ function PayPlay() {
const visT = useRef(null); const visT = useRef(null);
const hitLimit = useRef(false); const hitLimit = useRef(false);
const audioR = useRef(null); const audioR = useRef(null);
const filterR = useRef(null);
const milestoneR = useRef(new Set()); const milestoneR = useRef(new Set());
const song = SONGS[ci]; const song = SONGS[ci];
@ -301,6 +328,7 @@ function PayPlay() {
if (audioR.current) { audioR.current.pause(); audioR.current.currentTime = 0; audioR.current = null; } 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 (loopR.current) { loopR.current.stop(); loopR.current.dispose(); loopR.current = null; }
if (synthR.current) { synthR.current.dispose(); synthR.current = null; } if (synthR.current) { synthR.current.dispose(); synthR.current = null; }
if (filterR.current) { filterR.current.dispose(); filterR.current = null; }
try { Tone.getTransport().stop(); } catch(e) {} try { Tone.getTransport().stop(); } catch(e) {}
clearInterval(progT.current); clearInterval(visT.current); clearInterval(progT.current); clearInterval(visT.current);
setBars(Array(16).fill(2)); setBars(Array(16).fill(2));
@ -327,9 +355,11 @@ function PayPlay() {
return; return;
} }
// Tone.js procedural synth // Tone.js procedural synth (low-pass filtered unless HD Audio purchased)
await initTone(); 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(); const filter = new Tone.Filter(hdAudio ? 20000 : 1200, "lowpass").toDestination();
filterR.current = filter;
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 }).connect(filter);
synthR.current = synth; synthR.current = synth;
const loop = new Tone.Loop(time => s.gen(synth, time), 2.5); const loop = new Tone.Loop(time => s.gen(synth, time), 2.5);
loopR.current = loop; loopR.current = loop;
@ -338,7 +368,7 @@ function PayPlay() {
let p = from; let p = from;
progT.current = setInterval(() => { p += 1; setProg(p); if (p >= s.duration) { clearInterval(progT.current); stopAudio(); setOn(false); setProg(0); } }, 1000); 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); visT.current = setInterval(() => setBars(b => b.map(() => 2 + Math.random() * 28)), 150);
}, [initTone, stopAudio, vol]); }, [initTone, stopAudio, vol, hdAudio]);
useEffect(() => () => stopAudio(), [stopAudio]); useEffect(() => () => stopAudio(), [stopAudio]);
@ -525,11 +555,15 @@ function PayPlay() {
<span style={{fontSize:11,color:muted,minWidth:32}}>{vol}%</span> <span style={{fontSize:11,color:muted,minWidth:32}}>{vol}%</span>
{!has("volume") && <span style={{fontSize:9,color:"#aa4444"}}>🔒 23-47%</span>} {!has("volume") && <span style={{fontSize:9,color:"#aa4444"}}>🔒 23-47%</span>}
</div> </div>
{!hdAudio && !song.url && <div style={{textAlign:"center",fontSize:9,color:"#aa4444",marginTop:-16,marginBottom:8}}>🔊 64kbps Suffering Quality Upgrade to HD Audio</div>}
{/* Feature buttons (responsive grid) */} {/* Feature buttons (responsive grid) */}
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(100px,1fr))",gap:8,maxWidth:420,padding:"0 20px",marginBottom:16,width:"100%"}}> <div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(100px,1fr))",gap:8,maxWidth:420,padding:"0 20px",marginBottom:16,width:"100%"}}>
{[ {[
{ l:"Lyrics", i:"📝", a:() => tryAct("lyrics", () => setLyr(x => !x)) }, { l:"Lyrics", i:"📝", a:() => tryAct("lyrics", () => setLyr(x => !x)) },
{ l:"HD Audio", i:"🔊", a:() => tryAct("hd", () => { setHdAudio(true); if(filterR.current) filterR.current.frequency.rampTo(20000, 0.5); flash("HD Audio enabled. Hear the difference? (There isn't one.)", "#9b59b6"); }) },
{ l:"Like", i:"❤️", a:() => tryAct("like", () => { setLikes(l => l + 1); flash(`Liked! (${likes + 1} total). This does absolutely nothing.`, "#e74c3c"); }) },
{ l:"History", i:"📜", a:() => tryAct("history", () => flash(`Recently played: "${song.title}". That's it. You're still on it.`, "#1abc9c")) },
{ l:"EQ", i:"🎛️", a:() => tryAct("eq", () => flash("EQ: Flat ($0.50). Bass Boost: another $0.50. Each.", "#f39c12")) }, { 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:"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:"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"); }) },

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -13,6 +13,7 @@
<meta name="twitter:image" content="https://donothireus.com/pay2play/og-card.png"> <meta name="twitter:image" content="https://donothireus.com/pay2play/og-card.png">
<meta name="twitter:title" content="Pay2Play! — The Worst Music Player"> <meta name="twitter:title" content="Pay2Play! — The Worst Music Player">
<meta name="twitter:description" content="Every feature costs money. Even closing the app shows an unskippable ad."> <meta name="twitter:description" content="Every feature costs money. Even closing the app shows an unskippable ad.">
<link rel="icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Anybody:wght@400;700;900&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Anybody:wght@400;700;900&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">

View file

@ -10,6 +10,7 @@ body { background: #0d0d1a; font-family: 'IBM Plex Mono', monospace; }
@keyframes balFlash { 0% { color:#00ee44 } 20% { color:#e74c3c } 50% { color:#ff6b6b } 100% { color:#00ee44 } } @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 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 } } @keyframes successPop { 0% { transform:scale(0.5);opacity:0 } 50% { transform:scale(1.2) } 100% { transform:scale(1);opacity:1 } }
@keyframes fadeOut { from { opacity:1 } to { opacity:0 } }
/* Range input — larger thumb for touch targets */ /* 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-appearance:none; appearance:none; height:6px; border-radius:3px; outline:none; }

5
tools/favicon.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#0d0d1a"/>
<text x="32" y="44" text-anchor="middle" font-family="'Arial Black', sans-serif" font-weight="900" font-size="32" fill="#00ee44">$</text>
<circle cx="32" cy="32" r="26" fill="none" stroke="#00ee44" stroke-width="2" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 382 B

View file

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Before After
Before After