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 +10 -0
- package/components/MikuruAudioPlayer.mikuru +57 -16
- package/components/MikuruVideoPlayer.mikuru +95 -36
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
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;
|