eloigil6 commited on
Commit
c58be5d
·
1 Parent(s): 722a5d8

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
Files changed (2) hide show
  1. frontend/style.css +33 -21
  2. 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
- #carousel {
891
- flex: 1;
892
- display: flex;
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
- #carousel::-webkit-scrollbar {
901
- display: none;
 
 
 
 
 
 
 
 
902
  }
903
 
904
  .cassette-card {
905
- flex: none;
906
- width: 196px;
 
 
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 8px 22px rgba(15, 40, 90, 0.32);
 
 
 
 
 
918
  transition:
919
- transform 0.18s ease,
920
- box-shadow 0.18s ease;
921
- animation: cassette-out 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
 
 
922
  }
923
 
924
  .cassette-card:hover {
925
- transform: translateY(-4px);
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.14),
933
- 0 12px 28px rgba(15, 40, 90, 0.4);
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
- function syncCards() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  const playing = !audio.paused;
142
- for (const card of carousel.children) {
143
- const selected =
144
- !!currentSong && tapeKey(card.dataset.url) === tapeKey(currentSong.url);
145
- card.classList.toggle("selected", selected);
146
- card.classList.toggle("playing", selected && playing);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  }
148
  }
149
 
150
  function renderCarousel(songs) {
 
151
  carousel.innerHTML = "";
152
- songs.forEach((song, i) => {
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 (tapeKey(currentSong?.url) === tapeKey(song.url)) togglePlay();
 
176
  else loadCassette(song, shell);
177
  });
178
  carousel.appendChild(card);
 
179
  });
180
- syncCards();
181
- carousel
182
- .querySelector(".selected")
183
- ?.scrollIntoView({ inline: "center", block: "nearest" });
 
 
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
- $("caro-prev").addEventListener("click", () =>
261
- carousel.scrollBy({ left: -440, behavior: "smooth" })
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");