@unabridged/midwest 0.23.4 → 0.24.3
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.
- package/app/assets/javascript/midwest/index.ts +6 -0
- package/app/assets/javascript/midwest/viewport_observer.ts +49 -0
- package/app/assets/javascript/midwest.js +660 -72
- package/app/assets/javascript/midwest.js.map +1 -1
- package/app/assets/stylesheets/midwest/dark-mode.css +50 -0
- package/app/assets/stylesheets/midwest.css +1 -1
- package/app/assets/stylesheets/midwest.tailwind.css +8 -1
- package/dist/css/midwest.css +1 -1
- package/dist/javascript/collection/app/assets/javascript/midwest/index.js +6 -0
- package/dist/javascript/collection/app/assets/javascript/midwest/index.js.map +1 -1
- package/dist/javascript/collection/app/assets/javascript/midwest/viewport_observer.js +22 -0
- package/dist/javascript/collection/app/assets/javascript/midwest/viewport_observer.js.map +1 -0
- package/dist/javascript/collection/app/components/midwest/carousel_component/carousel_component_controller.js +4 -7
- package/dist/javascript/collection/app/components/midwest/carousel_component/carousel_component_controller.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/command_palette_component/command_palette_component_controller.js +66 -8
- package/dist/javascript/collection/app/components/midwest/command_palette_component/command_palette_component_controller.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/dropdown_component/dropdown_component_controller.js +0 -6
- package/dist/javascript/collection/app/components/midwest/dropdown_component/dropdown_component_controller.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/intersection_observer_component/intersection_observer_component_controller.js +20 -25
- package/dist/javascript/collection/app/components/midwest/intersection_observer_component/intersection_observer_component_controller.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/motion_component/motion_component_controller.js +32 -0
- package/dist/javascript/collection/app/components/midwest/motion_component/motion_component_controller.js.map +1 -0
- package/dist/javascript/collection/app/components/midwest/notification_component/notification_component_controller.js +4 -12
- package/dist/javascript/collection/app/components/midwest/notification_component/notification_component_controller.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/panorama_component/panorama_component_controller.js +296 -0
- package/dist/javascript/collection/app/components/midwest/panorama_component/panorama_component_controller.js.map +1 -0
- package/dist/javascript/collection/app/components/midwest/playlist_component/playlist_component_controller.js +220 -0
- package/dist/javascript/collection/app/components/midwest/playlist_component/playlist_component_controller.js.map +1 -0
- package/dist/javascript/collection/app/components/midwest/table_component/load_more_controller.js +10 -14
- package/dist/javascript/collection/app/components/midwest/table_component/load_more_controller.js.map +1 -1
- package/dist/javascript/midwest.js +660 -72
- package/dist/javascript/midwest.js.map +1 -1
- package/package.json +1 -1
|
@@ -2557,7 +2557,6 @@ class Dropdown extends Controller {
|
|
|
2557
2557
|
supported: { type: Boolean, default: true },
|
|
2558
2558
|
align: { type: String, default: "right" }
|
|
2559
2559
|
};
|
|
2560
|
-
observer = new IntersectionObserver(this.computedCB.bind(this), { threshold: 1 });
|
|
2561
2560
|
cleanup;
|
|
2562
2561
|
connect() {
|
|
2563
2562
|
this.supportedValue = CSS.supports("anchor-name: --myanchor");
|
|
@@ -2623,11 +2622,6 @@ class Dropdown extends Controller {
|
|
|
2623
2622
|
(item) => item.closest('[data-controller~="midwest-dropdown"]') === this.element && getComputedStyle(item).display !== "none"
|
|
2624
2623
|
);
|
|
2625
2624
|
}
|
|
2626
|
-
computedCB(entries) {
|
|
2627
|
-
entries.forEach(async () => {
|
|
2628
|
-
this.computation();
|
|
2629
|
-
});
|
|
2630
|
-
}
|
|
2631
2625
|
closeIfOpen = () => {
|
|
2632
2626
|
this.element.hidePopover();
|
|
2633
2627
|
};
|
|
@@ -3012,7 +3006,7 @@ class Carousel extends Controller {
|
|
|
3012
3006
|
}
|
|
3013
3007
|
const atEnd = this._currentIndex >= this.slides.length - 1;
|
|
3014
3008
|
if (this.loopValue && atEnd) {
|
|
3015
|
-
this.
|
|
3009
|
+
this.trackTarget.scrollTo({ left: 0, behavior: "smooth" });
|
|
3016
3010
|
} else {
|
|
3017
3011
|
this.trackTarget.scrollBy({ left: this.trackTarget.clientWidth, behavior: "smooth" });
|
|
3018
3012
|
}
|
|
@@ -3026,8 +3020,7 @@ class Carousel extends Controller {
|
|
|
3026
3020
|
}
|
|
3027
3021
|
const atStart = this._currentIndex <= 0;
|
|
3028
3022
|
if (this.loopValue && atStart) {
|
|
3029
|
-
|
|
3030
|
-
last?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
|
|
3023
|
+
this.trackTarget.scrollTo({ left: (this.slides.length - 1) * this.trackTarget.clientWidth, behavior: "smooth" });
|
|
3031
3024
|
} else {
|
|
3032
3025
|
this.trackTarget.scrollBy({ left: -this.trackTarget.clientWidth, behavior: "smooth" });
|
|
3033
3026
|
}
|
|
@@ -3039,8 +3032,7 @@ class Carousel extends Controller {
|
|
|
3039
3032
|
this.activateSlide(index);
|
|
3040
3033
|
return;
|
|
3041
3034
|
}
|
|
3042
|
-
|
|
3043
|
-
slide?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
|
|
3035
|
+
this.trackTarget.scrollTo({ left: index * this.trackTarget.clientWidth, behavior: "smooth" });
|
|
3044
3036
|
}
|
|
3045
3037
|
// Unlocks the next slide and advances to it. Intended for wizard mode: wire
|
|
3046
3038
|
// a "Continue" button inside a slide to this action so users progress
|
|
@@ -3144,8 +3136,7 @@ class Carousel extends Controller {
|
|
|
3144
3136
|
}
|
|
3145
3137
|
const atEnd = this._currentIndex >= this.slides.length - 1;
|
|
3146
3138
|
if (atEnd) {
|
|
3147
|
-
|
|
3148
|
-
firstSlide?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
|
|
3139
|
+
this.trackTarget.scrollTo({ left: 0, behavior: "smooth" });
|
|
3149
3140
|
} else {
|
|
3150
3141
|
this.next();
|
|
3151
3142
|
}
|
|
@@ -4871,10 +4862,7 @@ class Notification extends Controller {
|
|
|
4871
4862
|
connect() {
|
|
4872
4863
|
this.container = this.element.closest("[popover]");
|
|
4873
4864
|
this.container?.showPopover?.();
|
|
4874
|
-
|
|
4875
|
-
this.element.classList.add("visible");
|
|
4876
|
-
});
|
|
4877
|
-
const frame = this.element.parentElement;
|
|
4865
|
+
const frame = this.element.closest("turbo-frame");
|
|
4878
4866
|
if (frame) {
|
|
4879
4867
|
this.observer = new MutationObserver(() => this.syncCountdown());
|
|
4880
4868
|
this.observer.observe(frame, { childList: true });
|
|
@@ -4893,14 +4881,8 @@ class Notification extends Controller {
|
|
|
4893
4881
|
}
|
|
4894
4882
|
}
|
|
4895
4883
|
close() {
|
|
4896
|
-
if (!this.element.classList.contains("animated")) {
|
|
4897
|
-
this.element.remove();
|
|
4898
|
-
return;
|
|
4899
|
-
}
|
|
4900
4884
|
this.element.classList.add("closing");
|
|
4901
|
-
setTimeout(() =>
|
|
4902
|
-
this.element.remove();
|
|
4903
|
-
}, 350);
|
|
4885
|
+
setTimeout(() => (this.element.parentElement ?? this.element).remove(), 350);
|
|
4904
4886
|
}
|
|
4905
4887
|
syncCountdown() {
|
|
4906
4888
|
const timerEl = this.element.querySelector('[data-controller~="midwest-countdown-timer"]');
|
|
@@ -4912,7 +4894,8 @@ class Notification extends Controller {
|
|
|
4912
4894
|
);
|
|
4913
4895
|
if (!timer)
|
|
4914
4896
|
return;
|
|
4915
|
-
const
|
|
4897
|
+
const wrapper = this.element.parentElement ?? this.element;
|
|
4898
|
+
const isTop = wrapper === wrapper.parentElement?.firstElementChild;
|
|
4916
4899
|
if (isTop) {
|
|
4917
4900
|
timer.resume();
|
|
4918
4901
|
} else {
|
|
@@ -4986,36 +4969,51 @@ class Table extends Controller {
|
|
|
4986
4969
|
}
|
|
4987
4970
|
}
|
|
4988
4971
|
|
|
4972
|
+
function observeViewport(element, options = {}) {
|
|
4973
|
+
const { threshold = 0, rootMargin = "0px", once = false, onEnter, onLeave } = options;
|
|
4974
|
+
const observer = new IntersectionObserver(
|
|
4975
|
+
(entries) => {
|
|
4976
|
+
for (const entry of entries) {
|
|
4977
|
+
if (entry.isIntersecting) {
|
|
4978
|
+
onEnter?.(entry);
|
|
4979
|
+
if (once)
|
|
4980
|
+
observer.disconnect();
|
|
4981
|
+
} else {
|
|
4982
|
+
onLeave?.(entry);
|
|
4983
|
+
}
|
|
4984
|
+
}
|
|
4985
|
+
},
|
|
4986
|
+
{ threshold, rootMargin }
|
|
4987
|
+
);
|
|
4988
|
+
observer.observe(element);
|
|
4989
|
+
return () => observer.disconnect();
|
|
4990
|
+
}
|
|
4991
|
+
|
|
4989
4992
|
class LoadMore extends Controller {
|
|
4990
4993
|
static values = {
|
|
4991
4994
|
url: String,
|
|
4992
4995
|
tbodyId: String
|
|
4993
4996
|
};
|
|
4994
|
-
|
|
4997
|
+
teardown = null;
|
|
4995
4998
|
loading = false;
|
|
4996
4999
|
connect() {
|
|
4997
5000
|
if (!this.urlValue)
|
|
4998
5001
|
return;
|
|
4999
|
-
this.
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
// Start fetching 300px before the sentinel enters the viewport so rows
|
|
5005
|
-
// appear before the user actually reaches the bottom.
|
|
5006
|
-
{ rootMargin: "0px 0px 300px 0px" }
|
|
5007
|
-
);
|
|
5008
|
-
this.observer.observe(this.element);
|
|
5002
|
+
this.teardown = observeViewport(this.element, {
|
|
5003
|
+
rootMargin: "0px 0px 300px 0px",
|
|
5004
|
+
once: true,
|
|
5005
|
+
onEnter: () => this.load()
|
|
5006
|
+
});
|
|
5009
5007
|
}
|
|
5010
5008
|
disconnect() {
|
|
5011
|
-
this.
|
|
5012
|
-
this.
|
|
5009
|
+
this.teardown?.();
|
|
5010
|
+
this.teardown = null;
|
|
5013
5011
|
}
|
|
5014
5012
|
async load() {
|
|
5015
5013
|
if (this.loading)
|
|
5016
5014
|
return;
|
|
5017
5015
|
this.loading = true;
|
|
5018
|
-
this.
|
|
5016
|
+
this.teardown?.();
|
|
5019
5017
|
try {
|
|
5020
5018
|
const response = await fetch(this.urlValue, {
|
|
5021
5019
|
headers: {
|
|
@@ -5462,11 +5460,16 @@ class CommandPalette extends Controller {
|
|
|
5462
5460
|
commandPlaceholder = "";
|
|
5463
5461
|
messages = [];
|
|
5464
5462
|
streamAbort = null;
|
|
5463
|
+
// While true, new/streamed content pins the transcript to the bottom. Turns
|
|
5464
|
+
// false when the user scrolls up to read history so we don't yank them down.
|
|
5465
|
+
stickToBottom = true;
|
|
5465
5466
|
connect() {
|
|
5466
5467
|
this.allItems = this.itemsValue;
|
|
5467
5468
|
this.commandPlaceholder = this.inputTarget.placeholder;
|
|
5468
5469
|
document.addEventListener("keydown", this.handleGlobalKeydown);
|
|
5469
5470
|
this.element.addEventListener("click", this.handleBackdropClick);
|
|
5471
|
+
if (this.hasBodyTarget)
|
|
5472
|
+
this.bodyTarget.addEventListener("scroll", this.handleTranscriptScroll, { passive: true });
|
|
5470
5473
|
if (!this.turboFrameUrlValue) {
|
|
5471
5474
|
this.renderItems(this.allItems);
|
|
5472
5475
|
}
|
|
@@ -5474,6 +5477,8 @@ class CommandPalette extends Controller {
|
|
|
5474
5477
|
disconnect() {
|
|
5475
5478
|
document.removeEventListener("keydown", this.handleGlobalKeydown);
|
|
5476
5479
|
this.element.removeEventListener("click", this.handleBackdropClick);
|
|
5480
|
+
if (this.hasBodyTarget)
|
|
5481
|
+
this.bodyTarget.removeEventListener("scroll", this.handleTranscriptScroll);
|
|
5477
5482
|
if (this.fetchAbort)
|
|
5478
5483
|
this.fetchAbort.abort();
|
|
5479
5484
|
if (this.streamAbort)
|
|
@@ -5617,6 +5622,7 @@ class CommandPalette extends Controller {
|
|
|
5617
5622
|
this.transcriptTarget.innerHTML = "";
|
|
5618
5623
|
if (this.hasStartersTarget)
|
|
5619
5624
|
this.startersTarget.hidden = false;
|
|
5625
|
+
this.stickToBottom = true;
|
|
5620
5626
|
this.setBusy(false);
|
|
5621
5627
|
this.setMode("command");
|
|
5622
5628
|
}
|
|
@@ -5656,6 +5662,7 @@ class CommandPalette extends Controller {
|
|
|
5656
5662
|
const text = this.inputTarget.value.trim();
|
|
5657
5663
|
if (!text)
|
|
5658
5664
|
return;
|
|
5665
|
+
this.stickToBottom = true;
|
|
5659
5666
|
this.appendMessage("user", text);
|
|
5660
5667
|
this.messages.push({ role: "user", content: text });
|
|
5661
5668
|
this.inputTarget.value = "";
|
|
@@ -5727,16 +5734,31 @@ class CommandPalette extends Controller {
|
|
|
5727
5734
|
return null;
|
|
5728
5735
|
}
|
|
5729
5736
|
}
|
|
5730
|
-
// Agentic events: the server streams a tool "action row"
|
|
5731
|
-
// directive (navigate / run a palette command).
|
|
5732
|
-
// execution path as manual selection.
|
|
5737
|
+
// Agentic events: the server streams a tool "action row", a live preview
|
|
5738
|
+
// ("embed"), and/or a client directive (navigate / run a palette command).
|
|
5739
|
+
// Directives reuse the same execution path as manual selection.
|
|
5733
5740
|
handleAgentEvent(event) {
|
|
5734
5741
|
if (event.type === "tool") {
|
|
5735
5742
|
this.appendActionRow(event);
|
|
5743
|
+
} else if (event.type === "embed" && event.srcdoc) {
|
|
5744
|
+
this.appendEmbed(event);
|
|
5736
5745
|
} else if (event.type === "directive" && event.directive) {
|
|
5737
5746
|
this.performDirective(event.directive);
|
|
5747
|
+
} else if (event.type === "error") {
|
|
5748
|
+
this.renderStreamError(event.message);
|
|
5738
5749
|
}
|
|
5739
5750
|
}
|
|
5751
|
+
// A server-reported failure (e.g. provider billing/rate-limit error). Render
|
|
5752
|
+
// it into the current assistant bubble instead of failing silently.
|
|
5753
|
+
renderStreamError(message) {
|
|
5754
|
+
const last = this.currentAssistantMessage();
|
|
5755
|
+
if (!last)
|
|
5756
|
+
return;
|
|
5757
|
+
last.classList.add("is-error");
|
|
5758
|
+
const bubble = last.querySelector(".command-palette-message-bubble");
|
|
5759
|
+
if (bubble)
|
|
5760
|
+
bubble.textContent = message || "Something went wrong. Please try again.";
|
|
5761
|
+
}
|
|
5740
5762
|
// Action rows live in the current assistant message's card footer.
|
|
5741
5763
|
appendActionRow(event) {
|
|
5742
5764
|
if (!this.hasActionTemplateTarget)
|
|
@@ -5745,19 +5767,42 @@ class CommandPalette extends Controller {
|
|
|
5745
5767
|
if (!footer)
|
|
5746
5768
|
return;
|
|
5747
5769
|
const action = cloneTemplate(this.actionTemplateTarget);
|
|
5748
|
-
const label = action.querySelector(".
|
|
5770
|
+
const label = action.querySelector(".value");
|
|
5749
5771
|
if (label)
|
|
5750
5772
|
label.textContent = event.label || "Action";
|
|
5751
5773
|
footer.hidden = false;
|
|
5752
5774
|
footer.appendChild(action);
|
|
5753
5775
|
this.scrollTranscript();
|
|
5754
5776
|
}
|
|
5777
|
+
// A live preview streamed by an agentic tool (e.g. generate_iframe). The
|
|
5778
|
+
// server sends only the inner document; the client owns the <iframe> element
|
|
5779
|
+
// and hard-codes the sandbox (no allow-scripts) so injected markup is styled
|
|
5780
|
+
// but never executed — the markdown text path can't render HTML at all.
|
|
5781
|
+
appendEmbed(event) {
|
|
5782
|
+
const message = this.currentAssistantMessage();
|
|
5783
|
+
if (!message || !event.srcdoc)
|
|
5784
|
+
return;
|
|
5785
|
+
const body = message.querySelector(".command-palette-message-body") || message;
|
|
5786
|
+
const frame = document.createElement("iframe");
|
|
5787
|
+
frame.className = "command-palette-embed";
|
|
5788
|
+
frame.setAttribute("sandbox", "allow-same-origin");
|
|
5789
|
+
frame.setAttribute("loading", "lazy");
|
|
5790
|
+
frame.title = event.title || "Preview";
|
|
5791
|
+
frame.srcdoc = event.srcdoc;
|
|
5792
|
+
if (event.height)
|
|
5793
|
+
frame.style.height = `${event.height}px`;
|
|
5794
|
+
body.appendChild(frame);
|
|
5795
|
+
this.scrollTranscript();
|
|
5796
|
+
}
|
|
5755
5797
|
currentActionFooter() {
|
|
5798
|
+
const last = this.currentAssistantMessage();
|
|
5799
|
+
return last ? last.querySelector(".command-palette-action-footer") : null;
|
|
5800
|
+
}
|
|
5801
|
+
currentAssistantMessage() {
|
|
5756
5802
|
const messages = this.transcriptTarget.querySelectorAll(
|
|
5757
5803
|
".command-palette-message.role-assistant"
|
|
5758
5804
|
);
|
|
5759
|
-
|
|
5760
|
-
return last ? last.querySelector(".command-palette-action-footer") : null;
|
|
5805
|
+
return messages[messages.length - 1] || null;
|
|
5761
5806
|
}
|
|
5762
5807
|
performDirective(directive) {
|
|
5763
5808
|
if (directive.url) {
|
|
@@ -5797,10 +5842,21 @@ class CommandPalette extends Controller {
|
|
|
5797
5842
|
if (this.hasTranscriptTarget)
|
|
5798
5843
|
this.transcriptTarget.setAttribute("aria-busy", String(busy));
|
|
5799
5844
|
}
|
|
5845
|
+
// The body is the scrollable region; the transcript itself does not scroll.
|
|
5800
5846
|
scrollTranscript() {
|
|
5801
|
-
if (this.
|
|
5802
|
-
this.
|
|
5847
|
+
if (this.hasBodyTarget && this.stickToBottom) {
|
|
5848
|
+
this.bodyTarget.scrollTop = this.bodyTarget.scrollHeight;
|
|
5849
|
+
}
|
|
5803
5850
|
}
|
|
5851
|
+
// Track whether the user is anchored to the bottom. Once they scroll up to
|
|
5852
|
+
// read earlier messages we stop auto-scrolling; returning to the bottom
|
|
5853
|
+
// re-arms it so streamed text keeps the latest content on screen.
|
|
5854
|
+
handleTranscriptScroll = () => {
|
|
5855
|
+
if (!this.hasBodyTarget)
|
|
5856
|
+
return;
|
|
5857
|
+
const { scrollHeight, scrollTop, clientHeight } = this.bodyTarget;
|
|
5858
|
+
this.stickToBottom = scrollHeight - scrollTop - clientHeight < 24;
|
|
5859
|
+
};
|
|
5804
5860
|
appendMessage(role, text) {
|
|
5805
5861
|
const li = cloneTemplate(this.messageTemplateTarget);
|
|
5806
5862
|
li.classList.add(`role-${role}`);
|
|
@@ -6889,35 +6945,29 @@ class ViewportObserver extends Controller {
|
|
|
6889
6945
|
once: { type: Boolean, default: false },
|
|
6890
6946
|
visibleClass: { type: String, default: "" }
|
|
6891
6947
|
};
|
|
6892
|
-
|
|
6948
|
+
teardown = null;
|
|
6893
6949
|
connect() {
|
|
6894
|
-
this.
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6950
|
+
this.teardown = observeViewport(this.element, {
|
|
6951
|
+
threshold: this.thresholdValue,
|
|
6952
|
+
rootMargin: this.rootMarginValue,
|
|
6953
|
+
once: this.onceValue,
|
|
6954
|
+
onEnter: (entry) => {
|
|
6955
|
+
this.element.setAttribute("data-intersecting", "true");
|
|
6956
|
+
if (this.visibleClassValue)
|
|
6957
|
+
this.element.classList.add(this.visibleClassValue);
|
|
6958
|
+
this.dispatch("enter", { detail: { entry }, bubbles: true });
|
|
6898
6959
|
},
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
6960
|
+
onLeave: (entry) => {
|
|
6961
|
+
this.element.setAttribute("data-intersecting", "false");
|
|
6962
|
+
if (this.visibleClassValue)
|
|
6963
|
+
this.element.classList.remove(this.visibleClassValue);
|
|
6964
|
+
this.dispatch("leave", { detail: { entry }, bubbles: true });
|
|
6965
|
+
}
|
|
6966
|
+
});
|
|
6902
6967
|
}
|
|
6903
6968
|
disconnect() {
|
|
6904
|
-
this.
|
|
6905
|
-
this.
|
|
6906
|
-
}
|
|
6907
|
-
handle(entry) {
|
|
6908
|
-
if (entry.isIntersecting) {
|
|
6909
|
-
this.element.setAttribute("data-intersecting", "true");
|
|
6910
|
-
if (this.visibleClassValue)
|
|
6911
|
-
this.element.classList.add(this.visibleClassValue);
|
|
6912
|
-
this.dispatch("enter", { detail: { entry }, bubbles: true });
|
|
6913
|
-
if (this.onceValue)
|
|
6914
|
-
this.disconnect();
|
|
6915
|
-
} else {
|
|
6916
|
-
this.element.setAttribute("data-intersecting", "false");
|
|
6917
|
-
if (this.visibleClassValue)
|
|
6918
|
-
this.element.classList.remove(this.visibleClassValue);
|
|
6919
|
-
this.dispatch("leave", { detail: { entry }, bubbles: true });
|
|
6920
|
-
}
|
|
6969
|
+
this.teardown?.();
|
|
6970
|
+
this.teardown = null;
|
|
6921
6971
|
}
|
|
6922
6972
|
}
|
|
6923
6973
|
|
|
@@ -7307,6 +7357,541 @@ class ThemeSwitch extends Controller {
|
|
|
7307
7357
|
}
|
|
7308
7358
|
}
|
|
7309
7359
|
|
|
7360
|
+
const FRICTION = 0.92;
|
|
7361
|
+
const MIN_VELOCITY = 0.05;
|
|
7362
|
+
const KEY_STEP_FRACTION = 0.12;
|
|
7363
|
+
class Panorama extends Controller {
|
|
7364
|
+
static targets = ["viewport", "stage", "media", "status", "hint", "reset"];
|
|
7365
|
+
static values = {
|
|
7366
|
+
wrap: { type: Boolean, default: false },
|
|
7367
|
+
autoRotate: { type: Number, default: 0 },
|
|
7368
|
+
start: { type: Number, default: 0 }
|
|
7369
|
+
};
|
|
7370
|
+
offset = 0;
|
|
7371
|
+
// current translateX, always <= 0
|
|
7372
|
+
mediaWidth = 0;
|
|
7373
|
+
// rendered width of a single media copy
|
|
7374
|
+
viewportWidth = 0;
|
|
7375
|
+
dragging = false;
|
|
7376
|
+
pointerId = null;
|
|
7377
|
+
lastX = 0;
|
|
7378
|
+
lastMoveTime = 0;
|
|
7379
|
+
velocity = 0;
|
|
7380
|
+
inertiaRaf = null;
|
|
7381
|
+
autoRaf = null;
|
|
7382
|
+
lastAutoTime = 0;
|
|
7383
|
+
clone = null;
|
|
7384
|
+
resizeObserver = null;
|
|
7385
|
+
interacted = false;
|
|
7386
|
+
connect() {
|
|
7387
|
+
this.viewportTarget.addEventListener("pointerdown", this.onPointerDown);
|
|
7388
|
+
this.element.addEventListener("keydown", this.onKeydown);
|
|
7389
|
+
this.element.addEventListener("pointerenter", this.pauseAuto);
|
|
7390
|
+
this.element.addEventListener("pointerleave", this.resumeAuto);
|
|
7391
|
+
this.element.addEventListener("focusin", this.pauseAuto);
|
|
7392
|
+
this.element.addEventListener("focusout", this.resumeAuto);
|
|
7393
|
+
this.resizeObserver = new ResizeObserver(() => this.measure());
|
|
7394
|
+
this.resizeObserver.observe(this.viewportTarget);
|
|
7395
|
+
this.whenMediaReady(() => {
|
|
7396
|
+
this.setupWrap();
|
|
7397
|
+
this.measure();
|
|
7398
|
+
this.offset = this.startOffset();
|
|
7399
|
+
this.apply();
|
|
7400
|
+
this.startAuto();
|
|
7401
|
+
});
|
|
7402
|
+
}
|
|
7403
|
+
disconnect() {
|
|
7404
|
+
this.viewportTarget.removeEventListener("pointerdown", this.onPointerDown);
|
|
7405
|
+
window.removeEventListener("pointermove", this.onPointerMove);
|
|
7406
|
+
window.removeEventListener("pointerup", this.onPointerUp);
|
|
7407
|
+
window.removeEventListener("pointercancel", this.onPointerUp);
|
|
7408
|
+
this.element.removeEventListener("keydown", this.onKeydown);
|
|
7409
|
+
this.element.removeEventListener("pointerenter", this.pauseAuto);
|
|
7410
|
+
this.element.removeEventListener("pointerleave", this.resumeAuto);
|
|
7411
|
+
this.element.removeEventListener("focusin", this.pauseAuto);
|
|
7412
|
+
this.element.removeEventListener("focusout", this.resumeAuto);
|
|
7413
|
+
this.resizeObserver?.disconnect();
|
|
7414
|
+
this.stopInertia();
|
|
7415
|
+
this.stopAuto();
|
|
7416
|
+
}
|
|
7417
|
+
// Recenters the view to its starting position (wired to the reset button).
|
|
7418
|
+
reset() {
|
|
7419
|
+
this.stopInertia();
|
|
7420
|
+
this.markInteracted();
|
|
7421
|
+
this.offset = this.startOffset();
|
|
7422
|
+
this.apply();
|
|
7423
|
+
this.announce("View reset");
|
|
7424
|
+
}
|
|
7425
|
+
// --- media readiness & measurement -----------------------------------------
|
|
7426
|
+
whenMediaReady(cb) {
|
|
7427
|
+
const media = this.mediaTarget;
|
|
7428
|
+
if (media instanceof HTMLImageElement) {
|
|
7429
|
+
if (media.complete && media.naturalWidth > 0) {
|
|
7430
|
+
cb();
|
|
7431
|
+
return;
|
|
7432
|
+
}
|
|
7433
|
+
media.addEventListener("load", cb, { once: true });
|
|
7434
|
+
media.addEventListener("error", cb, { once: true });
|
|
7435
|
+
} else {
|
|
7436
|
+
if (media.readyState >= 1 && media.videoWidth > 0) {
|
|
7437
|
+
cb();
|
|
7438
|
+
return;
|
|
7439
|
+
}
|
|
7440
|
+
media.addEventListener("loadedmetadata", cb, { once: true });
|
|
7441
|
+
}
|
|
7442
|
+
}
|
|
7443
|
+
measure() {
|
|
7444
|
+
this.viewportWidth = this.viewportTarget.clientWidth;
|
|
7445
|
+
const first = this.stageTarget.querySelector(".midwest-panorama-media");
|
|
7446
|
+
this.mediaWidth = first?.getBoundingClientRect().width ?? 0;
|
|
7447
|
+
this.offset = this.normalize(this.offset);
|
|
7448
|
+
this.apply();
|
|
7449
|
+
}
|
|
7450
|
+
// Duplicates the image once so the stage can loop seamlessly. Only images
|
|
7451
|
+
// wrap — a live <video> can't be cheaply cloned/kept in sync.
|
|
7452
|
+
setupWrap() {
|
|
7453
|
+
if (!this.wrapValue || this.clone)
|
|
7454
|
+
return;
|
|
7455
|
+
if (!(this.mediaTarget instanceof HTMLImageElement))
|
|
7456
|
+
return;
|
|
7457
|
+
const clone = this.mediaTarget.cloneNode(true);
|
|
7458
|
+
clone.setAttribute("aria-hidden", "true");
|
|
7459
|
+
clone.setAttribute("alt", "");
|
|
7460
|
+
clone.removeAttribute("data-midwest-panorama-target");
|
|
7461
|
+
this.stageTarget.appendChild(clone);
|
|
7462
|
+
this.clone = clone;
|
|
7463
|
+
}
|
|
7464
|
+
// --- geometry --------------------------------------------------------------
|
|
7465
|
+
canWrap() {
|
|
7466
|
+
return this.wrapValue && this.clone !== null && this.mediaWidth > 0;
|
|
7467
|
+
}
|
|
7468
|
+
// Constrains an offset: modulo for seamless wrap, hard clamp otherwise.
|
|
7469
|
+
normalize(x) {
|
|
7470
|
+
const w = this.mediaWidth;
|
|
7471
|
+
if (this.canWrap()) {
|
|
7472
|
+
if (w <= 0)
|
|
7473
|
+
return 0;
|
|
7474
|
+
let n = x % w;
|
|
7475
|
+
if (n > 0)
|
|
7476
|
+
n -= w;
|
|
7477
|
+
return n;
|
|
7478
|
+
}
|
|
7479
|
+
const min = Math.min(0, this.viewportWidth - w);
|
|
7480
|
+
return Math.max(min, Math.min(0, x));
|
|
7481
|
+
}
|
|
7482
|
+
startOffset() {
|
|
7483
|
+
if (this.canWrap())
|
|
7484
|
+
return this.normalize(-this.startValue * this.mediaWidth);
|
|
7485
|
+
const min = Math.min(0, this.viewportWidth - this.mediaWidth);
|
|
7486
|
+
return min * this.startValue;
|
|
7487
|
+
}
|
|
7488
|
+
apply() {
|
|
7489
|
+
this.stageTarget.style.transform = `translate3d(${this.offset}px, 0, 0)`;
|
|
7490
|
+
}
|
|
7491
|
+
// --- pointer dragging ------------------------------------------------------
|
|
7492
|
+
onPointerDown = (event) => {
|
|
7493
|
+
if (event.button !== 0)
|
|
7494
|
+
return;
|
|
7495
|
+
this.dragging = true;
|
|
7496
|
+
this.pointerId = event.pointerId;
|
|
7497
|
+
this.lastX = event.clientX;
|
|
7498
|
+
this.lastMoveTime = performance.now();
|
|
7499
|
+
this.velocity = 0;
|
|
7500
|
+
this.stopInertia();
|
|
7501
|
+
this.pauseAuto();
|
|
7502
|
+
this.markInteracted();
|
|
7503
|
+
this.element.classList.add("is-dragging");
|
|
7504
|
+
this.viewportTarget.setPointerCapture(event.pointerId);
|
|
7505
|
+
window.addEventListener("pointermove", this.onPointerMove);
|
|
7506
|
+
window.addEventListener("pointerup", this.onPointerUp);
|
|
7507
|
+
window.addEventListener("pointercancel", this.onPointerUp);
|
|
7508
|
+
};
|
|
7509
|
+
onPointerMove = (event) => {
|
|
7510
|
+
if (!this.dragging)
|
|
7511
|
+
return;
|
|
7512
|
+
const dx = event.clientX - this.lastX;
|
|
7513
|
+
const now = performance.now();
|
|
7514
|
+
const dt = now - this.lastMoveTime;
|
|
7515
|
+
if (dt > 0)
|
|
7516
|
+
this.velocity = dx / dt * 16;
|
|
7517
|
+
this.lastX = event.clientX;
|
|
7518
|
+
this.lastMoveTime = now;
|
|
7519
|
+
this.offset = this.normalize(this.offset + dx);
|
|
7520
|
+
this.apply();
|
|
7521
|
+
};
|
|
7522
|
+
onPointerUp = () => {
|
|
7523
|
+
if (!this.dragging)
|
|
7524
|
+
return;
|
|
7525
|
+
this.dragging = false;
|
|
7526
|
+
this.element.classList.remove("is-dragging");
|
|
7527
|
+
if (this.pointerId !== null) {
|
|
7528
|
+
try {
|
|
7529
|
+
this.viewportTarget.releasePointerCapture(this.pointerId);
|
|
7530
|
+
} catch {
|
|
7531
|
+
}
|
|
7532
|
+
}
|
|
7533
|
+
this.pointerId = null;
|
|
7534
|
+
window.removeEventListener("pointermove", this.onPointerMove);
|
|
7535
|
+
window.removeEventListener("pointerup", this.onPointerUp);
|
|
7536
|
+
window.removeEventListener("pointercancel", this.onPointerUp);
|
|
7537
|
+
this.startInertia();
|
|
7538
|
+
};
|
|
7539
|
+
startInertia() {
|
|
7540
|
+
if (Math.abs(this.velocity) < MIN_VELOCITY) {
|
|
7541
|
+
this.resumeAuto();
|
|
7542
|
+
return;
|
|
7543
|
+
}
|
|
7544
|
+
const step = () => {
|
|
7545
|
+
this.offset = this.normalize(this.offset + this.velocity);
|
|
7546
|
+
this.apply();
|
|
7547
|
+
this.velocity *= FRICTION;
|
|
7548
|
+
if (!this.canWrap()) {
|
|
7549
|
+
const min = Math.min(0, this.viewportWidth - this.mediaWidth);
|
|
7550
|
+
if (this.offset <= min || this.offset >= 0)
|
|
7551
|
+
this.velocity = 0;
|
|
7552
|
+
}
|
|
7553
|
+
if (Math.abs(this.velocity) < MIN_VELOCITY) {
|
|
7554
|
+
this.inertiaRaf = null;
|
|
7555
|
+
this.resumeAuto();
|
|
7556
|
+
return;
|
|
7557
|
+
}
|
|
7558
|
+
this.inertiaRaf = requestAnimationFrame(step);
|
|
7559
|
+
};
|
|
7560
|
+
this.inertiaRaf = requestAnimationFrame(step);
|
|
7561
|
+
}
|
|
7562
|
+
stopInertia() {
|
|
7563
|
+
if (this.inertiaRaf !== null) {
|
|
7564
|
+
cancelAnimationFrame(this.inertiaRaf);
|
|
7565
|
+
this.inertiaRaf = null;
|
|
7566
|
+
}
|
|
7567
|
+
}
|
|
7568
|
+
// --- keyboard --------------------------------------------------------------
|
|
7569
|
+
onKeydown = (event) => {
|
|
7570
|
+
const step = this.viewportWidth * KEY_STEP_FRACTION;
|
|
7571
|
+
let handled = true;
|
|
7572
|
+
switch (event.key) {
|
|
7573
|
+
case "ArrowLeft":
|
|
7574
|
+
this.offset = this.normalize(this.offset + step);
|
|
7575
|
+
break;
|
|
7576
|
+
case "ArrowRight":
|
|
7577
|
+
this.offset = this.normalize(this.offset - step);
|
|
7578
|
+
break;
|
|
7579
|
+
case "Home":
|
|
7580
|
+
this.offset = this.startOffset();
|
|
7581
|
+
break;
|
|
7582
|
+
default:
|
|
7583
|
+
handled = false;
|
|
7584
|
+
}
|
|
7585
|
+
if (!handled)
|
|
7586
|
+
return;
|
|
7587
|
+
event.preventDefault();
|
|
7588
|
+
this.stopInertia();
|
|
7589
|
+
this.pauseAuto();
|
|
7590
|
+
this.markInteracted();
|
|
7591
|
+
this.apply();
|
|
7592
|
+
this.announce();
|
|
7593
|
+
};
|
|
7594
|
+
// --- auto-rotate -----------------------------------------------------------
|
|
7595
|
+
startAuto() {
|
|
7596
|
+
if (this.autoRotateValue <= 0 || this.autoRaf !== null || this.dragging)
|
|
7597
|
+
return;
|
|
7598
|
+
if (this.prefersReducedMotion())
|
|
7599
|
+
return;
|
|
7600
|
+
this.lastAutoTime = performance.now();
|
|
7601
|
+
const step = (now) => {
|
|
7602
|
+
const dt = (now - this.lastAutoTime) / 1e3;
|
|
7603
|
+
this.lastAutoTime = now;
|
|
7604
|
+
this.offset = this.normalize(this.offset - this.autoRotateValue * dt);
|
|
7605
|
+
this.apply();
|
|
7606
|
+
this.autoRaf = requestAnimationFrame(step);
|
|
7607
|
+
};
|
|
7608
|
+
this.autoRaf = requestAnimationFrame(step);
|
|
7609
|
+
}
|
|
7610
|
+
stopAuto() {
|
|
7611
|
+
if (this.autoRaf !== null) {
|
|
7612
|
+
cancelAnimationFrame(this.autoRaf);
|
|
7613
|
+
this.autoRaf = null;
|
|
7614
|
+
}
|
|
7615
|
+
}
|
|
7616
|
+
pauseAuto = () => {
|
|
7617
|
+
this.stopAuto();
|
|
7618
|
+
};
|
|
7619
|
+
resumeAuto = () => {
|
|
7620
|
+
if (this.dragging)
|
|
7621
|
+
return;
|
|
7622
|
+
this.startAuto();
|
|
7623
|
+
};
|
|
7624
|
+
// --- helpers ---------------------------------------------------------------
|
|
7625
|
+
markInteracted() {
|
|
7626
|
+
if (this.interacted)
|
|
7627
|
+
return;
|
|
7628
|
+
this.interacted = true;
|
|
7629
|
+
this.element.classList.add("has-interacted");
|
|
7630
|
+
}
|
|
7631
|
+
announce(message) {
|
|
7632
|
+
if (!this.hasStatusTarget)
|
|
7633
|
+
return;
|
|
7634
|
+
this.statusTarget.textContent = message ?? `Viewing ${this.percent()}%`;
|
|
7635
|
+
}
|
|
7636
|
+
percent() {
|
|
7637
|
+
const w = this.mediaWidth;
|
|
7638
|
+
if (w <= 0)
|
|
7639
|
+
return 0;
|
|
7640
|
+
if (this.canWrap()) {
|
|
7641
|
+
const n = (-this.offset % w + w) % w;
|
|
7642
|
+
return Math.round(n / w * 100);
|
|
7643
|
+
}
|
|
7644
|
+
const min = Math.min(0, this.viewportWidth - w);
|
|
7645
|
+
return min === 0 ? 0 : Math.round(this.offset / min * 100);
|
|
7646
|
+
}
|
|
7647
|
+
prefersReducedMotion() {
|
|
7648
|
+
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
|
7649
|
+
}
|
|
7650
|
+
}
|
|
7651
|
+
|
|
7652
|
+
function formatTime(seconds) {
|
|
7653
|
+
const mins = Math.floor(seconds / 60);
|
|
7654
|
+
const secs = Math.floor(seconds % 60);
|
|
7655
|
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
7656
|
+
}
|
|
7657
|
+
class Playlist extends Controller {
|
|
7658
|
+
static targets = [
|
|
7659
|
+
"audio",
|
|
7660
|
+
"audioSource",
|
|
7661
|
+
"playIcon",
|
|
7662
|
+
"pauseIcon",
|
|
7663
|
+
"trackTitle",
|
|
7664
|
+
"trackArtist",
|
|
7665
|
+
"artworkContainer",
|
|
7666
|
+
"placeholderIcon",
|
|
7667
|
+
"progressBar",
|
|
7668
|
+
"currentTime",
|
|
7669
|
+
"totalTime",
|
|
7670
|
+
"songList",
|
|
7671
|
+
"details"
|
|
7672
|
+
];
|
|
7673
|
+
static values = {
|
|
7674
|
+
playlistName: { type: String, default: "Playlist" },
|
|
7675
|
+
autoplay: { type: Boolean, default: false },
|
|
7676
|
+
view: { type: String, default: "playlist" },
|
|
7677
|
+
playlistVisible: { type: Boolean, default: true },
|
|
7678
|
+
artwork: { type: Boolean, default: false }
|
|
7679
|
+
};
|
|
7680
|
+
currentIndex = 0;
|
|
7681
|
+
songElements = [];
|
|
7682
|
+
connect() {
|
|
7683
|
+
this.songElements = Array.from(this.songListTarget.querySelectorAll(".midwest-song button"));
|
|
7684
|
+
this.updatePlaylistVisibility();
|
|
7685
|
+
this.audioTarget.addEventListener("timeupdate", this.handleTimeUpdate);
|
|
7686
|
+
this.audioTarget.addEventListener("loadedmetadata", this.handleMetadataLoaded);
|
|
7687
|
+
this.audioTarget.addEventListener("ended", this.handleEnded);
|
|
7688
|
+
this.audioTarget.addEventListener("play", this.handlePlayEvent);
|
|
7689
|
+
this.audioTarget.addEventListener("pause", this.handlePauseEvent);
|
|
7690
|
+
this.element.addEventListener("keydown", this.handleKeydown);
|
|
7691
|
+
if (this.autoplayValue && this.songElements.length > 0) {
|
|
7692
|
+
this.loadSong(0);
|
|
7693
|
+
}
|
|
7694
|
+
}
|
|
7695
|
+
disconnect() {
|
|
7696
|
+
this.audioTarget.removeEventListener("timeupdate", this.handleTimeUpdate);
|
|
7697
|
+
this.audioTarget.removeEventListener("loadedmetadata", this.handleMetadataLoaded);
|
|
7698
|
+
this.audioTarget.removeEventListener("ended", this.handleEnded);
|
|
7699
|
+
this.audioTarget.removeEventListener("play", this.handlePlayEvent);
|
|
7700
|
+
this.audioTarget.removeEventListener("pause", this.handlePauseEvent);
|
|
7701
|
+
this.element.removeEventListener("keydown", this.handleKeydown);
|
|
7702
|
+
}
|
|
7703
|
+
// Play/pause toggle
|
|
7704
|
+
togglePlay() {
|
|
7705
|
+
if (this.audioTarget.paused) {
|
|
7706
|
+
if (this.audioSourceTarget.src) {
|
|
7707
|
+
this.audioTarget.play();
|
|
7708
|
+
} else if (this.songElements.length > 0) {
|
|
7709
|
+
this.loadSong(0);
|
|
7710
|
+
}
|
|
7711
|
+
} else {
|
|
7712
|
+
this.audioTarget.pause();
|
|
7713
|
+
}
|
|
7714
|
+
}
|
|
7715
|
+
// Play the song whose button was clicked (wired from the song template)
|
|
7716
|
+
playSong(event) {
|
|
7717
|
+
const button = event.currentTarget;
|
|
7718
|
+
const index = this.songElements.indexOf(button);
|
|
7719
|
+
if (index === -1)
|
|
7720
|
+
return;
|
|
7721
|
+
if (index === this.currentIndex && this.audioSourceTarget.src) {
|
|
7722
|
+
this.togglePlay();
|
|
7723
|
+
return;
|
|
7724
|
+
}
|
|
7725
|
+
this.loadSong(index);
|
|
7726
|
+
}
|
|
7727
|
+
// Load and play a specific song by index
|
|
7728
|
+
loadSong(index) {
|
|
7729
|
+
const songButton = this.songElements[index];
|
|
7730
|
+
if (!songButton)
|
|
7731
|
+
return;
|
|
7732
|
+
const songElement = songButton.closest(".midwest-song");
|
|
7733
|
+
if (!songElement)
|
|
7734
|
+
return;
|
|
7735
|
+
const src = songElement.dataset.src;
|
|
7736
|
+
const title = songElement.dataset.title || "Unknown";
|
|
7737
|
+
const artist = songElement.dataset.artist || "Unknown";
|
|
7738
|
+
const picture = songElement.dataset.picture;
|
|
7739
|
+
if (!src)
|
|
7740
|
+
return;
|
|
7741
|
+
this.audioSourceTarget.src = src;
|
|
7742
|
+
this.audioTarget.load();
|
|
7743
|
+
this.trackTitleTarget.textContent = title;
|
|
7744
|
+
this.trackArtistTarget.textContent = artist;
|
|
7745
|
+
if (picture) {
|
|
7746
|
+
const img = document.createElement("img");
|
|
7747
|
+
img.src = picture;
|
|
7748
|
+
img.alt = `Album art for ${title}`;
|
|
7749
|
+
img.className = "midwest-playlist__artwork-image";
|
|
7750
|
+
const existingImg = this.artworkContainerTarget.querySelector(".midwest-playlist__artwork-image");
|
|
7751
|
+
if (existingImg) {
|
|
7752
|
+
existingImg.replaceWith(img);
|
|
7753
|
+
} else {
|
|
7754
|
+
this.artworkContainerTarget.appendChild(img);
|
|
7755
|
+
}
|
|
7756
|
+
this.placeholderIconTarget.classList.add("hidden");
|
|
7757
|
+
} else {
|
|
7758
|
+
this.placeholderIconTarget.classList.remove("hidden");
|
|
7759
|
+
}
|
|
7760
|
+
this.songElements.forEach((btn, i) => {
|
|
7761
|
+
const song = btn.closest(".midwest-song");
|
|
7762
|
+
if (i === index) {
|
|
7763
|
+
song.classList.add("is-playing");
|
|
7764
|
+
} else {
|
|
7765
|
+
song.classList.remove("is-playing");
|
|
7766
|
+
}
|
|
7767
|
+
});
|
|
7768
|
+
this.currentIndex = index;
|
|
7769
|
+
this.audioTarget.play().catch(() => {
|
|
7770
|
+
});
|
|
7771
|
+
}
|
|
7772
|
+
// Play next song
|
|
7773
|
+
next() {
|
|
7774
|
+
const nextIndex = (this.currentIndex + 1) % this.songElements.length;
|
|
7775
|
+
this.loadSong(nextIndex);
|
|
7776
|
+
}
|
|
7777
|
+
// Play previous song
|
|
7778
|
+
previous() {
|
|
7779
|
+
const prevIndex = this.currentIndex === 0 ? this.songElements.length - 1 : this.currentIndex - 1;
|
|
7780
|
+
this.loadSong(prevIndex);
|
|
7781
|
+
}
|
|
7782
|
+
// Seek to position in track
|
|
7783
|
+
seek(event) {
|
|
7784
|
+
const rect = this.progressBarTarget.getBoundingClientRect();
|
|
7785
|
+
const x = event.clientX - rect.left;
|
|
7786
|
+
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
|
7787
|
+
if (this.audioTarget.duration) {
|
|
7788
|
+
this.audioTarget.currentTime = percentage * this.audioTarget.duration;
|
|
7789
|
+
}
|
|
7790
|
+
}
|
|
7791
|
+
// Toggle playlist visibility
|
|
7792
|
+
togglePlaylist() {
|
|
7793
|
+
this.playlistVisibleValue = !this.playlistVisibleValue;
|
|
7794
|
+
this.updatePlaylistVisibility();
|
|
7795
|
+
this.savePlaylistVisibility();
|
|
7796
|
+
}
|
|
7797
|
+
// Toggle between playlist and art view
|
|
7798
|
+
toggleView() {
|
|
7799
|
+
this.viewValue = this.viewValue === "playlist" ? "art" : "playlist";
|
|
7800
|
+
this.updateView();
|
|
7801
|
+
}
|
|
7802
|
+
// Private: Handle time update for progress bar
|
|
7803
|
+
handleTimeUpdate = () => {
|
|
7804
|
+
const { currentTime, duration } = this.audioTarget;
|
|
7805
|
+
if (duration) {
|
|
7806
|
+
const percentage = currentTime / duration * 100;
|
|
7807
|
+
this.progressBarTarget.value = percentage;
|
|
7808
|
+
this.currentTimeTarget.textContent = formatTime(currentTime);
|
|
7809
|
+
this.totalTimeTarget.textContent = formatTime(duration);
|
|
7810
|
+
}
|
|
7811
|
+
};
|
|
7812
|
+
// Private: Handle metadata loaded
|
|
7813
|
+
handleMetadataLoaded = () => {
|
|
7814
|
+
const { duration } = this.audioTarget;
|
|
7815
|
+
if (duration) {
|
|
7816
|
+
this.totalTimeTarget.textContent = formatTime(duration);
|
|
7817
|
+
}
|
|
7818
|
+
};
|
|
7819
|
+
// Private: advance to the next track when the current one finishes
|
|
7820
|
+
handleEnded = () => {
|
|
7821
|
+
this.next();
|
|
7822
|
+
};
|
|
7823
|
+
// Private: reflect the <audio> playing state in the UI
|
|
7824
|
+
handlePlayEvent = () => {
|
|
7825
|
+
this.playIconTarget.hidden = true;
|
|
7826
|
+
this.pauseIconTarget.hidden = false;
|
|
7827
|
+
this.element.classList.add("is-playing");
|
|
7828
|
+
};
|
|
7829
|
+
handlePauseEvent = () => {
|
|
7830
|
+
this.playIconTarget.hidden = false;
|
|
7831
|
+
this.pauseIconTarget.hidden = true;
|
|
7832
|
+
this.element.classList.remove("is-playing");
|
|
7833
|
+
};
|
|
7834
|
+
// Private: Handle keyboard controls
|
|
7835
|
+
handleKeydown = (event) => {
|
|
7836
|
+
if (event.key === " " || event.key === "k") {
|
|
7837
|
+
event.preventDefault();
|
|
7838
|
+
this.togglePlay();
|
|
7839
|
+
} else if (event.key === "ArrowLeft") {
|
|
7840
|
+
event.preventDefault();
|
|
7841
|
+
this.previous();
|
|
7842
|
+
} else if (event.key === "ArrowRight") {
|
|
7843
|
+
event.preventDefault();
|
|
7844
|
+
this.next();
|
|
7845
|
+
}
|
|
7846
|
+
};
|
|
7847
|
+
// Private: Update playlist visibility
|
|
7848
|
+
updatePlaylistVisibility() {
|
|
7849
|
+
if (this.playlistVisibleValue) {
|
|
7850
|
+
this.songListTarget.classList.remove("is-hidden");
|
|
7851
|
+
} else {
|
|
7852
|
+
this.songListTarget.classList.add("is-hidden");
|
|
7853
|
+
}
|
|
7854
|
+
}
|
|
7855
|
+
// Private: Update view mode
|
|
7856
|
+
updateView() {
|
|
7857
|
+
this.element.setAttribute("view", this.viewValue);
|
|
7858
|
+
}
|
|
7859
|
+
// Private: Save playlist visibility to localStorage
|
|
7860
|
+
savePlaylistVisibility() {
|
|
7861
|
+
try {
|
|
7862
|
+
localStorage.setItem(`playlist-${this.playlistNameValue}-visibility`, this.playlistVisibleValue.toString());
|
|
7863
|
+
} catch {
|
|
7864
|
+
}
|
|
7865
|
+
}
|
|
7866
|
+
}
|
|
7867
|
+
|
|
7868
|
+
class Motion extends Controller {
|
|
7869
|
+
static values = {
|
|
7870
|
+
threshold: { type: Number, default: 0.15 },
|
|
7871
|
+
once: { type: Boolean, default: true }
|
|
7872
|
+
};
|
|
7873
|
+
teardown = null;
|
|
7874
|
+
connect() {
|
|
7875
|
+
this.teardown = observeViewport(this.element, {
|
|
7876
|
+
threshold: this.thresholdValue,
|
|
7877
|
+
once: this.onceValue,
|
|
7878
|
+
onEnter: () => this.reveal(),
|
|
7879
|
+
onLeave: this.onceValue ? void 0 : () => this.reset()
|
|
7880
|
+
});
|
|
7881
|
+
}
|
|
7882
|
+
disconnect() {
|
|
7883
|
+
this.teardown?.();
|
|
7884
|
+
this.teardown = null;
|
|
7885
|
+
}
|
|
7886
|
+
reveal() {
|
|
7887
|
+
this.element.setAttribute("data-motion-visible", "");
|
|
7888
|
+
this.dispatch("reveal", { bubbles: true });
|
|
7889
|
+
}
|
|
7890
|
+
reset() {
|
|
7891
|
+
this.element.removeAttribute("data-motion-visible");
|
|
7892
|
+
}
|
|
7893
|
+
}
|
|
7894
|
+
|
|
7310
7895
|
function registerMidwestControllers(application) {
|
|
7311
7896
|
application.register("midwest-card", Card);
|
|
7312
7897
|
application.register("midwest-banner", Banner);
|
|
@@ -7346,6 +7931,9 @@ function registerMidwestControllers(application) {
|
|
|
7346
7931
|
application.register("midwest-focus-trap", FocusTrap);
|
|
7347
7932
|
application.register("midwest-onboarding", Onboarding);
|
|
7348
7933
|
application.register("midwest-theme-switch", ThemeSwitch);
|
|
7934
|
+
application.register("midwest-panorama", Panorama);
|
|
7935
|
+
application.register("midwest-playlist", Playlist);
|
|
7936
|
+
application.register("midwest-motion", Motion);
|
|
7349
7937
|
}
|
|
7350
7938
|
|
|
7351
7939
|
export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };
|