diff --git a/app/assets/javascripts/outfit-viewer.js b/app/assets/javascripts/outfit-viewer.js index b281ce78..d173cf78 100644 --- a/app/assets/javascripts/outfit-viewer.js +++ b/app/assets/javascripts/outfit-viewer.js @@ -1,5 +1,6 @@ class OutfitViewer extends HTMLElement { #internals; + #isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround) constructor() { super(); @@ -7,26 +8,106 @@ class OutfitViewer extends HTMLElement { } connectedCallback() { + // Set up listener for bubbled hasanimationschange events from layers + this.addEventListener("hasanimationschange", (e) => { + // Only handle events from outfit-layer children, not from ourselves + if (e.target === this) return; + + this.#updateHasAnimations(); + }); + + // Watch for new layers being added and apply the current playing state + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.tagName === "OUTFIT-LAYER") { + // Apply current playing state to the new layer + if (this.#internals.states.has("playing")) { + node.play(); + } else { + node.pause(); + } + } + } + } + }); + observer.observe(this, { childList: true }); + // The `` is connected to the DOM right before its // children are. So, to engage with the children, wait a tick! setTimeout(() => this.#connectToChildren(), 0); } #connectToChildren() { - const playPauseToggle = document.querySelector(".play-pause-toggle"); + // Read initial playing state from cookie and initialize + const isPlayingFromCookie = this.#getIsPlayingCookie(); - // Read our initial playing state from the toggle, and subscribe to changes. - this.#setIsPlaying(playPauseToggle.checked); - playPauseToggle.addEventListener("change", () => { - this.#setIsPlaying(playPauseToggle.checked); - this.#setIsPlayingCookie(playPauseToggle.checked); - }); + // 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 + 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 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) { - // TODO: Listen for changes to the child list, and add `playing` when new - // nodes arrive, if playing. - const thirtyDays = 60 * 60 * 24 * 30; + // 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")) { @@ -38,6 +119,27 @@ class OutfitViewer extends HTMLElement { 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] === "true"; + } + return true; // Default to playing } #setIsPlayingCookie(isPlaying) { @@ -49,6 +151,7 @@ class OutfitViewer extends HTMLElement { class OutfitLayer extends HTMLElement { #internals; + #pendingMessage = null; // Queue one message to send when iframe loads constructor() { super(); @@ -105,6 +208,12 @@ class OutfitLayer extends HTMLElement { 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(` contained no image or iframe: `, this); } @@ -151,24 +260,45 @@ class OutfitLayer extends HTMLElement { * 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 or it hasn't loaded, ignore this 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( - `Ignoring message, frame not loaded yet: `, + `Queueing message, frame not loaded yet: `, this.iframe, message, ); + if (message.type === "play" || message.type === "pause") { + this.#pendingMessage = message; + } return; } @@ -177,8 +307,112 @@ class OutfitLayer extends HTMLElement { } } +/** + * OutfitViewerPlayPauseToggle is a web component that creates a play/pause + * toggle button for an outfit-viewer, referenced by the `for` attribute. + * + * Usage: + * ... + * + * + * Playing + * Paused + * + * + * 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( + " requires a 'for' attribute", + ); + return; + } + + this.#outfitViewer = document.getElementById(forId); + if (!this.#outfitViewer) { + console.warn( + ` 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( + " 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 diff --git a/app/assets/stylesheets/application/outfit-viewer.sass b/app/assets/stylesheets/application/outfit-viewer.sass index 41a19457..72d468e8 100644 --- a/app/assets/stylesheets/application/outfit-viewer.sass +++ b/app/assets/stylesheets/application/outfit-viewer.sass @@ -104,7 +104,12 @@ outfit-viewer &:has(.play-pause-toggle:active) transform: translateY(2px) - &:has(outfit-layer:state(has-animations)) + // Hide the play-pause toggle when there are no animations + outfit-viewer-play-pause-toggle[hidden] + display: none + + // Show the play-pause button when visible + outfit-viewer-play-pause-toggle:not([hidden]) .play-pause-button display: flex diff --git a/app/assets/stylesheets/items/show.sass b/app/assets/stylesheets/items/show.sass index 5aa5fc60..968c19e8 100644 --- a/app/assets/stylesheets/items/show.sass +++ b/app/assets/stylesheets/items/show.sass @@ -45,6 +45,8 @@ .preview-area margin: 0 auto position: relative + width: 300px + height: 300px .customize-more position: absolute @@ -287,6 +289,9 @@ species-face-picker .preview-area grid-area: viewer + width: 380px + height: 380px + outfit-viewer width: 380px height: 380px @@ -302,6 +307,52 @@ species-face-picker .item-preview-meta-info grid-area: meta +.play-pause-button + position: absolute + z-index: 1001 + left: 8px + bottom: 8px + display: flex + align-items: center + justify-content: center + color: white + background: rgba(0, 0, 0, 0.64) + width: 2.5em + height: 2.5em + border-radius: 100% + border: 2px solid transparent + transition: all .25s + cursor: pointer + + .playing-label, .paused-label + display: none + width: 1em + height: 1em + + input[type=checkbox] + // Visually hidden + clip: rect(0 0 0 0) + clip-path: inset(50%) + height: 1px + overflow: hidden + position: absolute + white-space: nowrap + width: 1px + + &:checked ~ .playing-label + display: block + + &:not(:checked) ~ .paused-label + display: block + + &:hover, &:has(.play-pause-toggle:focus) + border: 2px solid $module-border-color + background: $module-bg-color + color: $text-color + + &:has(.play-pause-toggle:active) + transform: translateY(2px) + @keyframes fade-in from opacity: 0 diff --git a/app/assets/stylesheets/wardrobe/show.css b/app/assets/stylesheets/wardrobe/show.css index b6b89e87..ffa4ea12 100644 --- a/app/assets/stylesheets/wardrobe/show.css +++ b/app/assets/stylesheets/wardrobe/show.css @@ -3,7 +3,8 @@ /* Base button defaults - applied to all interactive controls */ button, input[type="submit"], -select { +select, +.button { padding: 0.5rem 0.75rem; font-size: 0.95rem; border-radius: 0.375rem; @@ -29,7 +30,8 @@ select { .outfit-preview-section { button, select, - input[type="submit"] { + input[type="submit"], + .button { background: rgba(0, 0, 0, 0.7); border: 1px solid rgba(255, 255, 255, 0.3); color: white; @@ -217,16 +219,14 @@ body.wardrobe-v2 { font-size: 1.2rem; } - /* Preview controls container - groups species/color picker and pose picker */ + /* Preview controls container - groups all floating controls */ .preview-controls { position: absolute; - bottom: 0; - left: 0; - right: 0; + inset: 0; display: flex; - align-items: stretch; - justify-content: center; - gap: 0.5rem; + flex-direction: column; + align-items: center; + justify-content: space-between; padding: 1.5rem; pointer-events: none; @@ -239,6 +239,59 @@ body.wardrobe-v2 { } } + /* Top controls (play/pause) */ + .preview-controls-top { + display: flex; + justify-content: center; + width: 100%; + } + + /* Bottom controls (species/color picker, pose picker) */ + .preview-controls-bottom { + display: flex; + align-items: stretch; + justify-content: center; + gap: 0.5rem; + width: 100%; + } + + /* Play/Pause control button */ + .play-pause-control-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.95rem; + cursor: pointer; + + /* Hide the checkbox visually */ + input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + } + + /* Show/hide labels based on checkbox state */ + .playing-label, + .paused-label { + display: none; + } + + input[type="checkbox"]:checked ~ .playing-label { + display: block; + } + + input[type="checkbox"]:not(:checked) ~ .paused-label { + display: block; + } + } + + /* Hide the toggle when there are no animations */ + outfit-viewer-play-pause-toggle[hidden] { + display: none; + } + /* Pose picker button */ .pose-picker-button { anchor-name: --pose-picker-anchor; diff --git a/app/views/application/_outfit_viewer.html.haml b/app/views/application/_outfit_viewer.html.haml index 52f2e386..2463480c 100644 --- a/app/views/application/_outfit_viewer.html.haml +++ b/app/views/application/_outfit_viewer.html.haml @@ -1,17 +1,8 @@ - html_options = {} unless defined? html_options +- html_options[:id] ||= "outfit-viewer-#{SecureRandom.hex(8)}" = content_tag "outfit-viewer", **html_options do .loading-indicator= render partial: "hanger_spinner" - %label.play-pause-button{title: "Pause/play animations"} - %input.play-pause-toggle{ - type: "checkbox", - checked: outfit_viewer_is_playing, - } - %svg.playing-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Pause"} - %path{fill: "currentColor", d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z"} - %svg.paused-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Play"} - %path{fill: "currentColor", d: "M8 5v14l11-7z"} - - outfit.visible_layers.each do |swf_asset| %outfit-layer{ data: { diff --git a/app/views/items/show.html.haml b/app/views/items/show.html.haml index 22ea1f39..a9db4e3c 100644 --- a/app/views/items/show.html.haml +++ b/app/views/items/show.html.haml @@ -16,7 +16,14 @@ = turbo_frame_tag "item-preview" do .preview-area - = outfit_viewer @preview_outfit + = outfit_viewer @preview_outfit, id: "item-preview-outfit-viewer" + %outfit-viewer-play-pause-toggle{for: "item-preview-outfit-viewer"} + %label.play-pause-button{title: "Pause/play animations"} + %input{type: "checkbox"} + %svg.playing-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Pause"} + %path{fill: "currentColor", d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z"} + %svg.paused-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Play"} + %path{fill: "currentColor", d: "M8 5v14l11-7z"} .error-indicator 💥 We couldn't load all of this outfit. Try again? = link_to wardrobe_path(params: @preview_outfit.wardrobe_params), diff --git a/app/views/wardrobe/show.html.haml b/app/views/wardrobe/show.html.haml index bcfb2795..ef9c8dbf 100644 --- a/app/views/wardrobe/show.html.haml +++ b/app/views/wardrobe/show.html.haml @@ -27,13 +27,21 @@ We haven't seen this kind of pet before! Try a different species/color combination. - else - = outfit_viewer @outfit + = outfit_viewer @outfit, id: "wardrobe-outfit-viewer" .preview-controls - = render "species_color_picker" + .preview-controls-top + %outfit-viewer-play-pause-toggle{for: "wardrobe-outfit-viewer"} + %label.play-pause-control-button.button + %input{type: "checkbox"} + %span.paused-label Paused + %span.playing-label Playing - - if @pet_type - = render "pose_picker" + .preview-controls-bottom + = render "species_color_picker" + + - if @pet_type + = render "pose_picker" .outfit-controls-section .item-search-form diff --git a/docs/wardrobe-v2-migration.md b/docs/wardrobe-v2-migration.md index 32c46668..a94b015e 100644 --- a/docs/wardrobe-v2-migration.md +++ b/docs/wardrobe-v2-migration.md @@ -183,10 +183,10 @@ Below is a comprehensive comparison with the full feature set of Wardrobe 2020 ( **Preview Controls** - ✅ Has: Basic outfit viewer rendering -- ❌ No overlay controls +- ✅ Has: Play/Pause animation button (with cookie persistence) +- ❌ No overlay controls beyond play/pause - ❌ Missing from Wardrobe 2020: - Back button (to homepage/Your Outfits) - - Play/Pause animation button (with localStorage persistence) - Download outfit as PNG (with pre-generation on hover) - Copy link to clipboard (with "Copied!" confirmation) - Settings popover: @@ -493,7 +493,7 @@ Browser displays (instant if Turbo, full page otherwise) 6. **Preview Controls** - [ ] Overlay controls (auto-hide on desktop, always visible on touch) - - [ ] Play/Pause animation button + - [x] Play/Pause animation button - [ ] Download outfit as PNG - [ ] Copy link to clipboard (with confirmation) - [ ] Settings dropdown (hi-res mode, use archive)