@unabridged/midwest 0.23.3 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/app/assets/javascript/midwest/index.ts +4 -0
  2. package/app/assets/javascript/midwest.js +576 -8
  3. package/app/assets/javascript/midwest.js.map +1 -1
  4. package/app/assets/stylesheets/midwest/dark-mode.css +51 -1
  5. package/app/assets/stylesheets/midwest/form-group.css +5 -5
  6. package/app/assets/stylesheets/midwest/tailwind.css +101 -6
  7. package/app/assets/stylesheets/midwest/tokens.css +108 -0
  8. package/app/assets/stylesheets/midwest/utilities.css +20 -20
  9. package/app/assets/stylesheets/midwest.css +2 -2
  10. package/app/assets/stylesheets/midwest.tailwind.css +24 -1
  11. package/config/tailwind.theme.css +59 -0
  12. package/config/tailwind.theme.js +6 -2
  13. package/dist/css/midwest.css +2 -2
  14. package/dist/javascript/collection/app/assets/javascript/midwest/index.js +4 -0
  15. package/dist/javascript/collection/app/assets/javascript/midwest/index.js.map +1 -1
  16. package/dist/javascript/collection/app/components/midwest/command_palette_component/command_palette_component_controller.js +66 -8
  17. package/dist/javascript/collection/app/components/midwest/command_palette_component/command_palette_component_controller.js.map +1 -1
  18. package/dist/javascript/collection/app/components/midwest/panorama_component/panorama_component_controller.js +296 -0
  19. package/dist/javascript/collection/app/components/midwest/panorama_component/panorama_component_controller.js.map +1 -0
  20. package/dist/javascript/collection/app/components/midwest/playlist_component/playlist_component_controller.js +220 -0
  21. package/dist/javascript/collection/app/components/midwest/playlist_component/playlist_component_controller.js.map +1 -0
  22. package/dist/javascript/midwest.js +576 -8
  23. package/dist/javascript/midwest.js.map +1 -1
  24. package/package.json +6 -6
@@ -36,6 +36,8 @@ import IntersectionObserver from '../../../components/midwest/intersection_obser
36
36
  import FocusTrap from '../../../components/midwest/focus_trap_component/focus_trap_component_controller'
37
37
  import Onboarding from '../../../components/midwest/onboarding_component/onboarding_component_controller'
38
38
  import ThemeSwitch from '../../../components/midwest/form/switch_component/theme_switch_controller'
39
+ import Panorama from '../../../components/midwest/panorama_component/panorama_component_controller'
40
+ import Playlist from '../../../components/midwest/playlist_component/playlist_component_controller'
39
41
  /* IMPORTS */
40
42
 
41
43
  export { Card, Banner, CountdownTimer, Chart }
@@ -79,5 +81,7 @@ export function registerMidwestControllers (application: any) {
79
81
  application.register('midwest-focus-trap', FocusTrap)
80
82
  application.register('midwest-onboarding', Onboarding)
81
83
  application.register('midwest-theme-switch', ThemeSwitch)
84
+ application.register('midwest-panorama', Panorama)
85
+ application.register('midwest-playlist', Playlist)
82
86
  /* EXPORTS */
83
87
  }
@@ -5462,11 +5462,16 @@ class CommandPalette extends Controller {
5462
5462
  commandPlaceholder = "";
5463
5463
  messages = [];
5464
5464
  streamAbort = null;
5465
+ // While true, new/streamed content pins the transcript to the bottom. Turns
5466
+ // false when the user scrolls up to read history so we don't yank them down.
5467
+ stickToBottom = true;
5465
5468
  connect() {
5466
5469
  this.allItems = this.itemsValue;
5467
5470
  this.commandPlaceholder = this.inputTarget.placeholder;
5468
5471
  document.addEventListener("keydown", this.handleGlobalKeydown);
5469
5472
  this.element.addEventListener("click", this.handleBackdropClick);
5473
+ if (this.hasBodyTarget)
5474
+ this.bodyTarget.addEventListener("scroll", this.handleTranscriptScroll, { passive: true });
5470
5475
  if (!this.turboFrameUrlValue) {
5471
5476
  this.renderItems(this.allItems);
5472
5477
  }
@@ -5474,6 +5479,8 @@ class CommandPalette extends Controller {
5474
5479
  disconnect() {
5475
5480
  document.removeEventListener("keydown", this.handleGlobalKeydown);
5476
5481
  this.element.removeEventListener("click", this.handleBackdropClick);
5482
+ if (this.hasBodyTarget)
5483
+ this.bodyTarget.removeEventListener("scroll", this.handleTranscriptScroll);
5477
5484
  if (this.fetchAbort)
5478
5485
  this.fetchAbort.abort();
5479
5486
  if (this.streamAbort)
@@ -5617,6 +5624,7 @@ class CommandPalette extends Controller {
5617
5624
  this.transcriptTarget.innerHTML = "";
5618
5625
  if (this.hasStartersTarget)
5619
5626
  this.startersTarget.hidden = false;
5627
+ this.stickToBottom = true;
5620
5628
  this.setBusy(false);
5621
5629
  this.setMode("command");
5622
5630
  }
@@ -5656,6 +5664,7 @@ class CommandPalette extends Controller {
5656
5664
  const text = this.inputTarget.value.trim();
5657
5665
  if (!text)
5658
5666
  return;
5667
+ this.stickToBottom = true;
5659
5668
  this.appendMessage("user", text);
5660
5669
  this.messages.push({ role: "user", content: text });
5661
5670
  this.inputTarget.value = "";
@@ -5727,16 +5736,31 @@ class CommandPalette extends Controller {
5727
5736
  return null;
5728
5737
  }
5729
5738
  }
5730
- // Agentic events: the server streams a tool "action row" and/or a client
5731
- // directive (navigate / run a palette command). Directives reuse the same
5732
- // execution path as manual selection.
5739
+ // Agentic events: the server streams a tool "action row", a live preview
5740
+ // ("embed"), and/or a client directive (navigate / run a palette command).
5741
+ // Directives reuse the same execution path as manual selection.
5733
5742
  handleAgentEvent(event) {
5734
5743
  if (event.type === "tool") {
5735
5744
  this.appendActionRow(event);
5745
+ } else if (event.type === "embed" && event.srcdoc) {
5746
+ this.appendEmbed(event);
5736
5747
  } else if (event.type === "directive" && event.directive) {
5737
5748
  this.performDirective(event.directive);
5749
+ } else if (event.type === "error") {
5750
+ this.renderStreamError(event.message);
5738
5751
  }
5739
5752
  }
5753
+ // A server-reported failure (e.g. provider billing/rate-limit error). Render
5754
+ // it into the current assistant bubble instead of failing silently.
5755
+ renderStreamError(message) {
5756
+ const last = this.currentAssistantMessage();
5757
+ if (!last)
5758
+ return;
5759
+ last.classList.add("is-error");
5760
+ const bubble = last.querySelector(".command-palette-message-bubble");
5761
+ if (bubble)
5762
+ bubble.textContent = message || "Something went wrong. Please try again.";
5763
+ }
5740
5764
  // Action rows live in the current assistant message's card footer.
5741
5765
  appendActionRow(event) {
5742
5766
  if (!this.hasActionTemplateTarget)
@@ -5745,19 +5769,42 @@ class CommandPalette extends Controller {
5745
5769
  if (!footer)
5746
5770
  return;
5747
5771
  const action = cloneTemplate(this.actionTemplateTarget);
5748
- const label = action.querySelector(".command-palette-action-label");
5772
+ const label = action.querySelector(".value");
5749
5773
  if (label)
5750
5774
  label.textContent = event.label || "Action";
5751
5775
  footer.hidden = false;
5752
5776
  footer.appendChild(action);
5753
5777
  this.scrollTranscript();
5754
5778
  }
5779
+ // A live preview streamed by an agentic tool (e.g. generate_iframe). The
5780
+ // server sends only the inner document; the client owns the <iframe> element
5781
+ // and hard-codes the sandbox (no allow-scripts) so injected markup is styled
5782
+ // but never executed — the markdown text path can't render HTML at all.
5783
+ appendEmbed(event) {
5784
+ const message = this.currentAssistantMessage();
5785
+ if (!message || !event.srcdoc)
5786
+ return;
5787
+ const body = message.querySelector(".command-palette-message-body") || message;
5788
+ const frame = document.createElement("iframe");
5789
+ frame.className = "command-palette-embed";
5790
+ frame.setAttribute("sandbox", "allow-same-origin");
5791
+ frame.setAttribute("loading", "lazy");
5792
+ frame.title = event.title || "Preview";
5793
+ frame.srcdoc = event.srcdoc;
5794
+ if (event.height)
5795
+ frame.style.height = `${event.height}px`;
5796
+ body.appendChild(frame);
5797
+ this.scrollTranscript();
5798
+ }
5755
5799
  currentActionFooter() {
5800
+ const last = this.currentAssistantMessage();
5801
+ return last ? last.querySelector(".command-palette-action-footer") : null;
5802
+ }
5803
+ currentAssistantMessage() {
5756
5804
  const messages = this.transcriptTarget.querySelectorAll(
5757
5805
  ".command-palette-message.role-assistant"
5758
5806
  );
5759
- const last = messages[messages.length - 1];
5760
- return last ? last.querySelector(".command-palette-action-footer") : null;
5807
+ return messages[messages.length - 1] || null;
5761
5808
  }
5762
5809
  performDirective(directive) {
5763
5810
  if (directive.url) {
@@ -5797,10 +5844,21 @@ class CommandPalette extends Controller {
5797
5844
  if (this.hasTranscriptTarget)
5798
5845
  this.transcriptTarget.setAttribute("aria-busy", String(busy));
5799
5846
  }
5847
+ // The body is the scrollable region; the transcript itself does not scroll.
5800
5848
  scrollTranscript() {
5801
- if (this.hasTranscriptTarget)
5802
- this.transcriptTarget.scrollTop = this.transcriptTarget.scrollHeight;
5849
+ if (this.hasBodyTarget && this.stickToBottom) {
5850
+ this.bodyTarget.scrollTop = this.bodyTarget.scrollHeight;
5851
+ }
5803
5852
  }
5853
+ // Track whether the user is anchored to the bottom. Once they scroll up to
5854
+ // read earlier messages we stop auto-scrolling; returning to the bottom
5855
+ // re-arms it so streamed text keeps the latest content on screen.
5856
+ handleTranscriptScroll = () => {
5857
+ if (!this.hasBodyTarget)
5858
+ return;
5859
+ const { scrollHeight, scrollTop, clientHeight } = this.bodyTarget;
5860
+ this.stickToBottom = scrollHeight - scrollTop - clientHeight < 24;
5861
+ };
5804
5862
  appendMessage(role, text) {
5805
5863
  const li = cloneTemplate(this.messageTemplateTarget);
5806
5864
  li.classList.add(`role-${role}`);
@@ -7307,6 +7365,514 @@ class ThemeSwitch extends Controller {
7307
7365
  }
7308
7366
  }
7309
7367
 
7368
+ const FRICTION = 0.92;
7369
+ const MIN_VELOCITY = 0.05;
7370
+ const KEY_STEP_FRACTION = 0.12;
7371
+ class Panorama extends Controller {
7372
+ static targets = ["viewport", "stage", "media", "status", "hint", "reset"];
7373
+ static values = {
7374
+ wrap: { type: Boolean, default: false },
7375
+ autoRotate: { type: Number, default: 0 },
7376
+ start: { type: Number, default: 0 }
7377
+ };
7378
+ offset = 0;
7379
+ // current translateX, always <= 0
7380
+ mediaWidth = 0;
7381
+ // rendered width of a single media copy
7382
+ viewportWidth = 0;
7383
+ dragging = false;
7384
+ pointerId = null;
7385
+ lastX = 0;
7386
+ lastMoveTime = 0;
7387
+ velocity = 0;
7388
+ inertiaRaf = null;
7389
+ autoRaf = null;
7390
+ lastAutoTime = 0;
7391
+ clone = null;
7392
+ resizeObserver = null;
7393
+ interacted = false;
7394
+ connect() {
7395
+ this.viewportTarget.addEventListener("pointerdown", this.onPointerDown);
7396
+ this.element.addEventListener("keydown", this.onKeydown);
7397
+ this.element.addEventListener("pointerenter", this.pauseAuto);
7398
+ this.element.addEventListener("pointerleave", this.resumeAuto);
7399
+ this.element.addEventListener("focusin", this.pauseAuto);
7400
+ this.element.addEventListener("focusout", this.resumeAuto);
7401
+ this.resizeObserver = new ResizeObserver(() => this.measure());
7402
+ this.resizeObserver.observe(this.viewportTarget);
7403
+ this.whenMediaReady(() => {
7404
+ this.setupWrap();
7405
+ this.measure();
7406
+ this.offset = this.startOffset();
7407
+ this.apply();
7408
+ this.startAuto();
7409
+ });
7410
+ }
7411
+ disconnect() {
7412
+ this.viewportTarget.removeEventListener("pointerdown", this.onPointerDown);
7413
+ window.removeEventListener("pointermove", this.onPointerMove);
7414
+ window.removeEventListener("pointerup", this.onPointerUp);
7415
+ window.removeEventListener("pointercancel", this.onPointerUp);
7416
+ this.element.removeEventListener("keydown", this.onKeydown);
7417
+ this.element.removeEventListener("pointerenter", this.pauseAuto);
7418
+ this.element.removeEventListener("pointerleave", this.resumeAuto);
7419
+ this.element.removeEventListener("focusin", this.pauseAuto);
7420
+ this.element.removeEventListener("focusout", this.resumeAuto);
7421
+ this.resizeObserver?.disconnect();
7422
+ this.stopInertia();
7423
+ this.stopAuto();
7424
+ }
7425
+ // Recenters the view to its starting position (wired to the reset button).
7426
+ reset() {
7427
+ this.stopInertia();
7428
+ this.markInteracted();
7429
+ this.offset = this.startOffset();
7430
+ this.apply();
7431
+ this.announce("View reset");
7432
+ }
7433
+ // --- media readiness & measurement -----------------------------------------
7434
+ whenMediaReady(cb) {
7435
+ const media = this.mediaTarget;
7436
+ if (media instanceof HTMLImageElement) {
7437
+ if (media.complete && media.naturalWidth > 0) {
7438
+ cb();
7439
+ return;
7440
+ }
7441
+ media.addEventListener("load", cb, { once: true });
7442
+ media.addEventListener("error", cb, { once: true });
7443
+ } else {
7444
+ if (media.readyState >= 1 && media.videoWidth > 0) {
7445
+ cb();
7446
+ return;
7447
+ }
7448
+ media.addEventListener("loadedmetadata", cb, { once: true });
7449
+ }
7450
+ }
7451
+ measure() {
7452
+ this.viewportWidth = this.viewportTarget.clientWidth;
7453
+ const first = this.stageTarget.querySelector(".midwest-panorama-media");
7454
+ this.mediaWidth = first?.getBoundingClientRect().width ?? 0;
7455
+ this.offset = this.normalize(this.offset);
7456
+ this.apply();
7457
+ }
7458
+ // Duplicates the image once so the stage can loop seamlessly. Only images
7459
+ // wrap — a live <video> can't be cheaply cloned/kept in sync.
7460
+ setupWrap() {
7461
+ if (!this.wrapValue || this.clone)
7462
+ return;
7463
+ if (!(this.mediaTarget instanceof HTMLImageElement))
7464
+ return;
7465
+ const clone = this.mediaTarget.cloneNode(true);
7466
+ clone.setAttribute("aria-hidden", "true");
7467
+ clone.setAttribute("alt", "");
7468
+ clone.removeAttribute("data-midwest-panorama-target");
7469
+ this.stageTarget.appendChild(clone);
7470
+ this.clone = clone;
7471
+ }
7472
+ // --- geometry --------------------------------------------------------------
7473
+ canWrap() {
7474
+ return this.wrapValue && this.clone !== null && this.mediaWidth > 0;
7475
+ }
7476
+ // Constrains an offset: modulo for seamless wrap, hard clamp otherwise.
7477
+ normalize(x) {
7478
+ const w = this.mediaWidth;
7479
+ if (this.canWrap()) {
7480
+ if (w <= 0)
7481
+ return 0;
7482
+ let n = x % w;
7483
+ if (n > 0)
7484
+ n -= w;
7485
+ return n;
7486
+ }
7487
+ const min = Math.min(0, this.viewportWidth - w);
7488
+ return Math.max(min, Math.min(0, x));
7489
+ }
7490
+ startOffset() {
7491
+ if (this.canWrap())
7492
+ return this.normalize(-this.startValue * this.mediaWidth);
7493
+ const min = Math.min(0, this.viewportWidth - this.mediaWidth);
7494
+ return min * this.startValue;
7495
+ }
7496
+ apply() {
7497
+ this.stageTarget.style.transform = `translate3d(${this.offset}px, 0, 0)`;
7498
+ }
7499
+ // --- pointer dragging ------------------------------------------------------
7500
+ onPointerDown = (event) => {
7501
+ if (event.button !== 0)
7502
+ return;
7503
+ this.dragging = true;
7504
+ this.pointerId = event.pointerId;
7505
+ this.lastX = event.clientX;
7506
+ this.lastMoveTime = performance.now();
7507
+ this.velocity = 0;
7508
+ this.stopInertia();
7509
+ this.pauseAuto();
7510
+ this.markInteracted();
7511
+ this.element.classList.add("is-dragging");
7512
+ this.viewportTarget.setPointerCapture(event.pointerId);
7513
+ window.addEventListener("pointermove", this.onPointerMove);
7514
+ window.addEventListener("pointerup", this.onPointerUp);
7515
+ window.addEventListener("pointercancel", this.onPointerUp);
7516
+ };
7517
+ onPointerMove = (event) => {
7518
+ if (!this.dragging)
7519
+ return;
7520
+ const dx = event.clientX - this.lastX;
7521
+ const now = performance.now();
7522
+ const dt = now - this.lastMoveTime;
7523
+ if (dt > 0)
7524
+ this.velocity = dx / dt * 16;
7525
+ this.lastX = event.clientX;
7526
+ this.lastMoveTime = now;
7527
+ this.offset = this.normalize(this.offset + dx);
7528
+ this.apply();
7529
+ };
7530
+ onPointerUp = () => {
7531
+ if (!this.dragging)
7532
+ return;
7533
+ this.dragging = false;
7534
+ this.element.classList.remove("is-dragging");
7535
+ if (this.pointerId !== null) {
7536
+ try {
7537
+ this.viewportTarget.releasePointerCapture(this.pointerId);
7538
+ } catch {
7539
+ }
7540
+ }
7541
+ this.pointerId = null;
7542
+ window.removeEventListener("pointermove", this.onPointerMove);
7543
+ window.removeEventListener("pointerup", this.onPointerUp);
7544
+ window.removeEventListener("pointercancel", this.onPointerUp);
7545
+ this.startInertia();
7546
+ };
7547
+ startInertia() {
7548
+ if (Math.abs(this.velocity) < MIN_VELOCITY) {
7549
+ this.resumeAuto();
7550
+ return;
7551
+ }
7552
+ const step = () => {
7553
+ this.offset = this.normalize(this.offset + this.velocity);
7554
+ this.apply();
7555
+ this.velocity *= FRICTION;
7556
+ if (!this.canWrap()) {
7557
+ const min = Math.min(0, this.viewportWidth - this.mediaWidth);
7558
+ if (this.offset <= min || this.offset >= 0)
7559
+ this.velocity = 0;
7560
+ }
7561
+ if (Math.abs(this.velocity) < MIN_VELOCITY) {
7562
+ this.inertiaRaf = null;
7563
+ this.resumeAuto();
7564
+ return;
7565
+ }
7566
+ this.inertiaRaf = requestAnimationFrame(step);
7567
+ };
7568
+ this.inertiaRaf = requestAnimationFrame(step);
7569
+ }
7570
+ stopInertia() {
7571
+ if (this.inertiaRaf !== null) {
7572
+ cancelAnimationFrame(this.inertiaRaf);
7573
+ this.inertiaRaf = null;
7574
+ }
7575
+ }
7576
+ // --- keyboard --------------------------------------------------------------
7577
+ onKeydown = (event) => {
7578
+ const step = this.viewportWidth * KEY_STEP_FRACTION;
7579
+ let handled = true;
7580
+ switch (event.key) {
7581
+ case "ArrowLeft":
7582
+ this.offset = this.normalize(this.offset + step);
7583
+ break;
7584
+ case "ArrowRight":
7585
+ this.offset = this.normalize(this.offset - step);
7586
+ break;
7587
+ case "Home":
7588
+ this.offset = this.startOffset();
7589
+ break;
7590
+ default:
7591
+ handled = false;
7592
+ }
7593
+ if (!handled)
7594
+ return;
7595
+ event.preventDefault();
7596
+ this.stopInertia();
7597
+ this.pauseAuto();
7598
+ this.markInteracted();
7599
+ this.apply();
7600
+ this.announce();
7601
+ };
7602
+ // --- auto-rotate -----------------------------------------------------------
7603
+ startAuto() {
7604
+ if (this.autoRotateValue <= 0 || this.autoRaf !== null || this.dragging)
7605
+ return;
7606
+ if (this.prefersReducedMotion())
7607
+ return;
7608
+ this.lastAutoTime = performance.now();
7609
+ const step = (now) => {
7610
+ const dt = (now - this.lastAutoTime) / 1e3;
7611
+ this.lastAutoTime = now;
7612
+ this.offset = this.normalize(this.offset - this.autoRotateValue * dt);
7613
+ this.apply();
7614
+ this.autoRaf = requestAnimationFrame(step);
7615
+ };
7616
+ this.autoRaf = requestAnimationFrame(step);
7617
+ }
7618
+ stopAuto() {
7619
+ if (this.autoRaf !== null) {
7620
+ cancelAnimationFrame(this.autoRaf);
7621
+ this.autoRaf = null;
7622
+ }
7623
+ }
7624
+ pauseAuto = () => {
7625
+ this.stopAuto();
7626
+ };
7627
+ resumeAuto = () => {
7628
+ if (this.dragging)
7629
+ return;
7630
+ this.startAuto();
7631
+ };
7632
+ // --- helpers ---------------------------------------------------------------
7633
+ markInteracted() {
7634
+ if (this.interacted)
7635
+ return;
7636
+ this.interacted = true;
7637
+ this.element.classList.add("has-interacted");
7638
+ }
7639
+ announce(message) {
7640
+ if (!this.hasStatusTarget)
7641
+ return;
7642
+ this.statusTarget.textContent = message ?? `Viewing ${this.percent()}%`;
7643
+ }
7644
+ percent() {
7645
+ const w = this.mediaWidth;
7646
+ if (w <= 0)
7647
+ return 0;
7648
+ if (this.canWrap()) {
7649
+ const n = (-this.offset % w + w) % w;
7650
+ return Math.round(n / w * 100);
7651
+ }
7652
+ const min = Math.min(0, this.viewportWidth - w);
7653
+ return min === 0 ? 0 : Math.round(this.offset / min * 100);
7654
+ }
7655
+ prefersReducedMotion() {
7656
+ return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
7657
+ }
7658
+ }
7659
+
7660
+ function formatTime(seconds) {
7661
+ const mins = Math.floor(seconds / 60);
7662
+ const secs = Math.floor(seconds % 60);
7663
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
7664
+ }
7665
+ class Playlist extends Controller {
7666
+ static targets = [
7667
+ "audio",
7668
+ "audioSource",
7669
+ "playIcon",
7670
+ "pauseIcon",
7671
+ "trackTitle",
7672
+ "trackArtist",
7673
+ "artworkContainer",
7674
+ "placeholderIcon",
7675
+ "progressBar",
7676
+ "currentTime",
7677
+ "totalTime",
7678
+ "songList",
7679
+ "details"
7680
+ ];
7681
+ static values = {
7682
+ playlistName: { type: String, default: "Playlist" },
7683
+ autoplay: { type: Boolean, default: false },
7684
+ view: { type: String, default: "playlist" },
7685
+ playlistVisible: { type: Boolean, default: true },
7686
+ artwork: { type: Boolean, default: false }
7687
+ };
7688
+ currentIndex = 0;
7689
+ songElements = [];
7690
+ connect() {
7691
+ this.songElements = Array.from(this.songListTarget.querySelectorAll(".midwest-song button"));
7692
+ this.updatePlaylistVisibility();
7693
+ this.audioTarget.addEventListener("timeupdate", this.handleTimeUpdate);
7694
+ this.audioTarget.addEventListener("loadedmetadata", this.handleMetadataLoaded);
7695
+ this.audioTarget.addEventListener("ended", this.handleEnded);
7696
+ this.audioTarget.addEventListener("play", this.handlePlayEvent);
7697
+ this.audioTarget.addEventListener("pause", this.handlePauseEvent);
7698
+ this.element.addEventListener("keydown", this.handleKeydown);
7699
+ if (this.autoplayValue && this.songElements.length > 0) {
7700
+ this.loadSong(0);
7701
+ }
7702
+ }
7703
+ disconnect() {
7704
+ this.audioTarget.removeEventListener("timeupdate", this.handleTimeUpdate);
7705
+ this.audioTarget.removeEventListener("loadedmetadata", this.handleMetadataLoaded);
7706
+ this.audioTarget.removeEventListener("ended", this.handleEnded);
7707
+ this.audioTarget.removeEventListener("play", this.handlePlayEvent);
7708
+ this.audioTarget.removeEventListener("pause", this.handlePauseEvent);
7709
+ this.element.removeEventListener("keydown", this.handleKeydown);
7710
+ }
7711
+ // Play/pause toggle
7712
+ togglePlay() {
7713
+ if (this.audioTarget.paused) {
7714
+ if (this.audioSourceTarget.src) {
7715
+ this.audioTarget.play();
7716
+ } else if (this.songElements.length > 0) {
7717
+ this.loadSong(0);
7718
+ }
7719
+ } else {
7720
+ this.audioTarget.pause();
7721
+ }
7722
+ }
7723
+ // Play the song whose button was clicked (wired from the song template)
7724
+ playSong(event) {
7725
+ const button = event.currentTarget;
7726
+ const index = this.songElements.indexOf(button);
7727
+ if (index === -1)
7728
+ return;
7729
+ if (index === this.currentIndex && this.audioSourceTarget.src) {
7730
+ this.togglePlay();
7731
+ return;
7732
+ }
7733
+ this.loadSong(index);
7734
+ }
7735
+ // Load and play a specific song by index
7736
+ loadSong(index) {
7737
+ const songButton = this.songElements[index];
7738
+ if (!songButton)
7739
+ return;
7740
+ const songElement = songButton.closest(".midwest-song");
7741
+ if (!songElement)
7742
+ return;
7743
+ const src = songElement.dataset.src;
7744
+ const title = songElement.dataset.title || "Unknown";
7745
+ const artist = songElement.dataset.artist || "Unknown";
7746
+ const picture = songElement.dataset.picture;
7747
+ if (!src)
7748
+ return;
7749
+ this.audioSourceTarget.src = src;
7750
+ this.audioTarget.load();
7751
+ this.trackTitleTarget.textContent = title;
7752
+ this.trackArtistTarget.textContent = artist;
7753
+ if (picture) {
7754
+ const img = document.createElement("img");
7755
+ img.src = picture;
7756
+ img.alt = `Album art for ${title}`;
7757
+ img.className = "midwest-playlist__artwork-image";
7758
+ const existingImg = this.artworkContainerTarget.querySelector(".midwest-playlist__artwork-image");
7759
+ if (existingImg) {
7760
+ existingImg.replaceWith(img);
7761
+ } else {
7762
+ this.artworkContainerTarget.appendChild(img);
7763
+ }
7764
+ this.placeholderIconTarget.classList.add("hidden");
7765
+ } else {
7766
+ this.placeholderIconTarget.classList.remove("hidden");
7767
+ }
7768
+ this.songElements.forEach((btn, i) => {
7769
+ const song = btn.closest(".midwest-song");
7770
+ if (i === index) {
7771
+ song.classList.add("is-playing");
7772
+ } else {
7773
+ song.classList.remove("is-playing");
7774
+ }
7775
+ });
7776
+ this.currentIndex = index;
7777
+ this.audioTarget.play().catch(() => {
7778
+ });
7779
+ }
7780
+ // Play next song
7781
+ next() {
7782
+ const nextIndex = (this.currentIndex + 1) % this.songElements.length;
7783
+ this.loadSong(nextIndex);
7784
+ }
7785
+ // Play previous song
7786
+ previous() {
7787
+ const prevIndex = this.currentIndex === 0 ? this.songElements.length - 1 : this.currentIndex - 1;
7788
+ this.loadSong(prevIndex);
7789
+ }
7790
+ // Seek to position in track
7791
+ seek(event) {
7792
+ const rect = this.progressBarTarget.getBoundingClientRect();
7793
+ const x = event.clientX - rect.left;
7794
+ const percentage = Math.max(0, Math.min(1, x / rect.width));
7795
+ if (this.audioTarget.duration) {
7796
+ this.audioTarget.currentTime = percentage * this.audioTarget.duration;
7797
+ }
7798
+ }
7799
+ // Toggle playlist visibility
7800
+ togglePlaylist() {
7801
+ this.playlistVisibleValue = !this.playlistVisibleValue;
7802
+ this.updatePlaylistVisibility();
7803
+ this.savePlaylistVisibility();
7804
+ }
7805
+ // Toggle between playlist and art view
7806
+ toggleView() {
7807
+ this.viewValue = this.viewValue === "playlist" ? "art" : "playlist";
7808
+ this.updateView();
7809
+ }
7810
+ // Private: Handle time update for progress bar
7811
+ handleTimeUpdate = () => {
7812
+ const { currentTime, duration } = this.audioTarget;
7813
+ if (duration) {
7814
+ const percentage = currentTime / duration * 100;
7815
+ this.progressBarTarget.value = percentage;
7816
+ this.currentTimeTarget.textContent = formatTime(currentTime);
7817
+ this.totalTimeTarget.textContent = formatTime(duration);
7818
+ }
7819
+ };
7820
+ // Private: Handle metadata loaded
7821
+ handleMetadataLoaded = () => {
7822
+ const { duration } = this.audioTarget;
7823
+ if (duration) {
7824
+ this.totalTimeTarget.textContent = formatTime(duration);
7825
+ }
7826
+ };
7827
+ // Private: advance to the next track when the current one finishes
7828
+ handleEnded = () => {
7829
+ this.next();
7830
+ };
7831
+ // Private: reflect the <audio> playing state in the UI
7832
+ handlePlayEvent = () => {
7833
+ this.playIconTarget.hidden = true;
7834
+ this.pauseIconTarget.hidden = false;
7835
+ this.element.classList.add("is-playing");
7836
+ };
7837
+ handlePauseEvent = () => {
7838
+ this.playIconTarget.hidden = false;
7839
+ this.pauseIconTarget.hidden = true;
7840
+ this.element.classList.remove("is-playing");
7841
+ };
7842
+ // Private: Handle keyboard controls
7843
+ handleKeydown = (event) => {
7844
+ if (event.key === " " || event.key === "k") {
7845
+ event.preventDefault();
7846
+ this.togglePlay();
7847
+ } else if (event.key === "ArrowLeft") {
7848
+ event.preventDefault();
7849
+ this.previous();
7850
+ } else if (event.key === "ArrowRight") {
7851
+ event.preventDefault();
7852
+ this.next();
7853
+ }
7854
+ };
7855
+ // Private: Update playlist visibility
7856
+ updatePlaylistVisibility() {
7857
+ if (this.playlistVisibleValue) {
7858
+ this.songListTarget.classList.remove("is-hidden");
7859
+ } else {
7860
+ this.songListTarget.classList.add("is-hidden");
7861
+ }
7862
+ }
7863
+ // Private: Update view mode
7864
+ updateView() {
7865
+ this.element.setAttribute("view", this.viewValue);
7866
+ }
7867
+ // Private: Save playlist visibility to localStorage
7868
+ savePlaylistVisibility() {
7869
+ try {
7870
+ localStorage.setItem(`playlist-${this.playlistNameValue}-visibility`, this.playlistVisibleValue.toString());
7871
+ } catch {
7872
+ }
7873
+ }
7874
+ }
7875
+
7310
7876
  function registerMidwestControllers(application) {
7311
7877
  application.register("midwest-card", Card);
7312
7878
  application.register("midwest-banner", Banner);
@@ -7346,6 +7912,8 @@ function registerMidwestControllers(application) {
7346
7912
  application.register("midwest-focus-trap", FocusTrap);
7347
7913
  application.register("midwest-onboarding", Onboarding);
7348
7914
  application.register("midwest-theme-switch", ThemeSwitch);
7915
+ application.register("midwest-panorama", Panorama);
7916
+ application.register("midwest-playlist", Playlist);
7349
7917
  }
7350
7918
 
7351
7919
  export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };