[WV2] Custom play/pause animations button

This commit is contained in:
Emi Matchu 2026-01-03 10:44:10 -08:00
parent cbf69e1189
commit f545510edc
8 changed files with 389 additions and 40 deletions

View file

@ -1,5 +1,6 @@
class OutfitViewer extends HTMLElement { class OutfitViewer extends HTMLElement {
#internals; #internals;
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
constructor() { constructor() {
super(); super();
@ -7,26 +8,106 @@ class OutfitViewer extends HTMLElement {
} }
connectedCallback() { 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 `<outfit-layer>` is connected to the DOM right before its // The `<outfit-layer>` is connected to the DOM right before its
// children are. So, to engage with the children, wait a tick! // children are. So, to engage with the children, wait a tick!
setTimeout(() => this.#connectToChildren(), 0); setTimeout(() => this.#connectToChildren(), 0);
} }
#connectToChildren() { #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. // Initialize the boolean before calling #setIsPlaying
this.#setIsPlaying(playPauseToggle.checked); // (We set it to the opposite first so #setIsPlaying detects a change)
playPauseToggle.addEventListener("change", () => { this.#isPlaying = !isPlayingFromCookie;
this.#setIsPlaying(playPauseToggle.checked); this.#setIsPlaying(isPlayingFromCookie);
this.#setIsPlayingCookie(playPauseToggle.checked);
}); // 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) { #setIsPlaying(isPlaying) {
// TODO: Listen for changes to the child list, and add `playing` when new // Skip if already in this state
// nodes arrive, if playing. if (this.#isPlaying === isPlaying) {
const thirtyDays = 60 * 60 * 24 * 30; return;
}
// Update internal state (both boolean and CustomStateSet for CSS)
this.#isPlaying = isPlaying;
if (isPlaying) { if (isPlaying) {
this.#internals.states.add("playing"); this.#internals.states.add("playing");
for (const layer of this.querySelectorAll("outfit-layer")) { for (const layer of this.querySelectorAll("outfit-layer")) {
@ -38,6 +119,27 @@ class OutfitViewer extends HTMLElement {
layer.pause(); 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) { #setIsPlayingCookie(isPlaying) {
@ -49,6 +151,7 @@ class OutfitViewer extends HTMLElement {
class OutfitLayer extends HTMLElement { class OutfitLayer extends HTMLElement {
#internals; #internals;
#pendingMessage = null; // Queue one message to send when iframe loads
constructor() { constructor() {
super(); super();
@ -105,6 +208,12 @@ class OutfitLayer extends HTMLElement {
this.#sendMessageToIframe({ type: "requestStatus" }); this.#sendMessageToIframe({ type: "requestStatus" });
window.addEventListener("message", (m) => this.#onMessage(m)); window.addEventListener("message", (m) => this.#onMessage(m));
this.iframe.addEventListener("error", () => this.#setStatus("error")); 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 { } else {
console.warn(`<outfit-layer> contained no image or iframe: `, this); console.warn(`<outfit-layer> contained no image or iframe: `, this);
} }
@ -151,24 +260,45 @@ class OutfitLayer extends HTMLElement {
* Set whether CSS selector `:state(has-animations)` matches this element. * Set whether CSS selector `:state(has-animations)` matches this element.
*/ */
#setHasAnimations(hasAnimations) { #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) { if (hasAnimations) {
this.#internals.states.add("has-animations"); this.#internals.states.add("has-animations");
} else { } else {
this.#internals.states.delete("has-animations"); this.#internals.states.delete("has-animations");
} }
// Dispatch event so parent OutfitViewer can react
this.dispatchEvent(
new CustomEvent("hasanimationschange", {
detail: { hasAnimations },
bubbles: true,
}),
);
} }
#sendMessageToIframe(message) { #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 (this.iframe == null) {
if (message.type === "play" || message.type === "pause") {
this.#pendingMessage = message;
}
return; return;
} }
if (this.iframe.contentWindow == null) { if (this.iframe.contentWindow == null) {
console.debug( console.debug(
`Ignoring message, frame not loaded yet: `, `Queueing message, frame not loaded yet: `,
this.iframe, this.iframe,
message, message,
); );
if (message.type === "play" || message.type === "pause") {
this.#pendingMessage = message;
}
return; 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:
* <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-viewer", OutfitViewer);
customElements.define("outfit-layer", OutfitLayer); 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 // 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 // important for movies!—but ensure that it *doesn't* do its usual behavior of

View file

@ -104,7 +104,12 @@ outfit-viewer
&:has(.play-pause-toggle:active) &:has(.play-pause-toggle:active)
transform: translateY(2px) 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 .play-pause-button
display: flex display: flex

View file

@ -45,6 +45,8 @@
.preview-area .preview-area
margin: 0 auto margin: 0 auto
position: relative position: relative
width: 300px
height: 300px
.customize-more .customize-more
position: absolute position: absolute
@ -287,6 +289,9 @@ species-face-picker
.preview-area .preview-area
grid-area: viewer grid-area: viewer
width: 380px
height: 380px
outfit-viewer outfit-viewer
width: 380px width: 380px
height: 380px height: 380px
@ -302,6 +307,52 @@ species-face-picker
.item-preview-meta-info .item-preview-meta-info
grid-area: meta 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 @keyframes fade-in
from from
opacity: 0 opacity: 0

View file

@ -3,7 +3,8 @@
/* Base button defaults - applied to all interactive controls */ /* Base button defaults - applied to all interactive controls */
button, button,
input[type="submit"], input[type="submit"],
select { select,
.button {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
font-size: 0.95rem; font-size: 0.95rem;
border-radius: 0.375rem; border-radius: 0.375rem;
@ -29,7 +30,8 @@ select {
.outfit-preview-section { .outfit-preview-section {
button, button,
select, select,
input[type="submit"] { input[type="submit"],
.button {
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.7);
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.3);
color: white; color: white;
@ -217,16 +219,14 @@ body.wardrobe-v2 {
font-size: 1.2rem; font-size: 1.2rem;
} }
/* Preview controls container - groups species/color picker and pose picker */ /* Preview controls container - groups all floating controls */
.preview-controls { .preview-controls {
position: absolute; position: absolute;
bottom: 0; inset: 0;
left: 0;
right: 0;
display: flex; display: flex;
align-items: stretch; flex-direction: column;
justify-content: center; align-items: center;
gap: 0.5rem; justify-content: space-between;
padding: 1.5rem; padding: 1.5rem;
pointer-events: none; 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 */
.pose-picker-button { .pose-picker-button {
anchor-name: --pose-picker-anchor; anchor-name: --pose-picker-anchor;

View file

@ -1,17 +1,8 @@
- html_options = {} unless defined? html_options - html_options = {} unless defined? html_options
- html_options[:id] ||= "outfit-viewer-#{SecureRandom.hex(8)}"
= content_tag "outfit-viewer", **html_options do = content_tag "outfit-viewer", **html_options do
.loading-indicator= render partial: "hanger_spinner" .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.visible_layers.each do |swf_asset|
%outfit-layer{ %outfit-layer{
data: { data: {

View file

@ -16,7 +16,14 @@
= turbo_frame_tag "item-preview" do = turbo_frame_tag "item-preview" do
.preview-area .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 .error-indicator
💥 We couldn't load all of this outfit. Try again? 💥 We couldn't load all of this outfit. Try again?
= link_to wardrobe_path(params: @preview_outfit.wardrobe_params), = link_to wardrobe_path(params: @preview_outfit.wardrobe_params),

View file

@ -27,9 +27,17 @@
We haven't seen this kind of pet before! Try a different species/color We haven't seen this kind of pet before! Try a different species/color
combination. combination.
- else - else
= outfit_viewer @outfit = outfit_viewer @outfit, id: "wardrobe-outfit-viewer"
.preview-controls .preview-controls
.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
.preview-controls-bottom
= render "species_color_picker" = render "species_color_picker"
- if @pet_type - if @pet_type

View file

@ -183,10 +183,10 @@ Below is a comprehensive comparison with the full feature set of Wardrobe 2020 (
**Preview Controls** **Preview Controls**
- ✅ Has: Basic outfit viewer rendering - ✅ 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: - ❌ Missing from Wardrobe 2020:
- Back button (to homepage/Your Outfits) - Back button (to homepage/Your Outfits)
- Play/Pause animation button (with localStorage persistence)
- Download outfit as PNG (with pre-generation on hover) - Download outfit as PNG (with pre-generation on hover)
- Copy link to clipboard (with "Copied!" confirmation) - Copy link to clipboard (with "Copied!" confirmation)
- Settings popover: - Settings popover:
@ -493,7 +493,7 @@ Browser displays (instant if Turbo, full page otherwise)
6. **Preview Controls** 6. **Preview Controls**
- [ ] Overlay controls (auto-hide on desktop, always visible on touch) - [ ] Overlay controls (auto-hide on desktop, always visible on touch)
- [ ] Play/Pause animation button - [x] Play/Pause animation button
- [ ] Download outfit as PNG - [ ] Download outfit as PNG
- [ ] Copy link to clipboard (with confirmation) - [ ] Copy link to clipboard (with confirmation)
- [ ] Settings dropdown (hi-res mode, use archive) - [ ] Settings dropdown (hi-res mode, use archive)