pay2play/public/app.js

594 lines
38 KiB
JavaScript
Raw Normal View History

const { useState, useEffect, useRef, useCallback } = React;
// ============================================================
// SONG DEFINITIONS
//
2026-03-17 17:09:50 +01:00
// Procedural synth songs are the built-in fallback. If
// audio/songs.json exists (generated by tools/probe-songs.py),
// those MP3-backed songs replace the builtins at startup.
// ============================================================
2026-03-17 17:09:50 +01:00
const BUILTIN_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" },
];
2026-03-17 17:09:50 +01:00
// Mutable song list — replaced by audio/songs.json if available
let SONGS = BUILTIN_SONGS;
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 (
<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={{textAlign:"center",maxWidth:340,padding:"40px 28px"}}>
<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>
<p style={{fontSize:13,color:"#8899aa",lineHeight:1.7,marginBottom:24}}>
Every feature costs money. Pause, skip, volume all paywalled.
Your complimentary balance: <span style={{color:"#00ee44",fontWeight:700}}>${balance.toFixed(2)}</span>
</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)"}}>
Start Spending
</button>
<div style={{fontSize:10,color:"#556677",marginTop:20}}>Part of donothireus.com</div>
</div>
</div>
);
}
function Modal({ sub, phase, successText, onSub, onMicro, onClose, onTopUp }) {
if (!sub) return null;
const s = SUBS[sub];
return (
<div style={{position:"fixed",inset:0,background:"rgba(0,0,0,0.85)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:1000,backdropFilter:"blur(4px)"}}>
<div style={{background:"#1a1a2e",border:`2px solid ${s.color}`,borderRadius:16,padding:"32px 28px",maxWidth:400,width:"90%",textAlign:"center",boxShadow:`0 0 40px ${s.color}44`,animation:"modalIn 0.25s ease-out"}}>
{phase === "success" ? (
<>
<div style={{fontSize:48,animation:"successPop 0.4s ease-out",marginBottom:12}}></div>
<div style={{fontSize:18,fontWeight:700,color:"#fff",fontFamily:"'Anybody',system-ui,sans-serif",marginBottom:8}}>Congratulations!</div>
<div style={{fontSize:13,color:"#8899aa",fontStyle:"italic"}}>{successText}</div>
</>
) : phase === "broke" ? (
<>
<div style={{fontSize:48,marginBottom:12}}>💸</div>
<div style={{fontSize:18,fontWeight:700,color:"#e74c3c",fontFamily:"'Anybody',system-ui,sans-serif",marginBottom:8}}>Insufficient Funds</div>
<div style={{fontSize:13,color:"#8899aa",marginBottom:24}}>Even your wallet is on a free tier.</div>
<button onClick={onTopUp} style={{width:"100%",padding:"14px 16px",background:"linear-gradient(135deg,#00ee44,#00aa33)",color:"#000",border:"none",borderRadius:10,fontSize:15,fontWeight:700,cursor:"pointer",marginBottom:12}}>Add $10 (+$0.50 fee)</button>
<button onClick={onClose} style={{background:"none",border:"none",color:"#667788",fontSize:11,cursor:"pointer",textDecoration:"underline"}}>Accept poverty</button>
</>
) : (
<>
<div style={{fontSize:12,fontWeight:700,letterSpacing:3,color:s.color,marginBottom:8}}> FEATURE LOCKED </div>
<div style={{fontSize:22,fontWeight:800,color:"#fff",marginBottom:6,fontFamily:"'Anybody',system-ui,sans-serif"}}>{s.name}</div>
<div style={{fontSize:14,color:"#8899aa",marginBottom:24,lineHeight:1.5,fontStyle:"italic"}}>{s.desc}</div>
<button onClick={onSub} style={{width:"100%",padding:"14px 16px",background:`linear-gradient(135deg,${s.color},${s.color}cc)`,color:"#fff",border:"none",borderRadius:10,fontSize:15,fontWeight:700,cursor:"pointer",marginBottom:10}}>SUBSCRIBE {s.price}</button>
<button onClick={onMicro} style={{width:"100%",padding:"12px 16px",background:"transparent",color:s.color,border:`1px solid ${s.color}66`,borderRadius:10,fontSize:13,cursor:"pointer",marginBottom:16,fontWeight:600}}>One-time use ${s.micro.toFixed(2)}</button>
<button onClick={onClose} style={{background:"none",border:"none",color:"#667788",fontSize:11,cursor:"pointer",textDecoration:"underline"}}>{s.dismiss}</button>
</>
)}
</div>
</div>
);
}
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 (
<div style={{position:"fixed",inset:0,background:"#000",display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",zIndex:2000,color:"#fff"}}>
<div style={{fontSize:11,letterSpacing:3,color:"#e74c3c",marginBottom:16}}>UNSKIPPABLE AD</div>
<div style={{fontSize:56,fontWeight:900,fontFamily:"'Anybody',system-ui,sans-serif",animation:"countPulse 1s ease infinite"}}>{c}s</div>
<div style={{fontSize:13,color:"#8899aa",marginTop:24,maxWidth:320,textAlign:"center",lineHeight:1.6,minHeight:80,padding:"0 20px"}}>{AD_COPY[adIdx]}</div>
<div style={{fontSize:11,color:"#334455",marginTop:32,fontStyle:"italic"}}>Subscribe to Silent Exit ($1.99/mo) to skip this</div>
</div>
);
}
function CBtn({ onClick, color, size=28, children, label }) {
return (
<button onClick={onClick} aria-label={label} style={{background:"none",border:"none",fontSize:size,cursor:"pointer",color,padding:8,minWidth:44,minHeight:44,display:"flex",alignItems:"center",justifyContent:"center",filter:`drop-shadow(0 0 4px ${color}44)`}}>
{children}
</button>
);
}
// ============================================================
// 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 && <WelcomeBanner balance={bal} onStart={() => setWelcome(false)} />}
{showAd && <ExitAd onDone={() => { setShowAd(false); setShowExit(true); }} />}
{/* Exit / receipt screen */}
{showExit && (
<div style={{position:"fixed",inset:0,background:bg,display:"flex",alignItems:"center",justifyContent:"center",zIndex:2000,flexDirection:"column"}}>
<div style={{fontSize:42,marginBottom:16}}>👋</div>
<div style={{color:text,fontSize:20,fontWeight:700,fontFamily:"'Anybody',system-ui,sans-serif"}}>Thanks for listening!</div>
<div style={{color:muted,fontSize:14,marginTop:8}}>Total spent: <span style={{color:"#e74c3c",fontWeight:700}}>${spent.toFixed(2)}</span></div>
<div style={{color:muted,fontSize:12,marginTop:4}}>{acts} actions ${acts>0?(spent/acts).toFixed(2):"0.00"}/action</div>
<div style={{color:muted,fontSize:12,marginTop:4}}>{Object.keys(subs).length} subscription{Object.keys(subs).length !== 1 ? "s" : ""}</div>
<div style={{display:"flex",gap:12,marginTop:24}}>
<button onClick={shareReceipt} style={{padding:"10px 24px",background:accent,color:"#000",border:"none",borderRadius:8,cursor:"pointer",fontSize:12,fontWeight:700}}>Share Receipt</button>
<button onClick={() => setShowExit(false)} style={{padding:"10px 24px",background:"transparent",color:muted,border:`1px solid ${muted}44`,borderRadius:8,cursor:"pointer",fontSize:12}}>Return to suffering</button>
</div>
</div>
)}
<Modal sub={modal?.key} phase={modalPhase} successText={successText} onSub={onSubClick} onMicro={onMicroClick} onClose={() => { setModal(null); setModalPhase("choose"); }} onTopUp={handleModalTopUp} />
{/* Toast notification */}
<div style={{position:"fixed",bottom:90,left:"50%",transform:`translateX(-50%) translateY(${toastVis?0:20}px)`,background:toastColor,color:toastTextColor,padding:"10px 20px",borderRadius:8,fontWeight:700,fontSize:13,opacity:toastVis?1:0,transition:"opacity 0.3s ease, transform 0.3s ease",zIndex:999,boxShadow:`0 4px 20px ${toastColor}44`,pointerEvents:"none"}}>{toastMsg}</div>
{/* ---- Main player ---- */}
<div style={{background:bg,minHeight:"100vh",color:text,fontFamily:"'IBM Plex Mono',monospace",display:"flex",flexDirection:"column",alignItems:"center",paddingBottom:24,transition:"background 0.5s ease, color 0.3s ease"}}>
{/* Sticky header with balance */}
<header style={{width:"100%",maxWidth:420,display:"flex",justifyContent:"space-between",alignItems:"center",padding:"12px 20px",marginBottom:16,position:"sticky",top:0,zIndex:100,background:bg,transition:"background 0.5s ease"}}>
<div>
<div style={{fontSize:11,letterSpacing:3,color:muted}}>PAY2PLAY!</div>
<div style={{fontSize:9,color:muted,opacity:0.6}}>The Worst Music Player</div>
</div>
<div style={{display:"flex",gap:10,alignItems:"center"}}>
<div key={balKey} style={{background:card,padding:"6px 14px",borderRadius:20,fontSize:12,fontWeight:700,color:accent,animation:balKey>0?"balFlash 0.6s ease":"none",transition:"background 0.3s ease"}}>💰 ${bal.toFixed(2)}</div>
<button onClick={() => { 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(); }} style={{background:accent,color:"#000",border:"none",borderRadius:20,padding:"6px 14px",fontSize:11,fontWeight:700,cursor:"pointer",boxShadow:`0 2px 10px ${accent}33`}}>+$10</button>
</div>
</header>
{/* Active subscriptions marquee (speed scales with count) */}
{Object.keys(subs).length > 0 && (
<div style={{width:"100%",maxWidth:420,overflow:"hidden",padding:"4px 0",marginBottom:16}}>
<div style={{display:"flex",gap:12,animation:`marquee ${Math.max(10, Object.keys(subs).length * 4 + 8)}s linear infinite`,whiteSpace:"nowrap"}}>
{Object.keys(subs).map(k => <span key={k} style={{fontSize:10,color:SUBS[k].color,background:`${SUBS[k].color}15`,padding:"2px 8px",borderRadius:4}}> {SUBS[k].name}</span>)}
</div>
</div>
)}
{/* Visualizer (responsive width) */}
<div style={{width:"min(280px, calc(100vw - 80px))",aspectRatio:"1",borderRadius:16,background:`linear-gradient(135deg,${card},${bright?"#d0d0e0":"#1a1a3e"})`,display:"flex",alignItems:"flex-end",justifyContent:"center",marginBottom:24,position:"relative",overflow:"hidden",boxShadow:bright?"0 10px 40px rgba(0,0,0,0.1)":"0 20px 60px rgba(0,0,0,0.4)",gap:3,padding:"0 20px 30px",transition:"background 0.5s ease"}}>
{bars.map((h, i) => <div key={i} style={{width:12,height:`${h}%`,borderRadius:3,background:`linear-gradient(to top,${song.color}88,${song.color})`,transition:"height 0.15s ease",opacity:on?0.9:0.15}} />)}
{/* Vinyl disc — freezes in place on pause */}
<div style={{position:"absolute",top:"50%",left:"50%",transform:"translate(-50%,-50%)",width:80,height:80,borderRadius:"50%",border:`2px solid ${muted}33`,display:"flex",alignItems:"center",justifyContent:"center",animation:"spin 3s linear infinite",animationPlayState:on?"running":"paused",background:bright?"rgba(240,240,255,0.7)":"rgba(13,13,26,0.7)"}}>
<div style={{width:20,height:20,borderRadius:"50%",background:bg,border:`1px solid ${muted}22`}} />
</div>
<div style={{position:"absolute",bottom:8,right:12,fontSize:10,color:bright?"#00000033":"#ffffff33",fontWeight:700,letterSpacing:2}}>FREE TIER</div>
{/* Free-listening countdown badge */}
{on && !has("continue") && prog < 3 && (
<div style={{position:"absolute",top:12,left:12,background:"rgba(231,76,60,0.9)",color:"#fff",padding:"4px 10px",borderRadius:6,fontSize:11,fontWeight:700,letterSpacing:1,animation:"countPulse 0.8s ease infinite"}}>
FREE {3 - prog}s
</div>
)}
{!toneOk && !SONGS[0].url && <div style={{position:"absolute",top:"50%",left:"50%",transform:"translate(-50%,-50%)",fontSize:10,color:muted,textAlign:"center",background:bright?"rgba(240,240,255,0.9)":"rgba(13,13,26,0.9)",padding:"8px 16px",borderRadius:8,zIndex:2}}>Click play to start audio</div>}
</div>
{/* Song info */}
<div style={{textAlign:"center",marginBottom:24,maxWidth:420,padding:"0 20px"}}>
<h2 style={{fontSize:20,fontWeight:900,fontFamily:"'Anybody',system-ui,sans-serif",marginBottom:4,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{song.title}</h2>
<div style={{fontSize:13,color:muted}}>{song.artist}</div>
</div>
{/* Seek bar — larger touch target, keyboard accessible */}
<div style={{width:"100%",maxWidth:380,padding:"0 20px",marginBottom:24}}>
<div onClick={handleSeek} onKeyDown={handleSeekKey} tabIndex={0} role="slider" aria-label="Song progress" aria-valuenow={prog} aria-valuemin={0} aria-valuemax={song.duration} style={{width:"100%",padding:"10px 0",cursor:"pointer"}}>
<div style={{width:"100%",height:6,background:card,borderRadius:3,position:"relative",transition:"background 0.3s ease"}}>
<div style={{width:`${Math.min(100,(prog/song.duration)*100)}%`,height:"100%",background:song.color,borderRadius:3,transition:"width 0.5s linear"}} />
</div>
</div>
<div style={{display:"flex",justifyContent:"space-between",fontSize:11,color:muted,marginTop:4}}>
<span>{fmt(prog)}</span>
<span>
{!has("seek") && <span style={{color:"#aa4444",marginRight:8,fontSize:9}}>🔒 approx.</span>}
{fmt(song.duration)}
</span>
</div>
</div>
{/* Transport controls */}
<div style={{display:"flex",alignItems:"center",gap:20,marginBottom:24}}>
<CBtn onClick={() => tryAct("shuffle", () => setShuf(s => !s))} color={shuf ? accent : muted} label="Shuffle">🔀</CBtn>
<CBtn onClick={() => tryAct("prev", () => changeSong((ci - 1 + SONGS.length) % SONGS.length))} color={muted} size={36} label="Previous"></CBtn>
<button onClick={handlePlay} aria-label={on ? "Pause" : "Play"} style={{width:64,height:64,borderRadius:"50%",background:song.color,color:"#fff",border:"none",fontSize:28,cursor:"pointer",display:"flex",alignItems:"center",justifyContent:"center",boxShadow:`0 4px 20px ${song.color}55`}}>{on ? "⏸" : "▶"}</button>
<CBtn onClick={() => tryAct("skip", () => changeSong((ci + 1) % SONGS.length))} color={muted} size={36} label="Next"></CBtn>
<CBtn onClick={() => rep ? tryAct("unrepeat", () => setRep(false)) : tryAct("repeat", () => setRep(true))} color={rep ? accent : muted} label="Repeat">🔁</CBtn>
</div>
{/* Volume slider with free-zone indicator */}
<div style={{width:"100%",maxWidth:380,padding:"0 20px",display:"flex",alignItems:"center",gap:12,marginBottom:24}}>
<span style={{fontSize:14}}>🔈</span>
<input type="range" min="0" max="100" value={vol} onChange={handleVol} style={{flex:1,background:volBg}} />
<span style={{fontSize:11,color:muted,minWidth:32}}>{vol}%</span>
{!has("volume") && <span style={{fontSize:9,color:"#aa4444"}}>🔒 23-47%</span>}
</div>
{/* 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%"}}>
{[
{ 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 => <button key={f.l} onClick={f.a} style={{background:card,border:`1px solid ${muted}22`,color:muted,padding:"8px 12px",borderRadius:8,fontSize:11,cursor:"pointer",display:"flex",alignItems:"center",justifyContent:"center",gap:4,transition:"background 0.3s ease, color 0.3s ease"}}>{f.i} {f.l}</button>)}
</div>
{/* Lyrics panel — animated slide */}
<div style={{width:"100%",maxWidth:380,overflow:"hidden",maxHeight:lyr?500:0,opacity:lyr?1:0,transition:"max-height 0.35s ease, opacity 0.25s ease, margin 0.35s ease",marginBottom:lyr?16:0}}>
<div style={{padding:"16px 20px",background:card,borderRadius:12,textAlign:"center",transition:"background 0.3s ease"}}>
<div style={{fontSize:10,color:muted,marginBottom:12,letterSpacing:2}}>LYRICS {!has("lyrics") && "— REDACTED VERSION"}</div>
{curLyrics.map((line, i) => (
<div key={i} style={{fontSize:13,color:has("lyrics")?text:muted,marginBottom:4,lineHeight:1.6}}>
{has("lyrics") ? line.join(" ") : line.map((w, j) => <span key={j}>{j % 3 === 0 ? w : <span style={{color:"#e74c3c"}}></span>}{" "}</span>)}
</div>
))}
</div>
</div>
{/* Up Next — card styled, with price on lock icons */}
<section style={{width:"100%",maxWidth:380,marginBottom:16}}>
<h3 style={{fontSize:10,color:muted,letterSpacing:2,padding:"0 20px",marginBottom:8,fontWeight:400}}>UP NEXT</h3>
<div style={{background:card,borderRadius:12,overflow:"hidden",transition:"background 0.3s ease"}}>
{SONGS.map((s, i) => i !== ci && (
<div key={i} className="q-item" onClick={() => tryAct("skip", () => changeSong(i))} style={{display:"flex",alignItems:"center",gap:12,padding:"10px 20px",cursor:"pointer",borderBottom:`1px solid ${bright?"#ddd":muted+"11"}`}}>
<div style={{width:8,height:8,borderRadius:"50%",background:s.color,flexShrink:0}} />
<div style={{flex:1,minWidth:0}}>
<div style={{fontSize:12,fontWeight:600,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{s.title}</div>
<div style={{fontSize:10,color:muted}}>{s.artist}</div>
</div>
<div style={{fontSize:10,color:muted,flexShrink:0}}>{fmt(s.duration)}</div>
<div style={{fontSize:10,color:"#aa4444",flexShrink:0}}>🔒 ${SUBS.skip.micro.toFixed(2)}</div>
</div>
))}
</div>
</section>
{/* Session Economics */}
<section style={{width:"100%",maxWidth:380,padding:"12px 20px",background:card,borderRadius:12,marginBottom:16,transition:"background 0.3s ease"}}>
<h3 style={{fontSize:10,color:muted,letterSpacing:2,marginBottom:8,fontWeight:400}}>SESSION ECONOMICS</h3>
{[["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]) => (
<div key={l} style={{display:"flex",justifyContent:"space-between",fontSize:12,marginTop:4}}><span style={{color:muted}}>{l}</span><span style={{color:c,fontWeight:700}}>{v}</span></div>
))}
</section>
<button onClick={() => has("exit") ? setShowExit(true) : setShowAd(true)} style={{background:"transparent",border:"1px solid #e74c3c33",color:"#e74c3c",padding:"10px 28px",borderRadius:8,fontSize:11,cursor:"pointer",marginBottom:16}}> Close App</button>
<footer style={{fontSize:10,color:muted,textAlign:"center",maxWidth:380,padding:"0 20px",lineHeight:1.8,opacity:0.7}}>
Pay2Play! v6.6.6 Music procedurally generated even the songs are cheaply made
Refunds available for $4.99 processing fee © 2026 Suffering Inc.
</footer>
</div>
</>
);
}
2026-03-17 17:09:50 +01:00
// Load real songs from audio/songs.json if available, then render
fetch("audio/songs.json")
.then(r => { if (r.ok) return r.json(); throw new Error(); })
.then(songs => { if (Array.isArray(songs) && songs.length > 0) SONGS = songs; })
.catch(() => {}) // No songs.json — use procedural builtins
.finally(() => ReactDOM.createRoot(document.getElementById("root")).render(<PayPlay />));