mikuru 1.0.28 → 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,15 @@
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
+
8
+ ## 1.0.29 - 2026-05-15
9
+
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.
11
+ - Handled aborted `play()` promises during media player teardown to avoid uncaught browser `AbortError` / `DOMException` noise when closing a sample video quickly.
12
+
3
13
  ## 1.0.28 - 2026-05-15
4
14
 
5
15
  - Added package-exported Mikuru video player, audio player, image viewer, modal, carousel, toast, dropdown, tooltip, progress, and code block components with custom controls, template refs, lifecycle cleanup, keyboard-accessible seeking/navigation, volume, mute, playback rate, stop, fullscreen, close/select/dismiss events, progress states, and copy actions.
@@ -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>
@@ -49,7 +47,7 @@
49
47
  </template>
50
48
 
51
49
  <script>
52
- import { computed, onMounted, ref } from "mikuru";
50
+ import { computed, onBeforeUnmount, onMounted, ref } from "mikuru";
53
51
 
54
52
  const {
55
53
  src,
@@ -72,29 +70,45 @@ 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
  });
78
+ let isDisposed = false;
79
+ let ignoreMediaEventsUntil = 0;
79
80
 
80
81
  onMounted(() => {
81
82
  applyAudioSettings();
82
83
  });
83
84
 
85
+ onBeforeUnmount(() => {
86
+ isDisposed = true;
87
+ });
88
+
84
89
  function getMedia() {
90
+ if (isDisposed) return null;
85
91
  return mediaEl.value;
86
92
  }
87
93
 
94
+ function ignoreMediaEventsFor(ms) {
95
+ ignoreMediaEventsUntil = performance.now() + ms;
96
+ }
97
+
98
+ function shouldIgnoreMediaEvent() {
99
+ return performance.now() < ignoreMediaEventsUntil;
100
+ }
101
+
88
102
  function syncMedia() {
103
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
89
104
  const media = getMedia();
90
105
  if (!media) return;
91
106
  currentTime.value = media.currentTime || 0;
92
107
  duration.value = Number.isFinite(media.duration) ? media.duration : 0;
93
- volume.value = media.volume;
94
- muted.value = media.muted;
95
108
  }
96
109
 
97
110
  function applyAudioSettings() {
111
+ if (isDisposed) return;
98
112
  const media = getMedia();
99
113
  if (!media) return;
100
114
  media.volume = volume.value;
@@ -102,10 +116,12 @@ function applyAudioSettings() {
102
116
  }
103
117
 
104
118
  function markPlaying() {
119
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
105
120
  isPlaying.value = true;
106
121
  }
107
122
 
108
123
  function markPaused() {
124
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
109
125
  isPlaying.value = false;
110
126
  }
111
127
 
@@ -113,13 +129,20 @@ async function togglePlayback() {
113
129
  const media = getMedia();
114
130
  if (!media) return;
115
131
  if (media.paused) {
116
- await media.play();
132
+ try {
133
+ await media.play();
134
+ } catch (error) {
135
+ if (error?.name !== "AbortError") {
136
+ throw error;
137
+ }
138
+ }
117
139
  return;
118
140
  }
119
141
  media.pause();
120
142
  }
121
143
 
122
144
  function seek(event) {
145
+ if (isDisposed) return;
123
146
  const media = getMedia();
124
147
  const nextTime = Number(event.target.value);
125
148
  currentTime.value = nextTime;
@@ -129,15 +152,28 @@ function seek(event) {
129
152
  }
130
153
 
131
154
  function setVolume(event) {
155
+ if (isDisposed) return;
132
156
  const nextVolume = Number(event.target.value);
133
- volume.value = nextVolume;
134
- muted.value = nextVolume === 0;
135
- 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);
136
164
  }
137
165
 
138
- function toggleMute() {
139
- muted.value = !muted.value;
140
- applyAudioSettings();
166
+ function toggleMute(event) {
167
+ if (isDisposed) return;
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);
141
177
  }
142
178
 
143
179
  function skipBackward() {
@@ -149,11 +185,16 @@ function skipForward() {
149
185
  }
150
186
 
151
187
  function skipBy(offset) {
188
+ if (isDisposed) return;
152
189
  const media = getMedia();
153
190
  if (!media) return;
154
191
  const nextTime = Math.min(Math.max(media.currentTime + offset, 0), safeDuration.value || media.currentTime + offset);
155
- media.currentTime = nextTime;
156
- currentTime.value = nextTime;
192
+ ignoreMediaEventsFor(100);
193
+ window.setTimeout(() => {
194
+ if (isDisposed) return;
195
+ media.currentTime = nextTime;
196
+ currentTime.value = nextTime;
197
+ }, 0);
157
198
  }
158
199
 
159
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>
@@ -92,7 +88,7 @@
92
88
  </template>
93
89
 
94
90
  <script>
95
- import { computed, onMounted, onUnmounted, ref } from "mikuru";
91
+ import { computed, onBeforeUnmount, onMounted, onUnmounted, ref } from "mikuru";
96
92
 
97
93
  const {
98
94
  src,
@@ -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,50 +124,73 @@ 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;
132
+ let isDisposed = false;
133
+ let ignoreMediaEventsUntil = 0;
133
134
 
134
135
  onMounted(() => {
135
136
  applyAudioSettings();
136
137
  document.addEventListener("fullscreenchange", updateFullscreen);
137
138
  });
138
139
 
140
+ onBeforeUnmount(() => {
141
+ isDisposed = true;
142
+ isSeeking.value = false;
143
+ pointerInside.value = false;
144
+ });
145
+
139
146
  onUnmounted(() => {
140
147
  document.removeEventListener("fullscreenchange", updateFullscreen);
141
148
  });
142
149
 
143
150
  function getMedia() {
151
+ if (isDisposed) return null;
144
152
  return mediaEl.value;
145
153
  }
146
154
 
155
+ function ignoreMediaEventsFor(ms) {
156
+ ignoreMediaEventsUntil = performance.now() + ms;
157
+ }
158
+
159
+ function shouldIgnoreMediaEvent() {
160
+ return performance.now() < ignoreMediaEventsUntil;
161
+ }
162
+
147
163
  function syncMedia() {
164
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
148
165
  const media = getMedia();
149
166
  if (!media) return;
150
167
  currentTime.value = media.currentTime || 0;
151
168
  duration.value = Number.isFinite(media.duration) ? media.duration : 0;
152
- volume.value = media.volume;
153
- muted.value = media.muted;
154
- playbackRate.value = media.playbackRate;
155
169
  }
156
170
 
157
171
  function applyAudioSettings() {
172
+ if (isDisposed) return;
158
173
  const media = getMedia();
159
174
  if (!media) return;
160
175
  media.volume = volume.value;
161
176
  media.muted = muted.value;
162
- media.playbackRate = playbackRate.value;
177
+ media.playbackRate = playbackRate;
163
178
  }
164
179
 
165
180
  function markPlaying() {
181
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
166
182
  isPlaying.value = true;
167
183
  controlsVisible.value = pointerInside.value;
168
184
  }
169
185
 
170
186
  function markPaused() {
187
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
171
188
  isPlaying.value = false;
172
189
  controlsVisible.value = true;
173
190
  }
174
191
 
175
192
  function updateFullscreen() {
193
+ if (isDisposed) return;
176
194
  isFullscreen.value = document.fullscreenElement === screenEl.value || document.fullscreenElement === mediaEl.value;
177
195
  }
178
196
 
@@ -180,7 +198,13 @@ async function togglePlayback() {
180
198
  const media = getMedia();
181
199
  if (!media) return;
182
200
  if (media.paused) {
183
- await media.play();
201
+ try {
202
+ await media.play();
203
+ } catch (error) {
204
+ if (error?.name !== "AbortError") {
205
+ throw error;
206
+ }
207
+ }
184
208
  return;
185
209
  }
186
210
  media.pause();
@@ -194,6 +218,7 @@ function getPointerTime(event) {
194
218
  }
195
219
 
196
220
  function seekTo(nextTime) {
221
+ if (isDisposed) return;
197
222
  const media = getMedia();
198
223
  currentTime.value = nextTime;
199
224
  if (media) {
@@ -206,6 +231,7 @@ function seekFromPointer(event) {
206
231
  }
207
232
 
208
233
  function startSeek(event) {
234
+ if (isDisposed) return;
209
235
  isSeeking.value = true;
210
236
  controlsVisible.value = true;
211
237
  event.currentTarget.setPointerCapture?.(event.pointerId);
@@ -213,11 +239,13 @@ function startSeek(event) {
213
239
  }
214
240
 
215
241
  function dragSeek(event) {
242
+ if (isDisposed) return;
216
243
  if (!isSeeking.value) return;
217
244
  seekFromPointer(event);
218
245
  }
219
246
 
220
247
  function endSeek(event) {
248
+ if (isDisposed) return;
221
249
  if (!isSeeking.value) return;
222
250
  isSeeking.value = false;
223
251
  event.currentTarget.releasePointerCapture?.(event.pointerId);
@@ -225,17 +253,20 @@ function endSeek(event) {
225
253
  }
226
254
 
227
255
  function showControls() {
256
+ if (isDisposed) return;
228
257
  pointerInside.value = true;
229
258
  controlsVisible.value = true;
230
259
  }
231
260
 
232
261
  function hideControls() {
262
+ if (isDisposed) return;
233
263
  pointerInside.value = false;
234
264
  if (!isPlaying.value || isSeeking.value) return;
235
265
  controlsVisible.value = false;
236
266
  }
237
267
 
238
268
  function seekWithKeyboard(event) {
269
+ if (isDisposed) return;
239
270
  if (event.key !== "ArrowLeft" && event.key !== "ArrowRight" && event.key !== "Home" && event.key !== "End") {
240
271
  return;
241
272
  }
@@ -256,33 +287,61 @@ function seekWithKeyboard(event) {
256
287
  }
257
288
 
258
289
  function stopPlayback() {
259
- const media = getMedia();
260
- if (!media) return;
261
- media.pause();
262
- media.currentTime = 0;
263
- currentTime.value = 0;
264
- isPlaying.value = false;
290
+ if (isDisposed) return;
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);
265
299
  }
266
300
 
267
301
  function setVolume(event) {
302
+ if (isDisposed) return;
268
303
  const nextVolume = Number(event.target.value);
269
- volume.value = nextVolume;
270
- muted.value = nextVolume === 0;
271
- applyAudioSettings();
272
- }
273
-
274
- function toggleMute() {
275
- muted.value = !muted.value;
276
- applyAudioSettings();
277
- }
278
-
279
- function cycleRate() {
280
- const currentIndex = rates.indexOf(playbackRate.value);
281
- playbackRate.value = rates[(currentIndex + 1) % rates.length];
282
- 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);
311
+ }
312
+
313
+ function toggleMute(event) {
314
+ if (isDisposed) return;
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) {
329
+ if (isDisposed) return;
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);
283
341
  }
284
342
 
285
343
  function toggleFullscreen() {
344
+ if (isDisposed) return;
286
345
  if (document.fullscreenElement) {
287
346
  document.exitFullscreen?.();
288
347
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikuru",
3
- "version": "1.0.28",
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.",