Add hacky play/pause toggle to new item page preview

This doesn't do a good job maintaining state across morphs, but hey
it's Working At All in terms of wiring, and that's good!!

Also need to style up the toggle as a cute button instead of a visible
checkbox and the words "Play/pause"!
This commit is contained in:
Emi Matchu 2024-07-08 13:43:28 -07:00
parent 7688caebe1
commit 1aba4f405e
4 changed files with 126 additions and 13 deletions

View file

@ -1,4 +1,45 @@
class OutfitViewer extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
this.#setIsPlaying(false);
}
connectedCallback() {
setTimeout(() => this.#connectToChildren(), 0);
}
#connectToChildren() {
const playPauseToggle = document.querySelector(".play-pause-toggle");
// Read our initial playing state from the toggle, and subscribe to changes.
this.#setIsPlaying(playPauseToggle.checked);
playPauseToggle.addEventListener("change", () => {
this.#setIsPlaying(playPauseToggle.checked);
});
}
#setIsPlaying(isPlaying) {
// TODO: Listen for changes to the child list, and add `playing` when new
// nodes arrive, if playing.
if (isPlaying) {
this.#internals.states.add("playing");
for (const child of this.children) {
child.setAttribute("playing", "");
}
} else {
this.#internals.states.delete("playing");
for (const child of this.children) {
child.removeAttribute("playing");
}
}
}
}
class OutfitLayer extends HTMLElement { class OutfitLayer extends HTMLElement {
static observedAttributes = ["playing"];
#internals; #internals;
constructor() { constructor() {
@ -23,6 +64,13 @@ class OutfitLayer extends HTMLElement {
window.removeEventListener("message", this.#onMessage); window.removeEventListener("message", this.#onMessage);
} }
attributeChangedCallback(name, oldValue, newValue) {
if (name === "playing") {
const isPlaying = newValue != null;
this.#forwardIsPlaying(isPlaying);
}
}
#connectToChildren() { #connectToChildren() {
const image = this.querySelector("img"); const image = this.querySelector("img");
const iframe = this.querySelector("iframe"); const iframe = this.querySelector("iframe");
@ -46,8 +94,17 @@ class OutfitLayer extends HTMLElement {
return; return;
} }
if (data.type === "status" && ["loaded", "error"].includes(data.status)) { if (data.type === "status") {
this.#setStatus(data.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 { } else {
throw new Error( throw new Error(
`<outfit-layer> got unexpected message: ${JSON.stringify(data)}`, `<outfit-layer> got unexpected message: ${JSON.stringify(data)}`,
@ -56,11 +113,33 @@ class OutfitLayer extends HTMLElement {
} }
#setStatus(newStatus) { #setStatus(newStatus) {
this.#internals.states.clear(); this.#internals.states.delete("loading");
this.#internals.states.delete("loaded");
this.#internals.states.delete("error");
this.#internals.states.add(newStatus); this.#internals.states.add(newStatus);
} }
#setHasAnimations(hasAnimations) {
if (hasAnimations) {
this.#internals.states.add("has-animations");
} else {
this.#internals.states.delete("has-animations");
}
}
#forwardIsPlaying(isPlaying) {
if (this.iframe == null) {
return;
}
this.iframe.contentWindow.postMessage(
{ type: isPlaying ? "play" : "pause" },
"*", // The frame is sandboxed (origin == null), so send to Any origin.
);
}
} }
customElements.define("outfit-viewer", OutfitViewer);
customElements.define("outfit-layer", OutfitLayer); customElements.define("outfit-layer", OutfitLayer);
// Morph turbo-frames on this page, to reuse asset nodes when we want to—very // Morph turbo-frames on this page, to reuse asset nodes when we want to—very

View file

@ -237,9 +237,7 @@ function onAnimationFrame() {
if (msSinceLastLog >= 5000) { if (msSinceLastLog >= 5000) {
const fps = numFramesSinceLastLog / (msSinceLastLog / 1000); const fps = numFramesSinceLastLog / (msSinceLastLog / 1000);
console.debug( console.debug(`${logPrefix} FPS: ${fps.toFixed(2)} (Target: ${targetFps})`);
`${logPrefix} FPS: ${fps.toFixed(2)} (Target: ${targetFps})`,
);
lastLogTime = document.timeline.currentTime; lastLogTime = document.timeline.currentTime;
numFramesSinceLastLog = 0; numFramesSinceLastLog = 0;
} }
@ -276,6 +274,22 @@ function getInitialPlayingStatus() {
} }
} }
/**
* Recursively scans the given MovieClip (or child createjs node), to see if
* there are any animated areas.
*/
function hasAnimations(createjsNode) {
return (
// Some nodes have simple animation frames.
createjsNode.totalFrames > 1 ||
// Tweens are a form of animation that can happen separately from frames.
// They expect timer ticks to happen, and they change the scene accordingly.
createjsNode?.timeline?.tweens?.length >= 1 ||
// And some nodes have _children_ that are animated.
(createjsNode.children || []).some(hasAnimations)
);
}
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
updateCanvasDimensions(); updateCanvasDimensions();
@ -304,7 +318,11 @@ window.addEventListener("message", ({ data }) => {
startMovie() startMovie()
.then(() => { .then(() => {
parent.postMessage( parent.postMessage(
{ type: "status", status: "loaded" }, {
type: "status",
status: "loaded",
hasAnimations: hasAnimations(movieClip),
},
document.location.origin, document.location.origin,
); );
}) })

View file

@ -38,7 +38,7 @@ body.items-show
height: 16px height: 16px
width: 16px width: 16px
.outfit-viewer outfit-viewer
display: block display: block
position: relative position: relative
width: 300px width: 300px
@ -59,7 +59,7 @@ body.items-show
.loading-indicator .loading-indicator
position: absolute position: absolute
z-index: 999 z-index: 1000
bottom: 0px bottom: 0px
right: 4px right: 4px
padding: 8px padding: 8px
@ -68,6 +68,17 @@ body.items-show
opacity: 0 opacity: 0
transition: opacity .5s transition: opacity .5s
.play-pause-button
position: absolute
z-index: 1001
left: 8px
bottom: 8px
display: none
&:has(outfit-layer:state(has-animations))
.play-pause-button
display: block
.error-indicator .error-indicator
font-size: 85% font-size: 85%
color: $error-color color: $error-color
@ -79,14 +90,14 @@ body.items-show
// apply the delay here, because fading *out* on load should be instant.) // apply the delay here, because fading *out* on load should be instant.)
// We are loading when the <turbo-frame> is busy, or when at least one layer // We are loading when the <turbo-frame> is busy, or when at least one layer
// is loading. // is loading.
#item-preview[busy] .outfit-viewer, .outfit-viewer:has(outfit-layer:state(loading)) #item-preview[busy] outfit-viewer, outfit-viewer:has(outfit-layer:state(loading))
cursor: wait cursor: wait
.loading-indicator .loading-indicator
opacity: 1 opacity: 1
transition-delay: 2s transition-delay: 2s
#item-preview:has(outfit-layer:state(error)) #item-preview:has(outfit-layer:state(error))
.outfit-viewer outfit-viewer
border: 2px solid red border: 2px solid red
.error-indicator .error-indicator
display: block display: block

View file

@ -1,5 +1,10 @@
.outfit-viewer %outfit-viewer
.loading-indicator= render partial: "hanger_spinner" .loading-indicator= render partial: "hanger_spinner"
%label.play-pause-button
%input.play-pause-toggle{type: "checkbox"}
Play/pause
- outfit.visible_layers.each do |swf_asset| - outfit.visible_layers.each do |swf_asset|
%outfit-layer{ %outfit-layer{
data: { data: {
@ -8,6 +13,6 @@
}, },
} }
- if swf_asset.canvas_movie? - if swf_asset.canvas_movie?
%iframe{src: swf_asset_path(swf_asset) + "?playing"} %iframe{src: swf_asset_path(swf_asset)}
- else - else
= image_tag swf_asset.image_url, alt: "" = image_tag swf_asset.image_url, alt: ""