589 lines
38 KiB
JavaScript
589 lines
38 KiB
JavaScript
|
|
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 (
|
||
|
|
<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>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
ReactDOM.createRoot(document.getElementById("root")).render(<PayPlay />);
|