diff --git a/app/assets/javascripts/outfit-viewer.js b/app/assets/javascripts/outfit-viewer.js index f248d7e7..0be7557e 100644 --- a/app/assets/javascripts/outfit-viewer.js +++ b/app/assets/javascripts/outfit-viewer.js @@ -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 { + static observedAttributes = ["playing"]; #internals; constructor() { @@ -23,6 +64,13 @@ class OutfitLayer extends HTMLElement { window.removeEventListener("message", this.#onMessage); } + attributeChangedCallback(name, oldValue, newValue) { + if (name === "playing") { + const isPlaying = newValue != null; + this.#forwardIsPlaying(isPlaying); + } + } + #connectToChildren() { const image = this.querySelector("img"); const iframe = this.querySelector("iframe"); @@ -46,8 +94,17 @@ class OutfitLayer extends HTMLElement { return; } - if (data.type === "status" && ["loaded", "error"].includes(data.status)) { - this.#setStatus(data.status); + 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( + ` got unexpected status: ${JSON.stringify(data.status)}`, + ); + } } else { throw new Error( ` got unexpected message: ${JSON.stringify(data)}`, @@ -56,11 +113,33 @@ class OutfitLayer extends HTMLElement { } #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); } + + #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); // Morph turbo-frames on this page, to reuse asset nodes when we want to—very diff --git a/app/assets/javascripts/swf_assets/show.js b/app/assets/javascripts/swf_assets/show.js index 2e372885..e406bda2 100644 --- a/app/assets/javascripts/swf_assets/show.js +++ b/app/assets/javascripts/swf_assets/show.js @@ -237,9 +237,7 @@ function onAnimationFrame() { if (msSinceLastLog >= 5000) { const fps = numFramesSinceLastLog / (msSinceLastLog / 1000); - console.debug( - `${logPrefix} FPS: ${fps.toFixed(2)} (Target: ${targetFps})`, - ); + console.debug(`${logPrefix} FPS: ${fps.toFixed(2)} (Target: ${targetFps})`); lastLogTime = document.timeline.currentTime; 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", () => { updateCanvasDimensions(); @@ -304,7 +318,11 @@ window.addEventListener("message", ({ data }) => { startMovie() .then(() => { parent.postMessage( - { type: "status", status: "loaded" }, + { + type: "status", + status: "loaded", + hasAnimations: hasAnimations(movieClip), + }, document.location.origin, ); }) diff --git a/app/assets/stylesheets/items/_show.sass b/app/assets/stylesheets/items/_show.sass index 7b9be0ee..d672515f 100644 --- a/app/assets/stylesheets/items/_show.sass +++ b/app/assets/stylesheets/items/_show.sass @@ -38,7 +38,7 @@ body.items-show height: 16px width: 16px - .outfit-viewer + outfit-viewer display: block position: relative width: 300px @@ -59,7 +59,7 @@ body.items-show .loading-indicator position: absolute - z-index: 999 + z-index: 1000 bottom: 0px right: 4px padding: 8px @@ -68,6 +68,17 @@ body.items-show opacity: 0 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 font-size: 85% color: $error-color @@ -79,14 +90,14 @@ body.items-show // apply the delay here, because fading *out* on load should be instant.) // We are loading when the is busy, or when at least one layer // 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 .loading-indicator opacity: 1 transition-delay: 2s #item-preview:has(outfit-layer:state(error)) - .outfit-viewer + outfit-viewer border: 2px solid red .error-indicator display: block diff --git a/app/views/items/_outfit_viewer.html.haml b/app/views/items/_outfit_viewer.html.haml index 8a2fdab9..1596c913 100644 --- a/app/views/items/_outfit_viewer.html.haml +++ b/app/views/items/_outfit_viewer.html.haml @@ -1,5 +1,10 @@ -.outfit-viewer +%outfit-viewer .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-layer{ data: { @@ -8,6 +13,6 @@ }, } - if swf_asset.canvas_movie? - %iframe{src: swf_asset_path(swf_asset) + "?playing"} + %iframe{src: swf_asset_path(swf_asset)} - else = image_tag swf_asset.image_url, alt: "" \ No newline at end of file