Enhance carousel functionality and styling. Updated ui.js to implement a coverflow layout for cassette cards, allowing for improved visual presentation and interaction. Added keyboard navigation for carousel controls and refined button states in style.css for better user experience. Adjusted card animations and positioning for a more dynamic interface.
Browse files- frontend/style.css +33 -21
- frontend/ui.js +72 -19
frontend/style.css
CHANGED
|
@@ -868,6 +868,8 @@ body {
|
|
| 868 |
|
| 869 |
.caro-btn {
|
| 870 |
flex: none;
|
|
|
|
|
|
|
| 871 |
width: 40px;
|
| 872 |
height: 40px;
|
| 873 |
border-radius: 50%;
|
|
@@ -887,25 +889,29 @@ body {
|
|
| 887 |
background: rgba(255, 255, 255, 0.38);
|
| 888 |
}
|
| 889 |
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
gap: 16px;
|
| 894 |
-
overflow-x: auto;
|
| 895 |
-
scroll-snap-type: x mandatory;
|
| 896 |
-
padding: 14px 6px 20px;
|
| 897 |
-
scrollbar-width: none;
|
| 898 |
}
|
| 899 |
|
| 900 |
-
|
| 901 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 902 |
}
|
| 903 |
|
| 904 |
.cassette-card {
|
| 905 |
-
|
| 906 |
-
|
|
|
|
|
|
|
| 907 |
box-sizing: border-box;
|
| 908 |
-
scroll-snap-align: center;
|
| 909 |
cursor: pointer;
|
| 910 |
border: none;
|
| 911 |
padding: 0 0 4px;
|
|
@@ -914,23 +920,29 @@ body {
|
|
| 914 |
border-radius: 14px;
|
| 915 |
box-shadow:
|
| 916 |
inset 0 2px 6px rgba(255, 255, 255, 0.14),
|
| 917 |
-
0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 918 |
transition:
|
| 919 |
-
transform 0.
|
| 920 |
-
|
| 921 |
-
|
|
|
|
|
|
|
| 922 |
}
|
| 923 |
|
| 924 |
.cassette-card:hover {
|
| 925 |
-
|
| 926 |
}
|
| 927 |
|
| 928 |
.cassette-card.selected {
|
| 929 |
-
transform: translateY(-6px);
|
| 930 |
box-shadow:
|
| 931 |
0 0 0 3px #ffd84a,
|
| 932 |
-
inset 0 2px 6px rgba(255, 255, 255, 0.
|
| 933 |
-
0
|
| 934 |
}
|
| 935 |
|
| 936 |
.cassette-card .cassette-label {
|
|
|
|
| 868 |
|
| 869 |
.caro-btn {
|
| 870 |
flex: none;
|
| 871 |
+
position: relative;
|
| 872 |
+
z-index: 2;
|
| 873 |
width: 40px;
|
| 874 |
height: 40px;
|
| 875 |
border-radius: 50%;
|
|
|
|
| 889 |
background: rgba(255, 255, 255, 0.38);
|
| 890 |
}
|
| 891 |
|
| 892 |
+
.caro-btn:disabled {
|
| 893 |
+
opacity: 0.32;
|
| 894 |
+
cursor: default;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 895 |
}
|
| 896 |
|
| 897 |
+
.caro-btn:disabled:hover {
|
| 898 |
+
background: rgba(255, 255, 255, 0.22);
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
#carousel {
|
| 902 |
+
position: relative;
|
| 903 |
+
flex: 1;
|
| 904 |
+
height: 232px;
|
| 905 |
+
perspective: 1200px;
|
| 906 |
+
overflow: hidden;
|
| 907 |
}
|
| 908 |
|
| 909 |
.cassette-card {
|
| 910 |
+
position: absolute;
|
| 911 |
+
left: 50%;
|
| 912 |
+
top: 50%;
|
| 913 |
+
width: 184px;
|
| 914 |
box-sizing: border-box;
|
|
|
|
| 915 |
cursor: pointer;
|
| 916 |
border: none;
|
| 917 |
padding: 0 0 4px;
|
|
|
|
| 920 |
border-radius: 14px;
|
| 921 |
box-shadow:
|
| 922 |
inset 0 2px 6px rgba(255, 255, 255, 0.14),
|
| 923 |
+
0 10px 24px rgba(15, 40, 90, 0.32);
|
| 924 |
+
transform-origin: center center;
|
| 925 |
+
transform: translate(-50%, -50%) scale(0.5); /* pre-layout: centred + small */
|
| 926 |
+
opacity: 0;
|
| 927 |
+
backface-visibility: hidden;
|
| 928 |
+
-webkit-backface-visibility: hidden;
|
| 929 |
transition:
|
| 930 |
+
transform 0.44s cubic-bezier(0.22, 0.61, 0.36, 1),
|
| 931 |
+
opacity 0.44s ease,
|
| 932 |
+
box-shadow 0.25s ease,
|
| 933 |
+
filter 0.2s ease;
|
| 934 |
+
will-change: transform, opacity;
|
| 935 |
}
|
| 936 |
|
| 937 |
.cassette-card:hover {
|
| 938 |
+
filter: brightness(1.08);
|
| 939 |
}
|
| 940 |
|
| 941 |
.cassette-card.selected {
|
|
|
|
| 942 |
box-shadow:
|
| 943 |
0 0 0 3px #ffd84a,
|
| 944 |
+
inset 0 2px 6px rgba(255, 255, 255, 0.16),
|
| 945 |
+
0 16px 34px rgba(15, 40, 90, 0.46);
|
| 946 |
}
|
| 947 |
|
| 948 |
.cassette-card .cassette-label {
|
frontend/ui.js
CHANGED
|
@@ -137,26 +137,67 @@ export function initUI({
|
|
| 137 |
// out absolute URLs and the other relative ones
|
| 138 |
const tapeKey = (url) => url?.split("/").pop();
|
| 139 |
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
const playing = !audio.paused;
|
| 142 |
-
|
| 143 |
-
const
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
card.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
}
|
| 148 |
}
|
| 149 |
|
| 150 |
function renderCarousel(songs) {
|
|
|
|
| 151 |
carousel.innerHTML = "";
|
| 152 |
-
songs.
|
| 153 |
const shell = SHELL_COLORS[i % SHELL_COLORS.length];
|
| 154 |
const card = document.createElement("button");
|
| 155 |
card.className = "cassette-card";
|
| 156 |
card.dataset.url = song.url;
|
| 157 |
card.style.setProperty("--shell1", shell[0]);
|
| 158 |
card.style.setProperty("--shell2", shell[1]);
|
| 159 |
-
card.style.animationDelay = `${Math.min(i * 60, 480)}ms`;
|
| 160 |
card.title = song.title;
|
| 161 |
|
| 162 |
const label = document.createElement("div");
|
|
@@ -172,15 +213,19 @@ export function initUI({
|
|
| 172 |
|
| 173 |
card.append(label, reels);
|
| 174 |
card.addEventListener("click", () => {
|
| 175 |
-
if (
|
|
|
|
| 176 |
else loadCassette(song, shell);
|
| 177 |
});
|
| 178 |
carousel.appendChild(card);
|
|
|
|
| 179 |
});
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
.
|
| 183 |
-
|
|
|
|
|
|
|
| 184 |
}
|
| 185 |
|
| 186 |
async function openCollection() {
|
|
@@ -257,12 +302,8 @@ export function initUI({
|
|
| 257 |
deckProgress.addEventListener("click", seek(deckProgress));
|
| 258 |
|
| 259 |
$("collection-close").addEventListener("click", () => onRequestCloseCollection());
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
);
|
| 263 |
-
$("caro-next").addEventListener("click", () =>
|
| 264 |
-
carousel.scrollBy({ left: 440, behavior: "smooth" })
|
| 265 |
-
);
|
| 266 |
|
| 267 |
// --- modal controls ---------------------------------------------------------
|
| 268 |
|
|
@@ -285,6 +326,18 @@ export function initUI({
|
|
| 285 |
}
|
| 286 |
});
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
function syncPill() {
|
| 289 |
// the corner pill yields to the machine modal and the tape deck
|
| 290 |
const modalHidden = modal.classList.contains("hidden");
|
|
|
|
| 137 |
// out absolute URLs and the other relative ones
|
| 138 |
const tapeKey = (url) => url?.split("/").pop();
|
| 139 |
|
| 140 |
+
let cards = []; // card nodes in song order
|
| 141 |
+
let songsList = []; // songs backing the cards
|
| 142 |
+
let centerIndex = 0; // which tape sits in the middle (the selected one)
|
| 143 |
+
const caroPrev = $("caro-prev");
|
| 144 |
+
const caroNext = $("caro-next");
|
| 145 |
+
|
| 146 |
+
// Coverflow geometry, indexed by distance from the centre (capped at 3): the
|
| 147 |
+
// middle tape is biggest and face-on; neighbours shrink and angle inward;
|
| 148 |
+
// anything past ±2 is parked offscreen and hidden, so ~5 tapes show at once.
|
| 149 |
+
const CF_STEP = [0, 150, 268, 360]; // px from centre
|
| 150 |
+
const CF_SCALE = [1.16, 0.82, 0.64, 0.5];
|
| 151 |
+
const CF_TILT = [0, 20, 28, 28]; // deg, rotated toward the centre
|
| 152 |
+
const CF_FADE = [1, 0.9, 0.46, 0]; // opacity
|
| 153 |
+
|
| 154 |
+
function layoutCarousel() {
|
| 155 |
const playing = !audio.paused;
|
| 156 |
+
cards.forEach((card, i) => {
|
| 157 |
+
const offset = i - centerIndex;
|
| 158 |
+
const d = Math.min(Math.abs(offset), 3);
|
| 159 |
+
const dir = Math.sign(offset);
|
| 160 |
+
card.style.transform =
|
| 161 |
+
`translate(-50%, -50%) translateX(${dir * CF_STEP[d]}px) ` +
|
| 162 |
+
`scale(${CF_SCALE[d]}) rotateY(${-dir * CF_TILT[d]}deg)`;
|
| 163 |
+
card.style.opacity = CF_FADE[d];
|
| 164 |
+
card.style.zIndex = 30 - d;
|
| 165 |
+
card.style.pointerEvents = d <= 2 ? "auto" : "none";
|
| 166 |
+
card.setAttribute("aria-hidden", d <= 2 ? "false" : "true");
|
| 167 |
+
card.classList.toggle("selected", offset === 0);
|
| 168 |
+
card.classList.toggle("playing", offset === 0 && playing);
|
| 169 |
+
});
|
| 170 |
+
caroPrev.disabled = centerIndex <= 0;
|
| 171 |
+
caroNext.disabled = centerIndex >= cards.length - 1;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// older callers just want the selection/playing classes refreshed in place
|
| 175 |
+
const syncCards = layoutCarousel;
|
| 176 |
+
|
| 177 |
+
// Bring tape `index` to the middle. `load` also drops it into the deck and
|
| 178 |
+
// plays it (an explicit pick); open-time centering passes load:false.
|
| 179 |
+
function centerOn(index, { load = false } = {}) {
|
| 180 |
+
if (!cards.length) return;
|
| 181 |
+
centerIndex = Math.max(0, Math.min(cards.length - 1, index));
|
| 182 |
+
const song = songsList[centerIndex];
|
| 183 |
+
if (load && song && tapeKey(song.url) !== tapeKey(currentSong?.url)) {
|
| 184 |
+
loadCassette(song, SHELL_COLORS[centerIndex % SHELL_COLORS.length]);
|
| 185 |
+
} else {
|
| 186 |
+
if (load && audio.paused) audio.play().catch(() => {});
|
| 187 |
+
layoutCarousel();
|
| 188 |
}
|
| 189 |
}
|
| 190 |
|
| 191 |
function renderCarousel(songs) {
|
| 192 |
+
songsList = songs;
|
| 193 |
carousel.innerHTML = "";
|
| 194 |
+
cards = songs.map((song, i) => {
|
| 195 |
const shell = SHELL_COLORS[i % SHELL_COLORS.length];
|
| 196 |
const card = document.createElement("button");
|
| 197 |
card.className = "cassette-card";
|
| 198 |
card.dataset.url = song.url;
|
| 199 |
card.style.setProperty("--shell1", shell[0]);
|
| 200 |
card.style.setProperty("--shell2", shell[1]);
|
|
|
|
| 201 |
card.title = song.title;
|
| 202 |
|
| 203 |
const label = document.createElement("div");
|
|
|
|
| 213 |
|
| 214 |
card.append(label, reels);
|
| 215 |
card.addEventListener("click", () => {
|
| 216 |
+
if (i !== centerIndex) centerOn(i, { load: true });
|
| 217 |
+
else if (tapeKey(currentSong?.url) === tapeKey(song.url)) togglePlay();
|
| 218 |
else loadCassette(song, shell);
|
| 219 |
});
|
| 220 |
carousel.appendChild(card);
|
| 221 |
+
return card;
|
| 222 |
});
|
| 223 |
+
// open centered on the tape already playing, otherwise the newest one
|
| 224 |
+
const playingIdx = songs.findIndex(
|
| 225 |
+
(s) => tapeKey(s.url) === tapeKey(currentSong?.url)
|
| 226 |
+
);
|
| 227 |
+
centerIndex = playingIdx >= 0 ? playingIdx : 0;
|
| 228 |
+
layoutCarousel();
|
| 229 |
}
|
| 230 |
|
| 231 |
async function openCollection() {
|
|
|
|
| 302 |
deckProgress.addEventListener("click", seek(deckProgress));
|
| 303 |
|
| 304 |
$("collection-close").addEventListener("click", () => onRequestCloseCollection());
|
| 305 |
+
caroPrev.addEventListener("click", () => centerOn(centerIndex - 1, { load: true }));
|
| 306 |
+
caroNext.addEventListener("click", () => centerOn(centerIndex + 1, { load: true }));
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
// --- modal controls ---------------------------------------------------------
|
| 309 |
|
|
|
|
| 326 |
}
|
| 327 |
});
|
| 328 |
|
| 329 |
+
// arrow keys flick through the carousel while the collection is open
|
| 330 |
+
window.addEventListener("keydown", (e) => {
|
| 331 |
+
if (!collectionOpen) return;
|
| 332 |
+
if (e.key === "ArrowLeft") {
|
| 333 |
+
e.preventDefault();
|
| 334 |
+
centerOn(centerIndex - 1, { load: true });
|
| 335 |
+
} else if (e.key === "ArrowRight") {
|
| 336 |
+
e.preventDefault();
|
| 337 |
+
centerOn(centerIndex + 1, { load: true });
|
| 338 |
+
}
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
function syncPill() {
|
| 342 |
// the corner pill yields to the machine modal and the tape deck
|
| 343 |
const modalHidden = modal.classList.contains("hidden");
|