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) <noreply@anthropic.com>
This commit is contained in:
commit
96b1ecf7e6
6 changed files with 851 additions and 0 deletions
55
CLAUDE.md
Normal file
55
CLAUDE.md
Normal file
|
|
@ -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.
|
||||
152
README.md
Normal file
152
README.md
Normal file
|
|
@ -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.
|
||||
589
public/app.js
Normal file
589
public/app.js
Normal file
|
|
@ -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 (
|
||||
<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 />);
|
||||
0
public/audio/.gitkeep
Normal file
0
public/audio/.gitkeep
Normal file
26
public/index.html
Normal file
26
public/index.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Pay2Play! — The Worst Music Player</title>
|
||||
<meta name="description" content="Every feature costs money. Every button is a paywall. Welcome to Pay2Play!.">
|
||||
<meta property="og:title" content="Pay2Play! — The Worst Music Player">
|
||||
<meta property="og:description" content="A music player where pause costs $0.01, resume is a separate charge, and you only get 3 seconds free.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<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.">
|
||||
<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 rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.26.9/babel.min.js"></script>
|
||||
<script type="text/babel" src="app.js" data-type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
public/style.css
Normal file
29
public/style.css
Normal file
|
|
@ -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; }
|
||||
Loading…
Add table
Add a link
Reference in a new issue