@rogieking/figui3 4.9.1 → 4.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig.js CHANGED
@@ -8388,6 +8388,13 @@ class FigMedia extends HTMLElement {
8388
8388
  #boundHandleMediaPlay = this.#handleMediaPlay.bind(this);
8389
8389
  #boundHandleMediaPause = this.#handleMediaPause.bind(this);
8390
8390
  #boundHandleMediaEnded = this.#handleMediaEnded.bind(this);
8391
+ #controlsEl = null;
8392
+ #controlsWiredFor = null;
8393
+ #controlsWiredControls = null;
8394
+ #controlsSync = null;
8395
+ #controlsOnPlay = null;
8396
+ #controlsOnPause = null;
8397
+ #controlsOnSeek = null;
8391
8398
 
8392
8399
  static get observedAttributes() {
8393
8400
  return [
@@ -8486,6 +8493,7 @@ class FigMedia extends HTMLElement {
8486
8493
  disconnectedCallback() {
8487
8494
  this.#fileInput?.removeEventListener("change", this.#boundHandleFileInput);
8488
8495
  this.#removeMediaElementListeners();
8496
+ this.#removeControls();
8489
8497
  if (this.#blobUrl) {
8490
8498
  URL.revokeObjectURL(this.#blobUrl);
8491
8499
  this.#blobUrl = null;
@@ -8586,11 +8594,163 @@ class FigMedia extends HTMLElement {
8586
8594
  } else {
8587
8595
  this.#mediaEl.removeAttribute("poster");
8588
8596
  }
8589
- this.#mediaEl.controls = this.#isEnabledAttr("controls", false);
8597
+ this.#mediaEl.controls = false;
8598
+ this.#mediaEl.removeAttribute("controls");
8590
8599
  this.#mediaEl.autoplay = this.#isEnabledAttr("autoplay", false);
8591
8600
  this.#mediaEl.loop = this.#isEnabledAttr("loop", false);
8592
8601
  this.#mediaEl.muted = this.#isEnabledAttr("muted", false);
8593
8602
  this.#mediaEl.playsInline = true;
8603
+ this.#syncControlsVisibility();
8604
+ }
8605
+
8606
+ get mediaEl() {
8607
+ return this.#mediaEl;
8608
+ }
8609
+
8610
+ #syncControlsVisibility() {
8611
+ if (this.mediaKind !== "video") {
8612
+ this.#removeControls();
8613
+ return;
8614
+ }
8615
+ const userControls = this.querySelector(
8616
+ ":scope > fig-media-controls:not([data-generated])",
8617
+ );
8618
+ if (userControls) {
8619
+ if (this.#controlsEl !== userControls) {
8620
+ this.#removeControls();
8621
+ this.#controlsEl = userControls;
8622
+ }
8623
+ this.#wireControlsToMedia();
8624
+ return;
8625
+ }
8626
+ if (this.#isEnabledAttr("controls", false)) {
8627
+ this.#ensureControls();
8628
+ } else {
8629
+ this.#removeControls();
8630
+ }
8631
+ }
8632
+
8633
+ #ensureControls() {
8634
+ if (this.#controlsEl && this.#controlsEl.isConnected) {
8635
+ this.#wireControlsToMedia();
8636
+ return;
8637
+ }
8638
+ const controls = document.createElement("fig-media-controls");
8639
+ controls.setAttribute("data-generated", "");
8640
+ controls.setAttribute("overlay", "");
8641
+ this.append(controls);
8642
+ this.#controlsEl = controls;
8643
+ this.#wireControlsToMedia();
8644
+ }
8645
+
8646
+ #wireControlsToMedia() {
8647
+ if (!this.#controlsEl || !this.#mediaEl) return;
8648
+ if (
8649
+ this.#controlsWiredFor === this.#mediaEl &&
8650
+ this.#controlsWiredControls === this.#controlsEl
8651
+ ) {
8652
+ return;
8653
+ }
8654
+ this.#unwireControls();
8655
+
8656
+ const controls = this.#controlsEl;
8657
+ const video = this.#mediaEl;
8658
+ this.#controlsWiredFor = video;
8659
+ this.#controlsWiredControls = controls;
8660
+
8661
+ let pendingSeekTime = null;
8662
+ const syncFromVideo = () => {
8663
+ controls.playing = !video.paused && !video.ended;
8664
+ if (Number.isFinite(video.duration)) controls.duration = video.duration;
8665
+ if (pendingSeekTime !== null) {
8666
+ if (Math.abs(video.currentTime - pendingSeekTime) < 0.25) {
8667
+ pendingSeekTime = null;
8668
+ } else {
8669
+ return;
8670
+ }
8671
+ }
8672
+ controls.time = video.currentTime || 0;
8673
+ };
8674
+ const onPlay = () => {
8675
+ const p = video.play?.();
8676
+ if (p && typeof p.catch === "function") p.catch(() => {});
8677
+ };
8678
+ const onPause = () => video.pause?.();
8679
+ const onSeek = (e) => {
8680
+ const next = Number(e?.detail?.time);
8681
+ if (!Number.isFinite(next)) return;
8682
+ pendingSeekTime = next;
8683
+ try { video.currentTime = next; } catch {}
8684
+ };
8685
+
8686
+ this.#controlsSync = syncFromVideo;
8687
+ this.#controlsOnPlay = onPlay;
8688
+ this.#controlsOnPause = onPause;
8689
+ this.#controlsOnSeek = onSeek;
8690
+
8691
+ video.addEventListener("play", syncFromVideo);
8692
+ video.addEventListener("pause", syncFromVideo);
8693
+ video.addEventListener("ended", syncFromVideo);
8694
+ video.addEventListener("timeupdate", syncFromVideo);
8695
+ video.addEventListener("loadedmetadata", syncFromVideo);
8696
+ video.addEventListener("durationchange", syncFromVideo);
8697
+ video.addEventListener("seeked", syncFromVideo);
8698
+ controls.addEventListener("play", onPlay);
8699
+ controls.addEventListener("pause", onPause);
8700
+ controls.addEventListener("seek", onSeek);
8701
+
8702
+ syncFromVideo();
8703
+ }
8704
+
8705
+ #unwireControls() {
8706
+ const video = this.#controlsWiredFor;
8707
+ const controls = this.#controlsWiredControls;
8708
+ if (video && this.#controlsSync) {
8709
+ video.removeEventListener("play", this.#controlsSync);
8710
+ video.removeEventListener("pause", this.#controlsSync);
8711
+ video.removeEventListener("ended", this.#controlsSync);
8712
+ video.removeEventListener("timeupdate", this.#controlsSync);
8713
+ video.removeEventListener("loadedmetadata", this.#controlsSync);
8714
+ video.removeEventListener("durationchange", this.#controlsSync);
8715
+ video.removeEventListener("seeked", this.#controlsSync);
8716
+ }
8717
+ if (controls) {
8718
+ if (this.#controlsOnPlay) controls.removeEventListener("play", this.#controlsOnPlay);
8719
+ if (this.#controlsOnPause) controls.removeEventListener("pause", this.#controlsOnPause);
8720
+ if (this.#controlsOnSeek) controls.removeEventListener("seek", this.#controlsOnSeek);
8721
+ }
8722
+ this.#controlsWiredFor = null;
8723
+ this.#controlsWiredControls = null;
8724
+ this.#controlsSync = null;
8725
+ this.#controlsOnPlay = null;
8726
+ this.#controlsOnPause = null;
8727
+ this.#controlsOnSeek = null;
8728
+ }
8729
+
8730
+ #removeControls() {
8731
+ this.#unwireControls();
8732
+ if (!this.#controlsEl) return;
8733
+ if (this.#controlsEl.hasAttribute("data-generated")) {
8734
+ this.#controlsEl.remove();
8735
+ }
8736
+ this.#controlsEl = null;
8737
+ }
8738
+
8739
+ toggle() {
8740
+ if (!this.#mediaEl || this.mediaKind !== "video") return;
8741
+ if (this.#mediaEl.paused || this.#mediaEl.ended) this.play();
8742
+ else this.pause();
8743
+ }
8744
+
8745
+ play() {
8746
+ if (this.mediaKind !== "video" || !this.#mediaEl) return;
8747
+ const p = this.#mediaEl.play();
8748
+ if (p && typeof p.catch === "function") p.catch(() => {});
8749
+ }
8750
+
8751
+ pause() {
8752
+ if (this.mediaKind !== "video" || !this.#mediaEl) return;
8753
+ this.#mediaEl.pause();
8594
8754
  }
8595
8755
 
8596
8756
  #createFileInput() {
@@ -8775,6 +8935,203 @@ class FigVideo extends FigMedia {
8775
8935
  }
8776
8936
  customElements.define("fig-video", FigVideo);
8777
8937
 
8938
+ /**
8939
+ * <fig-media-controls> — Standalone playback controls UI.
8940
+ *
8941
+ * Renders a play/pause button, a scrubber slider, and a MM:SS time display.
8942
+ * Holds its own state via attributes — no media element required.
8943
+ *
8944
+ * Attributes:
8945
+ * - `playing` (boolean presence) — current play/pause state
8946
+ * - `duration` (number, seconds) — total track length
8947
+ * - `time` (number, seconds) — current playhead position
8948
+ *
8949
+ * Events:
8950
+ * - `play` — emitted when the user toggles playback on (detail: { playing: true })
8951
+ * - `pause` — emitted when the user toggles playback off (detail: { playing: false })
8952
+ * - `seek` — emitted when the user drags the scrubber (detail: { time })
8953
+ *
8954
+ * Properties: `playing`, `duration`, `time` mirror the attributes.
8955
+ */
8956
+ class FigMediaControls extends HTMLElement {
8957
+ #playBtn = null;
8958
+ #playTooltip = null;
8959
+ #timeSlider = null;
8960
+ #timeEl = null;
8961
+ #userSeeking = false;
8962
+ #rendered = false;
8963
+
8964
+ static get observedAttributes() {
8965
+ return ["playing", "duration", "time"];
8966
+ }
8967
+
8968
+ connectedCallback() {
8969
+ this.#render();
8970
+ this.#syncPlayingUi();
8971
+ this.#syncTimeUi();
8972
+ }
8973
+
8974
+ get playing() {
8975
+ return this.hasAttribute("playing") && this.getAttribute("playing") !== "false";
8976
+ }
8977
+ set playing(value) {
8978
+ if (value) this.setAttribute("playing", "");
8979
+ else this.removeAttribute("playing");
8980
+ }
8981
+
8982
+ get duration() {
8983
+ const n = Number(this.getAttribute("duration"));
8984
+ return Number.isFinite(n) && n >= 0 ? n : 0;
8985
+ }
8986
+ set duration(value) {
8987
+ const n = Number(value);
8988
+ if (!Number.isFinite(n) || n < 0) {
8989
+ this.removeAttribute("duration");
8990
+ return;
8991
+ }
8992
+ this.setAttribute("duration", String(n));
8993
+ }
8994
+
8995
+ get time() {
8996
+ const n = Number(this.getAttribute("time"));
8997
+ return Number.isFinite(n) && n >= 0 ? n : 0;
8998
+ }
8999
+ set time(value) {
9000
+ const n = Number(value);
9001
+ if (!Number.isFinite(n) || n < 0) {
9002
+ this.removeAttribute("time");
9003
+ return;
9004
+ }
9005
+ this.setAttribute("time", String(n));
9006
+ }
9007
+
9008
+ attributeChangedCallback(name) {
9009
+ if (!this.#rendered) return;
9010
+ if (name === "playing") this.#syncPlayingUi();
9011
+ if (name === "duration" || name === "time") this.#syncTimeUi();
9012
+ }
9013
+
9014
+ #render() {
9015
+ if (this.#rendered) return;
9016
+ this.#rendered = true;
9017
+
9018
+ const tooltip = document.createElement("fig-tooltip");
9019
+ tooltip.setAttribute("text", "Play");
9020
+ const btn = document.createElement("fig-button");
9021
+ btn.setAttribute("variant", "ghost");
9022
+ btn.setAttribute("size", "small");
9023
+ btn.setAttribute("icon", "true");
9024
+ btn.setAttribute("aria-label", "Play");
9025
+ const icon = document.createElement("span");
9026
+ icon.className = "fig-mask-icon fig-media-controls-play-icon";
9027
+ icon.style.setProperty("--icon", "var(--icon-play)");
9028
+ icon.style.setProperty("--size", "1.5rem");
9029
+ btn.append(icon);
9030
+ tooltip.append(btn);
9031
+ btn.addEventListener("click", (e) => {
9032
+ e.preventDefault();
9033
+ e.stopPropagation();
9034
+ this.toggle();
9035
+ });
9036
+
9037
+ const slider = document.createElement("fig-slider");
9038
+ slider.setAttribute("variant", "neue");
9039
+ slider.setAttribute("min", "0");
9040
+ slider.setAttribute("max", String(this.duration));
9041
+ slider.setAttribute("step", "0.1");
9042
+ slider.setAttribute("value", String(this.time));
9043
+ slider.setAttribute("full", "");
9044
+ const timeEl = document.createElement("label");
9045
+ timeEl.className = "fig-media-controls-time";
9046
+ timeEl.textContent = this.#formatTime(this.time);
9047
+
9048
+ const handleSeek = (e) => {
9049
+ const host = e.currentTarget;
9050
+ const next = Number(host?.value);
9051
+ if (!Number.isFinite(next)) return;
9052
+ this.#userSeeking = true;
9053
+ this.setAttribute("time", String(next));
9054
+ this.dispatchEvent(
9055
+ new CustomEvent("seek", {
9056
+ bubbles: true,
9057
+ composed: true,
9058
+ detail: { time: next },
9059
+ }),
9060
+ );
9061
+ requestAnimationFrame(() => {
9062
+ this.#userSeeking = false;
9063
+ });
9064
+ };
9065
+ slider.addEventListener("input", handleSeek);
9066
+ slider.addEventListener("change", handleSeek);
9067
+
9068
+ this.append(tooltip, slider, timeEl);
9069
+
9070
+ this.#playBtn = btn;
9071
+ this.#playTooltip = tooltip;
9072
+ this.#timeSlider = slider;
9073
+ this.#timeEl = timeEl;
9074
+ }
9075
+
9076
+ #formatTime(seconds) {
9077
+ if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
9078
+ const total = Math.floor(seconds);
9079
+ const m = Math.floor(total / 60);
9080
+ const s = total % 60;
9081
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
9082
+ }
9083
+
9084
+ #syncPlayingUi() {
9085
+ if (!this.#playBtn) return;
9086
+ const playing = this.playing;
9087
+ this.#playBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
9088
+ this.#playTooltip?.setAttribute("text", playing ? "Pause" : "Play");
9089
+ const icon = this.#playBtn.querySelector(".fig-media-controls-play-icon");
9090
+ if (icon) {
9091
+ icon.style.setProperty(
9092
+ "--icon",
9093
+ playing ? "var(--icon-pause)" : "var(--icon-play)",
9094
+ );
9095
+ }
9096
+ }
9097
+
9098
+ #syncTimeUi() {
9099
+ if (!this.#timeSlider) return;
9100
+ const duration = this.duration;
9101
+ if (Number(this.#timeSlider.getAttribute("max")) !== duration) {
9102
+ this.#timeSlider.setAttribute("max", String(duration));
9103
+ }
9104
+ const t = this.time;
9105
+ if (!this.#userSeeking) {
9106
+ this.#timeSlider.setAttribute("value", String(t));
9107
+ }
9108
+ if (this.#timeEl) this.#timeEl.textContent = this.#formatTime(t);
9109
+ }
9110
+
9111
+ toggle() {
9112
+ const next = !this.playing;
9113
+ this.playing = next;
9114
+ this.dispatchEvent(
9115
+ new CustomEvent(next ? "play" : "pause", {
9116
+ bubbles: true,
9117
+ composed: true,
9118
+ detail: { playing: next },
9119
+ }),
9120
+ );
9121
+ }
9122
+
9123
+ play() {
9124
+ if (this.playing) return;
9125
+ this.toggle();
9126
+ }
9127
+
9128
+ pause() {
9129
+ if (!this.playing) return;
9130
+ this.toggle();
9131
+ }
9132
+ }
9133
+ customElements.define("fig-media-controls", FigMediaControls);
9134
+
8778
9135
  /* File Upload Input */
8779
9136
  class FigInputFile extends HTMLElement {
8780
9137
  static observedAttributes = ["accepts", "label", "disabled", "multiple", "variant", "url"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "4.9.1",
3
+ "version": "4.10.1",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "MIT",