mikuru 1.0.29 → 1.0.30

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.30 - 2026-05-15
4
+
5
+ - Hardened `MikuruVideoPlayer` controls so stop, mute, playback speed, seeking, and modal close operations do not trigger recursive updates while browser media events are firing.
6
+ - Kept media button UI stable by avoiding reactive branch swaps for player control icons and deferring non-play media operations out of the click event stack.
7
+
3
8
  ## 1.0.29 - 2026-05-15
4
9
 
5
10
  - Fixed `MikuruVideoPlayer` and `MikuruAudioPlayer` unmount handling so media events fired while a modal or conditional branch is being removed no longer update component refs recursively.
@@ -31,13 +31,11 @@
31
31
  <div class="controls">
32
32
  <button type="button" @click="skipBackward">-10</button>
33
33
  <button class="primary" type="button" @click="togglePlayback" :aria-label="playLabel">
34
- <span m-if="isPlaying">Pause</span>
35
- <span m-else>Play</span>
34
+ <span>{{ playText }}</span>
36
35
  </button>
37
36
  <button type="button" @click="skipForward">+10</button>
38
37
  <button type="button" @click="toggleMute" :aria-label="muteLabel">
39
- <span m-if="muted">Muted</span>
40
- <span m-else>Sound</span>
38
+ <span>Sound</span>
41
39
  </button>
42
40
  <label class="volume">
43
41
  <span>Volume</span>
@@ -72,11 +70,13 @@ const isPlaying = ref(false);
72
70
  const safeDuration = computed(() => duration.value > 0 ? duration.value : 0);
73
71
  const playLabel = computed(() => isPlaying.value ? "Pause audio" : "Play audio");
74
72
  const muteLabel = computed(() => muted.value ? "Unmute audio" : "Mute audio");
73
+ const playText = computed(() => isPlaying.value ? "Pause" : "Play");
75
74
  const initials = computed(() => {
76
75
  const words = title.value.split(" ").filter(Boolean);
77
76
  return words.slice(0, 2).map((word) => word[0]).join("").toUpperCase() || "M";
78
77
  });
79
78
  let isDisposed = false;
79
+ let ignoreMediaEventsUntil = 0;
80
80
 
81
81
  onMounted(() => {
82
82
  applyAudioSettings();
@@ -91,14 +91,20 @@ function getMedia() {
91
91
  return mediaEl.value;
92
92
  }
93
93
 
94
+ function ignoreMediaEventsFor(ms) {
95
+ ignoreMediaEventsUntil = performance.now() + ms;
96
+ }
97
+
98
+ function shouldIgnoreMediaEvent() {
99
+ return performance.now() < ignoreMediaEventsUntil;
100
+ }
101
+
94
102
  function syncMedia() {
95
- if (isDisposed) return;
103
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
96
104
  const media = getMedia();
97
105
  if (!media) return;
98
106
  currentTime.value = media.currentTime || 0;
99
107
  duration.value = Number.isFinite(media.duration) ? media.duration : 0;
100
- volume.value = media.volume;
101
- muted.value = media.muted;
102
108
  }
103
109
 
104
110
  function applyAudioSettings() {
@@ -110,12 +116,12 @@ function applyAudioSettings() {
110
116
  }
111
117
 
112
118
  function markPlaying() {
113
- if (isDisposed) return;
119
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
114
120
  isPlaying.value = true;
115
121
  }
116
122
 
117
123
  function markPaused() {
118
- if (isDisposed) return;
124
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
119
125
  isPlaying.value = false;
120
126
  }
121
127
 
@@ -126,7 +132,7 @@ async function togglePlayback() {
126
132
  try {
127
133
  await media.play();
128
134
  } catch (error) {
129
- if (!isDisposed && error?.name !== "AbortError") {
135
+ if (error?.name !== "AbortError") {
130
136
  throw error;
131
137
  }
132
138
  }
@@ -148,15 +154,26 @@ function seek(event) {
148
154
  function setVolume(event) {
149
155
  if (isDisposed) return;
150
156
  const nextVolume = Number(event.target.value);
151
- volume.value = nextVolume;
152
- muted.value = nextVolume === 0;
153
- applyAudioSettings();
157
+ window.setTimeout(() => {
158
+ if (isDisposed) return;
159
+ const media = getMedia();
160
+ if (!media) return;
161
+ media.volume = nextVolume;
162
+ media.muted = nextVolume === 0;
163
+ }, 0);
154
164
  }
155
165
 
156
- function toggleMute() {
166
+ function toggleMute(event) {
157
167
  if (isDisposed) return;
158
- muted.value = !muted.value;
159
- applyAudioSettings();
168
+ const button = event.currentTarget;
169
+ window.setTimeout(() => {
170
+ if (isDisposed) return;
171
+ const media = getMedia();
172
+ if (!media) return;
173
+ media.muted = !media.muted;
174
+ button.textContent = media.muted ? "Muted" : "Sound";
175
+ button.setAttribute("aria-label", media.muted ? "Unmute audio" : "Mute audio");
176
+ }, 0);
160
177
  }
161
178
 
162
179
  function skipBackward() {
@@ -172,8 +189,12 @@ function skipBy(offset) {
172
189
  const media = getMedia();
173
190
  if (!media) return;
174
191
  const nextTime = Math.min(Math.max(media.currentTime + offset, 0), safeDuration.value || media.currentTime + offset);
175
- media.currentTime = nextTime;
176
- currentTime.value = nextTime;
192
+ ignoreMediaEventsFor(100);
193
+ window.setTimeout(() => {
194
+ if (isDisposed) return;
195
+ media.currentTime = nextTime;
196
+ currentTime.value = nextTime;
197
+ }, 0);
177
198
  }
178
199
 
179
200
  function formatTime(seconds) {
@@ -32,8 +32,7 @@
32
32
  </div>
33
33
 
34
34
  <button class="center-toggle" type="button" @click="togglePlayback" :aria-label="playLabel">
35
- <span m-if="isPlaying" class="fa-icon icon-pause large" aria-hidden="true"></span>
36
- <span m-else class="fa-icon icon-play large" aria-hidden="true"></span>
35
+ <span class="fa-icon large" :class="playIconClass" aria-hidden="true"></span>
37
36
  </button>
38
37
 
39
38
  <div class="control-shelf">
@@ -61,15 +60,13 @@
61
60
  <div class="control-row">
62
61
  <div class="left-controls">
63
62
  <button class="icon-button" type="button" @click="togglePlayback" :aria-label="playLabel">
64
- <span m-if="isPlaying" class="fa-icon icon-pause" aria-hidden="true"></span>
65
- <span m-else class="fa-icon icon-play" aria-hidden="true"></span>
63
+ <span class="fa-icon" :class="playIconClass" aria-hidden="true"></span>
66
64
  </button>
67
65
  <button class="icon-button" type="button" @click="stopPlayback" aria-label="Stop video">
68
66
  <span class="fa-icon icon-stop" aria-hidden="true"></span>
69
67
  </button>
70
68
  <button class="icon-button" type="button" @click="toggleMute" :aria-label="muteLabel">
71
- <span m-if="muted" class="fa-icon icon-mute" aria-hidden="true"></span>
72
- <span m-else class="fa-icon icon-volume" aria-hidden="true"></span>
69
+ <span class="fa-icon icon-volume" aria-hidden="true"></span>
73
70
  </button>
74
71
  <label class="volume">
75
72
  <span>Volume</span>
@@ -79,10 +76,9 @@
79
76
  </div>
80
77
 
81
78
  <div class="right-controls">
82
- <button class="pill-button" type="button" @click="cycleRate">{{ playbackRate }}x</button>
79
+ <button class="pill-button" type="button" @click="cycleRate" aria-label="Change playback speed">1x</button>
83
80
  <button class="icon-button fullscreen-button" type="button" @click="toggleFullscreen" aria-label="Fullscreen">
84
- <span m-if="isFullscreen" class="fa-icon icon-minimize" aria-hidden="true"></span>
85
- <span m-else class="fa-icon icon-maximize" aria-hidden="true"></span>
81
+ <span class="fa-icon" :class="fullscreenIconClass" aria-hidden="true"></span>
86
82
  </button>
87
83
  </div>
88
84
  </div>
@@ -119,7 +115,6 @@ const isFullscreen = ref(false);
119
115
  const isSeeking = ref(false);
120
116
  const pointerInside = ref(false);
121
117
  const controlsVisible = ref(true);
122
- const playbackRate = ref(1);
123
118
  const safeDuration = computed(() => duration.value > 0 ? duration.value : 0);
124
119
  const seekProgress = computed(() => {
125
120
  if (safeDuration.value <= 0) return 0;
@@ -129,8 +124,13 @@ const seekProgress = computed(() => {
129
124
  const seekStyle = computed(() => "--progress: " + seekProgress.value + "%");
130
125
  const playLabel = computed(() => isPlaying.value ? "Pause video" : "Play video");
131
126
  const muteLabel = computed(() => muted.value ? "Unmute video" : "Mute video");
127
+ const playIconClass = computed(() => isPlaying.value ? "icon-pause" : "icon-play");
128
+ const fullscreenIconClass = computed(() => isFullscreen.value ? "icon-minimize" : "icon-maximize");
132
129
  const rates = [1, 1.25, 1.5, 2, 0.75];
130
+ let playbackRate = 1;
131
+ let rateIndex = 0;
133
132
  let isDisposed = false;
133
+ let ignoreMediaEventsUntil = 0;
134
134
 
135
135
  onMounted(() => {
136
136
  applyAudioSettings();
@@ -152,15 +152,20 @@ function getMedia() {
152
152
  return mediaEl.value;
153
153
  }
154
154
 
155
+ function ignoreMediaEventsFor(ms) {
156
+ ignoreMediaEventsUntil = performance.now() + ms;
157
+ }
158
+
159
+ function shouldIgnoreMediaEvent() {
160
+ return performance.now() < ignoreMediaEventsUntil;
161
+ }
162
+
155
163
  function syncMedia() {
156
- if (isDisposed) return;
164
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
157
165
  const media = getMedia();
158
166
  if (!media) return;
159
167
  currentTime.value = media.currentTime || 0;
160
168
  duration.value = Number.isFinite(media.duration) ? media.duration : 0;
161
- volume.value = media.volume;
162
- muted.value = media.muted;
163
- playbackRate.value = media.playbackRate;
164
169
  }
165
170
 
166
171
  function applyAudioSettings() {
@@ -169,17 +174,17 @@ function applyAudioSettings() {
169
174
  if (!media) return;
170
175
  media.volume = volume.value;
171
176
  media.muted = muted.value;
172
- media.playbackRate = playbackRate.value;
177
+ media.playbackRate = playbackRate;
173
178
  }
174
179
 
175
180
  function markPlaying() {
176
- if (isDisposed) return;
181
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
177
182
  isPlaying.value = true;
178
183
  controlsVisible.value = pointerInside.value;
179
184
  }
180
185
 
181
186
  function markPaused() {
182
- if (isDisposed) return;
187
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
183
188
  isPlaying.value = false;
184
189
  controlsVisible.value = true;
185
190
  }
@@ -196,7 +201,7 @@ async function togglePlayback() {
196
201
  try {
197
202
  await media.play();
198
203
  } catch (error) {
199
- if (!isDisposed && error?.name !== "AbortError") {
204
+ if (error?.name !== "AbortError") {
200
205
  throw error;
201
206
  }
202
207
  }
@@ -283,33 +288,56 @@ function seekWithKeyboard(event) {
283
288
 
284
289
  function stopPlayback() {
285
290
  if (isDisposed) return;
286
- const media = getMedia();
287
- if (!media) return;
288
- media.pause();
289
- media.currentTime = 0;
290
- currentTime.value = 0;
291
- isPlaying.value = false;
291
+ window.setTimeout(() => {
292
+ if (isDisposed) return;
293
+ const media = getMedia();
294
+ if (!media) return;
295
+ ignoreMediaEventsFor(300);
296
+ media.pause();
297
+ media.currentTime = 0;
298
+ }, 0);
292
299
  }
293
300
 
294
301
  function setVolume(event) {
295
302
  if (isDisposed) return;
296
303
  const nextVolume = Number(event.target.value);
297
- volume.value = nextVolume;
298
- muted.value = nextVolume === 0;
299
- applyAudioSettings();
304
+ window.setTimeout(() => {
305
+ if (isDisposed) return;
306
+ const media = getMedia();
307
+ if (!media) return;
308
+ media.volume = nextVolume;
309
+ media.muted = nextVolume === 0;
310
+ }, 0);
300
311
  }
301
312
 
302
- function toggleMute() {
313
+ function toggleMute(event) {
303
314
  if (isDisposed) return;
304
- muted.value = !muted.value;
305
- applyAudioSettings();
306
- }
307
-
308
- function cycleRate() {
315
+ const button = event.currentTarget;
316
+ window.setTimeout(() => {
317
+ if (isDisposed) return;
318
+ const media = getMedia();
319
+ if (!media) return;
320
+ media.muted = !media.muted;
321
+ const icon = button.querySelector(".fa-icon");
322
+ icon?.classList.toggle("icon-volume", !media.muted);
323
+ icon?.classList.toggle("icon-mute", media.muted);
324
+ button.setAttribute("aria-label", media.muted ? "Unmute video" : "Mute video");
325
+ }, 0);
326
+ }
327
+
328
+ function cycleRate(event) {
309
329
  if (isDisposed) return;
310
- const currentIndex = rates.indexOf(playbackRate.value);
311
- playbackRate.value = rates[(currentIndex + 1) % rates.length];
312
- applyAudioSettings();
330
+ const button = event.currentTarget;
331
+ window.setTimeout(() => {
332
+ if (isDisposed) return;
333
+ rateIndex = (rateIndex + 1) % rates.length;
334
+ playbackRate = rates[rateIndex];
335
+ const media = getMedia();
336
+ if (media) {
337
+ media.playbackRate = playbackRate;
338
+ }
339
+ button.textContent = playbackRate + "x";
340
+ }, 0);
313
341
  }
314
342
 
315
343
  function toggleFullscreen() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikuru",
3
- "version": "1.0.29",
3
+ "version": "1.0.30",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A compile-first JavaScript framework with Vue-like authoring and Svelte-like generated DOM updates.",