Spaces:
Running on Zero
Running on Zero
Enhance audio playback controls with loop functionality. Added a loop button in index.html and corresponding styles in style.css. Updated ui.js to manage loop state and toggle behavior, allowing users to switch between looping the current tape and playing through the shelf. Refactored loadCassette function to accommodate new looping logic.
Browse files- .claude/launch.json +1 -1
- frontend/index.html +24 -0
- frontend/style.css +36 -0
- frontend/ui.js +37 -10
.claude/launch.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
{
|
| 5 |
"name": "lofinity",
|
| 6 |
"runtimeExecutable": "/bin/sh",
|
| 7 |
-
"runtimeArgs": ["-c", "GRADIO_SERVER_PORT=$PORT
|
| 8 |
"port": 7860,
|
| 9 |
"autoPort": true
|
| 10 |
}
|
|
|
|
| 4 |
{
|
| 5 |
"name": "lofinity",
|
| 6 |
"runtimeExecutable": "/bin/sh",
|
| 7 |
+
"runtimeArgs": ["-c", "GRADIO_SERVER_PORT=$PORT exec .venv/bin/python app.py"],
|
| 8 |
"port": 7860,
|
| 9 |
"autoPort": true
|
| 10 |
}
|
frontend/index.html
CHANGED
|
@@ -254,6 +254,30 @@
|
|
| 254 |
<span class="mini-reel"></span>
|
| 255 |
<span class="mini-reel"></span>
|
| 256 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
<a
|
| 258 |
id="deck-download"
|
| 259 |
class="disabled"
|
|
|
|
| 254 |
<span class="mini-reel"></span>
|
| 255 |
<span class="mini-reel"></span>
|
| 256 |
</div>
|
| 257 |
+
<button
|
| 258 |
+
id="deck-loop"
|
| 259 |
+
class="on"
|
| 260 |
+
aria-label="loop this tape"
|
| 261 |
+
aria-pressed="true"
|
| 262 |
+
title="looping this tape β click to play the whole shelf"
|
| 263 |
+
>
|
| 264 |
+
<svg
|
| 265 |
+
viewBox="0 0 24 24"
|
| 266 |
+
width="20"
|
| 267 |
+
height="20"
|
| 268 |
+
fill="none"
|
| 269 |
+
stroke="currentColor"
|
| 270 |
+
stroke-width="2.4"
|
| 271 |
+
stroke-linecap="round"
|
| 272 |
+
stroke-linejoin="round"
|
| 273 |
+
aria-hidden="true"
|
| 274 |
+
>
|
| 275 |
+
<polyline points="17 1 21 5 17 9" />
|
| 276 |
+
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
| 277 |
+
<polyline points="7 23 3 19 7 15" />
|
| 278 |
+
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
| 279 |
+
</svg>
|
| 280 |
+
</button>
|
| 281 |
<a
|
| 282 |
id="deck-download"
|
| 283 |
class="disabled"
|
frontend/style.css
CHANGED
|
@@ -1228,6 +1228,42 @@ body {
|
|
| 1228 |
animation: reel-spin 2.4s linear infinite;
|
| 1229 |
}
|
| 1230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1231 |
#deck-download {
|
| 1232 |
flex: none;
|
| 1233 |
width: 44px;
|
|
|
|
| 1228 |
animation: reel-spin 2.4s linear infinite;
|
| 1229 |
}
|
| 1230 |
|
| 1231 |
+
#deck-loop {
|
| 1232 |
+
flex: none;
|
| 1233 |
+
width: 44px;
|
| 1234 |
+
height: 44px;
|
| 1235 |
+
border-radius: 50%;
|
| 1236 |
+
display: flex;
|
| 1237 |
+
align-items: center;
|
| 1238 |
+
justify-content: center;
|
| 1239 |
+
border: 1px solid rgba(255, 255, 255, 0.5);
|
| 1240 |
+
background: rgba(255, 255, 255, 0.22);
|
| 1241 |
+
color: #fff;
|
| 1242 |
+
cursor: pointer;
|
| 1243 |
+
box-sizing: border-box;
|
| 1244 |
+
transition:
|
| 1245 |
+
background 0.15s ease,
|
| 1246 |
+
color 0.15s ease,
|
| 1247 |
+
box-shadow 0.15s ease,
|
| 1248 |
+
border-color 0.15s ease;
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
#deck-loop:hover {
|
| 1252 |
+
background: rgba(255, 255, 255, 0.4);
|
| 1253 |
+
}
|
| 1254 |
+
|
| 1255 |
+
/* lit up = this tape loops; off = roll through the whole shelf */
|
| 1256 |
+
#deck-loop.on {
|
| 1257 |
+
background: #ffd84a;
|
| 1258 |
+
border-color: #ffd84a;
|
| 1259 |
+
color: #3a2c14;
|
| 1260 |
+
box-shadow: 0 0 10px rgba(255, 216, 74, 0.7);
|
| 1261 |
+
}
|
| 1262 |
+
|
| 1263 |
+
#deck-loop:active {
|
| 1264 |
+
transform: translateY(1px);
|
| 1265 |
+
}
|
| 1266 |
+
|
| 1267 |
#deck-download {
|
| 1268 |
flex: none;
|
| 1269 |
width: 44px;
|
frontend/ui.js
CHANGED
|
@@ -43,6 +43,7 @@ export function initUI({
|
|
| 43 |
const deckFill = $("deck-fill");
|
| 44 |
const deckTime = $("deck-time");
|
| 45 |
const deckCassette = $("deck-cassette");
|
|
|
|
| 46 |
const deckDownload = $("deck-download");
|
| 47 |
|
| 48 |
// Two audio elements so tapes can crossfade β one element can't overlap
|
|
@@ -56,6 +57,9 @@ export function initUI({
|
|
| 56 |
let fadeTimer = null;
|
| 57 |
let playlist = []; // collection songs, when playing as a playlist
|
| 58 |
let playlistIndex = -1; // active track's index in `playlist`, or -1 = single loop
|
|
|
|
|
|
|
|
|
|
| 59 |
const CROSSFADE = 2.5; // s β overlap as one tape rolls into the next
|
| 60 |
const PICK_FADE = 0.9; // s β quick blend when you pick or skip a tape by hand
|
| 61 |
|
|
@@ -184,13 +188,14 @@ export function initUI({
|
|
| 184 |
syncPlayState();
|
| 185 |
}
|
| 186 |
|
| 187 |
-
// A freshly vended tape
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
| 194 |
}
|
| 195 |
|
| 196 |
// Play the collection as a playlist from `index`; tracks crossfade into one
|
|
@@ -205,13 +210,14 @@ export function initUI({
|
|
| 205 |
songsList[playlistIndex],
|
| 206 |
SHELL_COLORS[playlistIndex % SHELL_COLORS.length],
|
| 207 |
);
|
| 208 |
-
startTrack(songsList[playlistIndex].url, { loop:
|
| 209 |
layoutCarousel();
|
| 210 |
}
|
| 211 |
|
| 212 |
-
// Near the end of a playlist track, roll into the next one
|
|
|
|
| 213 |
function maybeAdvance() {
|
| 214 |
-
if (playlistIndex < 0 || fadeTimer || !active.duration) return;
|
| 215 |
if (active.currentTime < 1) return; // ignore the very start of a track
|
| 216 |
if (active.duration - active.currentTime <= CROSSFADE) {
|
| 217 |
playCollection(playlistIndex + 1, CROSSFADE);
|
|
@@ -402,6 +408,27 @@ export function initUI({
|
|
| 402 |
if (active.src) togglePlay();
|
| 403 |
});
|
| 404 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
const seek = (bar) => (e) => {
|
| 406 |
if (!active.duration) return;
|
| 407 |
const rect = bar.getBoundingClientRect();
|
|
|
|
| 43 |
const deckFill = $("deck-fill");
|
| 44 |
const deckTime = $("deck-time");
|
| 45 |
const deckCassette = $("deck-cassette");
|
| 46 |
+
const deckLoop = $("deck-loop");
|
| 47 |
const deckDownload = $("deck-download");
|
| 48 |
|
| 49 |
// Two audio elements so tapes can crossfade β one element can't overlap
|
|
|
|
| 57 |
let fadeTimer = null;
|
| 58 |
let playlist = []; // collection songs, when playing as a playlist
|
| 59 |
let playlistIndex = -1; // active track's index in `playlist`, or -1 = single loop
|
| 60 |
+
// When on (the default), the current tape loops; toggle it off to roll through
|
| 61 |
+
// the rest of the shelf. Controlled by the loop button on the tape deck.
|
| 62 |
+
let loopOne = true;
|
| 63 |
const CROSSFADE = 2.5; // s β overlap as one tape rolls into the next
|
| 64 |
const PICK_FADE = 0.9; // s β quick blend when you pick or skip a tape by hand
|
| 65 |
|
|
|
|
| 188 |
syncPlayState();
|
| 189 |
}
|
| 190 |
|
| 191 |
+
// A freshly vended tape starts playing and then rolls into the rest of the
|
| 192 |
+
// session's tapes (newest β older) instead of looping on itself. With only
|
| 193 |
+
// one tape on the shelf, the playlist re-triggers it, so a lone tape still
|
| 194 |
+
// loops seamlessly.
|
| 195 |
+
function loadCassette(song) {
|
| 196 |
+
songsList = sessionTapes; // the shelf doubles as the now-playing playlist
|
| 197 |
+
const idx = sessionTapes.indexOf(song);
|
| 198 |
+
playCollection(idx >= 0 ? idx : 0, PICK_FADE);
|
| 199 |
}
|
| 200 |
|
| 201 |
// Play the collection as a playlist from `index`; tracks crossfade into one
|
|
|
|
| 210 |
songsList[playlistIndex],
|
| 211 |
SHELL_COLORS[playlistIndex % SHELL_COLORS.length],
|
| 212 |
);
|
| 213 |
+
startTrack(songsList[playlistIndex].url, { loop: loopOne, fadeSec });
|
| 214 |
layoutCarousel();
|
| 215 |
}
|
| 216 |
|
| 217 |
+
// Near the end of a playlist track, roll into the next one β unless the user
|
| 218 |
+
// has the loop toggle on, in which case the tape repeats itself natively.
|
| 219 |
function maybeAdvance() {
|
| 220 |
+
if (loopOne || playlistIndex < 0 || fadeTimer || !active.duration) return;
|
| 221 |
if (active.currentTime < 1) return; // ignore the very start of a track
|
| 222 |
if (active.duration - active.currentTime <= CROSSFADE) {
|
| 223 |
playCollection(playlistIndex + 1, CROSSFADE);
|
|
|
|
| 408 |
if (active.src) togglePlay();
|
| 409 |
});
|
| 410 |
|
| 411 |
+
// Loop toggle: lit = the current tape repeats; off = play through the shelf.
|
| 412 |
+
function syncLoopBtn() {
|
| 413 |
+
deckLoop.classList.toggle("on", loopOne);
|
| 414 |
+
deckLoop.setAttribute("aria-pressed", String(loopOne));
|
| 415 |
+
deckLoop.title = loopOne
|
| 416 |
+
? "looping this tape β click to play the whole shelf"
|
| 417 |
+
: "playing the whole shelf β click to loop this tape";
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
function setLoop(on) {
|
| 421 |
+
loopOne = on;
|
| 422 |
+
// apply to the live element(s) so the change takes effect this play, not
|
| 423 |
+
// just on the next track
|
| 424 |
+
audioA.loop = on;
|
| 425 |
+
audioB.loop = on;
|
| 426 |
+
syncLoopBtn();
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
deckLoop.addEventListener("click", () => setLoop(!loopOne));
|
| 430 |
+
syncLoopBtn();
|
| 431 |
+
|
| 432 |
const seek = (bar) => (e) => {
|
| 433 |
if (!active.duration) return;
|
| 434 |
const rect = bar.getBoundingClientRect();
|