impress/app/assets/javascripts/outfit-viewer.js
Emi Matchu 5e68d3809c [WV2] Fix syncing for play/pause state across page morphs
Reproduce:
1. Add an item with animations, and play them.
2. Remove the item.
3. Add it back.
4. Observe the button shows up in "Paused" state, even though it's playing.

This is because the server-side template wasn't doing anything to try to keep the play/pause button it renders in sync with the current saved state in the cookies, so it was always causing a morph to the pause state. Now we listen to the cookie instead!

I also updated the JS behavior to be a bit more consistent: treat the behavior as defaulting to true, unless it's explicitly set to the string "false".
2026-02-05 19:15:09 -08:00

465 lines
14 KiB
JavaScript

class OutfitViewer extends HTMLElement {
#internals;
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
#hasAnimations = false; // Track hasAnimations state internally (Safari CustomStateSet bug workaround)
constructor() {
super();
this.#internals = this.attachInternals(); // for CSS `:state()`
}
connectedCallback() {
const observer = new MutationObserver((mutations) => {
// When a layer is added, update its playing state to match ours.
const addedLayers = mutations
.flatMap(m => [...m.addedNodes])
.filter(n => n.tagName === "OUTFIT-LAYER");
for (const layer of addedLayers) {
if (this.#internals.states.has("playing")) {
layer.play();
} else {
layer.pause();
}
}
const removedLayers = mutations
.flatMap(m => [...m.removedNodes])
.filter(n => n.tagName === "OUTFIT-LAYER");
// If any layers were added or removed, updated our hasAnimations state.
if (addedLayers.length > 0 || removedLayers.length > 0) {
this.#updateHasAnimations();
}
});
observer.observe(this, { childList: true });
// When a new layer finishes loading and determines it has animations, update.
this.addEventListener("hasanimationschange", (e) => {
// Only handle events from outfit-layer children, not from ourselves
if (e.target === this) return;
this.#updateHasAnimations();
});
// The `<outfit-layer>` is connected to the DOM right before its
// children are. So, to engage with the children, wait a tick!
setTimeout(() => this.#connectToChildren(), 0);
}
#connectToChildren() {
// Read initial playing state from cookie and initialize
const isPlayingFromCookie = this.#getIsPlayingCookie();
// Initialize the boolean before calling #setIsPlaying
// (We set it to the opposite first so #setIsPlaying detects a change)
this.#isPlaying = !isPlayingFromCookie;
this.#setIsPlaying(isPlayingFromCookie);
// Check initial animation state
this.#updateHasAnimations();
}
#updateHasAnimations() {
// Check if any layer has animations
const hasAnimations =
this.querySelector("outfit-layer:state(has-animations)") !== null;
// Check if state actually changed
if (hasAnimations === this.#hasAnimations) {
return; // No change, skip
}
// Update internal state
this.#hasAnimations = hasAnimations;
if (hasAnimations) {
this.#internals.states.add("has-animations");
} else {
this.#internals.states.delete("has-animations");
}
// Dispatch event so external components can react
this.dispatchEvent(
new CustomEvent("hasanimationschange", {
detail: { hasAnimations },
bubbles: true,
}),
);
}
/**
* Public API: Start playing animations
*/
play() {
this.#setIsPlaying(true);
}
/**
* Public API: Pause animations
*/
pause() {
this.#setIsPlaying(false);
}
/**
* Public API: Check if currently playing
*/
get isPlaying() {
return this.#isPlaying;
}
#setIsPlaying(isPlaying) {
// Skip if already in this state
if (this.#isPlaying === isPlaying) {
return;
}
// Update internal state (both boolean and CustomStateSet for CSS)
this.#isPlaying = isPlaying;
if (isPlaying) {
this.#internals.states.add("playing");
for (const layer of this.querySelectorAll("outfit-layer")) {
layer.play();
}
} else {
this.#internals.states.delete("playing");
for (const layer of this.querySelectorAll("outfit-layer")) {
layer.pause();
}
}
// Persist to cookie
this.#setIsPlayingCookie(isPlaying);
// Dispatch event so external components can react
this.dispatchEvent(
new CustomEvent("playingchange", {
detail: { isPlaying },
bubbles: true,
}),
);
}
#getIsPlayingCookie() {
const cookie = document.cookie
.split("; ")
.find((row) => row.startsWith("DTIOutfitViewerIsPlaying="));
if (cookie) {
return cookie.split("=")[1] !== "false";
}
return true; // Default to playing
}
#setIsPlayingCookie(isPlaying) {
const thirtyDays = 60 * 60 * 24 * 30;
const value = isPlaying ? "true" : "false";
document.cookie = `DTIOutfitViewerIsPlaying=${value};max-age=${thirtyDays}`;
}
}
class OutfitLayer extends HTMLElement {
#internals;
#pendingMessage = null; // Queue one message to send when iframe loads
constructor() {
super();
this.#internals = this.attachInternals();
// An <outfit-layer> starts in the loading state, and then might very
// quickly decide it's not after `#connectToChildren`. This is to prevent a
// flash of *non*-loading state, when a new layer loads in. (e.g. In the
// time between our parent <turbo-frame> loading, which shows the loading
// spinner; and us being marked `:state(loading)`, which shows the loading
// spinner; we don't want the loading spinner to do its usual *immediate*
// total fade-out; then have to fade back in again, on the usual delay.)
this.#setStatus("loading");
}
connectedCallback() {
setTimeout(() => this.#connectToChildren(), 0);
}
disconnectedCallback() {
// When this `<outfit-layer>` leaves the DOM, stop listening for iframe
// messages, if we were.
window.removeEventListener("message", this.#onMessage);
}
play() {
this.#sendMessageToIframe({ type: "play" });
}
pause() {
this.#sendMessageToIframe({ type: "pause" });
}
#connectToChildren() {
const image = this.querySelector("img");
const iframe = this.querySelector("iframe");
if (image) {
// If this is an image layer, track its loading state by listening
// to the load/error events, and initialize based on whether it's
// already `complete` (which it can be if it loaded from cache).
this.#setStatus(image.complete ? "loaded" : "loading");
image.addEventListener("load", () => this.#setStatus("loaded"));
image.addEventListener("error", () => this.#setStatus("error"));
} else if (iframe) {
this.iframe = iframe;
// Initialize status to `loading`, and asynchronously request a
// status message from the iframe if it managed to load before this
// triggers (impressive, but I think I've seen it happen!). Then,
// wait for messages or error events from the iframe to update
// status further if needed.
this.#setStatus("loading");
this.#sendMessageToIframe({ type: "requestStatus" });
window.addEventListener("message", (m) => this.#onMessage(m));
this.iframe.addEventListener("error", () => this.#setStatus("error"));
// If there was a pending play/pause message, send it now
if (this.#pendingMessage) {
this.#sendMessageToIframe(this.#pendingMessage);
this.#pendingMessage = null;
}
} else {
console.warn(`<outfit-layer> contained no image or iframe: `, this);
}
}
#onMessage({ source, data }) {
// Ignore messages that aren't from *our* frame.
if (source !== this.iframe.contentWindow) {
return;
}
// Validate the incoming status message, then set our status to match.
if (data.type === "status") {
if (data.status === "loaded") {
this.#setStatus("loaded");
this.#setHasAnimations(data.hasAnimations);
} else if (data.status === "error") {
this.#setStatus("error");
} else {
throw new Error(
`<outfit-layer> got unexpected status: ` +
JSON.stringify(data.status),
);
}
} else {
throw new Error(
`<outfit-layer> got unexpected message: ` + JSON.stringify(data),
);
}
}
/**
* Set the status value that the CSS `:state()` selector will match.
* For example, when loading, `:state(loading)` matches this element.
*/
#setStatus(newStatus) {
this.#internals.states.delete("loading");
this.#internals.states.delete("loaded");
this.#internals.states.delete("error");
this.#internals.states.add(newStatus);
}
/**
* Set whether CSS selector `:state(has-animations)` matches this element.
*/
#setHasAnimations(hasAnimations) {
// Check if state actually changed
const hadAnimations = this.#internals.states.has("has-animations");
if (hasAnimations === hadAnimations) {
return; // No change, skip
}
// Update internal state
if (hasAnimations) {
this.#internals.states.add("has-animations");
} else {
this.#internals.states.delete("has-animations");
}
// Dispatch event so parent OutfitViewer can react
this.dispatchEvent(
new CustomEvent("hasanimationschange", {
detail: { hasAnimations },
bubbles: true,
}),
);
}
#sendMessageToIframe(message) {
// If we have no frame, queue play/pause messages for later
if (this.iframe == null) {
if (message.type === "play" || message.type === "pause") {
this.#pendingMessage = message;
}
return;
}
if (this.iframe.contentWindow == null) {
console.debug(
`Queueing message, frame not loaded yet: `,
this.iframe,
message,
);
if (message.type === "play" || message.type === "pause") {
this.#pendingMessage = message;
}
return;
}
// The frame is sandboxed (origin == null), so send to Any origin.
this.iframe.contentWindow.postMessage(message, "*");
}
}
/**
* OutfitViewerPlayPauseToggle is a web component that creates a play/pause
* toggle button for an outfit-viewer, referenced by the `for` attribute.
*
* Usage:
* <outfit-viewer id="my-viewer">...</outfit-viewer>
* <outfit-viewer-play-pause-toggle for="my-viewer">
* <input type="checkbox" class="toggle-input">
* <span class="playing-label">Playing</span>
* <span class="paused-label">Paused</span>
* </outfit-viewer-play-pause-toggle>
*
* The toggle will:
* - Read initial state from the outfit-viewer
* - Update the checkbox when the outfit-viewer state changes
* - Call play()/pause() on the outfit-viewer when the checkbox changes
* - Show/hide based on whether the outfit has animations
*/
class OutfitViewerPlayPauseToggle extends HTMLElement {
#outfitViewer = null;
#checkbox = null;
connectedCallback() {
setTimeout(() => this.#connect(), 0);
}
#connect() {
// Find the outfit-viewer referenced by the `for` attribute
const forId = this.getAttribute("for");
if (!forId) {
console.warn(
"<outfit-viewer-play-pause-toggle> requires a 'for' attribute",
);
return;
}
this.#outfitViewer = document.getElementById(forId);
if (!this.#outfitViewer) {
console.warn(
`<outfit-viewer-play-pause-toggle> could not find outfit-viewer with id="${forId}"`,
);
return;
}
// Find the checkbox input
this.#checkbox = this.querySelector('input[type="checkbox"]');
if (!this.#checkbox) {
console.warn(
"<outfit-viewer-play-pause-toggle> requires a checkbox input child",
);
return;
}
// Sync initial state from outfit-viewer to checkbox
this.#syncFromOutfitViewer();
// Listen for checkbox changes and update outfit-viewer
this.#checkbox.addEventListener("change", () => {
if (this.#checkbox.checked) {
this.#outfitViewer.play();
} else {
this.#outfitViewer.pause();
}
});
// Listen for outfit-viewer state changes and update checkbox
this.#outfitViewer.addEventListener("playingchange", (e) => {
this.#checkbox.checked = e.detail.isPlaying;
});
// Listen for animation availability changes and update visibility
this.#outfitViewer.addEventListener("hasanimationschange", (e) => {
this.#updateVisibility(e.detail.hasAnimations);
});
// Set initial visibility based on current state
this.#updateVisibility(
this.#outfitViewer.querySelector(
"outfit-layer:state(has-animations)",
) !== null,
);
}
#syncFromOutfitViewer() {
if (this.#outfitViewer && this.#checkbox) {
this.#checkbox.checked = this.#outfitViewer.isPlaying;
}
}
#updateVisibility(hasAnimations) {
// Show/hide the toggle based on whether there are animations
// Use a custom state so CSS can control the display
if (hasAnimations) {
this.removeAttribute("hidden");
} else {
this.setAttribute("hidden", "");
}
}
}
customElements.define("outfit-viewer", OutfitViewer);
customElements.define("outfit-layer", OutfitLayer);
customElements.define(
"outfit-viewer-play-pause-toggle",
OutfitViewerPlayPauseToggle,
);
// Morph turbo-frames on this page, to reuse asset nodes when we want to—very
// important for movies!—but ensure that it *doesn't* do its usual behavior of
// aggressively reusing existing <outfit-layer> nodes for entirely different
// assets. (It's a lot clearer for managing the loading state, and not showing
// old incorrect layers!) (We also tried using `id` to enforce this… no luck.)
function morphWithOutfitLayers(currentElement, newElement) {
Idiomorph.morph(currentElement, newElement.innerHTML, {
morphStyle: "innerHTML",
callbacks: {
beforeNodeMorphed: (currentNode, newNode) => {
// If Idiomorph wants to transform an <outfit-layer> to
// have a different data-asset-id attribute, we replace
// the node ourselves and abort the morph.
if (
newNode.tagName === "OUTFIT-LAYER" &&
newNode.getAttribute("data-asset-id") !==
currentNode.getAttribute("data-asset-id")
) {
currentNode.replaceWith(newNode);
return false;
}
},
},
});
}
function onTurboRender(event) {
// Rather than enforce Idiomorph must be loaded, let's just be resilient
// and only bother if we have it. (Replacing content is not *that* bad!)
if (typeof Idiomorph !== "undefined") {
event.detail.render = (a, b) => morphWithOutfitLayers(a, b);
}
}
// On most pages, we only apply this to Turbo frames, to be conservative. (Morphing the whole page is hard!)
addEventListener("turbo:before-frame-render", onTurboRender);
// But on pages that opt into it (namely the wardrobe), we do it for the full page too.
if (document.querySelector('meta[name=outfit-viewer-morph-mode][value=full-page]') !== null) {
addEventListener("turbo:before-render", onTurboRender);
}