mikuru 1.0.30 → 1.0.32

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.32 - 2026-05-16
4
+
5
+ - Added `controls` and `live` props to `MikuruVideoPlayer` and `MikuruAudioPlayer` so callers can choose visible controls and render live-stream UI without seek controls.
6
+ - Removed the `MikuruVideoPlayer` stop control so video playback uses the same play/pause-only primary transport as the center control.
7
+ - Updated `MikuruAudioPlayer` controls to use icon buttons for play, skip, mute, and volume-facing actions, matching the video player control style.
8
+ - Added timed auto-dismiss to `MikuruToast` with stack-level and per-toast `duration` controls.
9
+ - Exported `MikuruVideoPlayer` media events so parent components can listen for playback, timing, seeking, volume, and playback-rate changes with typed media state payloads.
10
+ - Exported matching `MikuruAudioPlayer` media events and documented the shared media player event payload.
11
+
3
12
  ## 1.0.30 - 2026-05-15
4
13
 
5
14
  - Hardened `MikuruVideoPlayer` controls so stop, mute, playback speed, seeking, and modal close operations do not trigger recursive updates while browser media events are firing.
package/README.md CHANGED
@@ -270,12 +270,12 @@ npm run dev:mikuru-vue-like
270
270
 
271
271
  The package also includes original Mikuru components:
272
272
 
273
- - `MikuruVideoPlayer.mikuru`: overlay video controls, div-based seeking, volume/mute, playback rate, stop, and fullscreen controls.
274
- - `MikuruAudioPlayer.mikuru`: audio playback with seeking, skip controls, volume, and mute.
273
+ - `MikuruVideoPlayer.mikuru`: overlay video controls, configurable control visibility, live mode, div-based seeking, volume/mute, playback rate, and fullscreen controls.
274
+ - `MikuruAudioPlayer.mikuru`: audio playback with configurable control visibility, live mode, seeking, skip controls, volume, and mute.
275
275
  - `MikuruImageViewer.mikuru`: image zoom, pan, rotate, reset, and fullscreen controls.
276
276
  - `MikuruModal.mikuru`: accessible modal shell with backdrop, Escape close, slots, and close events.
277
277
  - `MikuruCarousel.mikuru`: image carousel with arrows, dots, keyboard navigation, and optional autoplay.
278
- - `MikuruToast.mikuru`: fixed notification stack with dismiss events and tone variants.
278
+ - `MikuruToast.mikuru`: fixed notification stack with timed auto-dismiss, dismiss events, and tone variants.
279
279
  - `MikuruDropdown.mikuru`: menu button with outside-click close, Escape handling, and select events.
280
280
  - `MikuruToolTip.mikuru`: hover/focus tooltip with configurable placement.
281
281
  - `MikuruProgress.mikuru`: determinate and indeterminate progress indicator.
@@ -1,15 +1,18 @@
1
1
  <template>
2
- <section class="mikuru-audio" :class="{ 'is-playing': isPlaying }" :aria-label="title">
2
+ <section class="mikuru-audio" :class="{ 'is-playing': isPlaying, 'is-live': isLive }" :aria-label="title">
3
3
  <audio
4
4
  ref="mediaEl"
5
5
  :src="src"
6
6
  :preload="preload"
7
- @loadedmetadata="syncMedia"
8
- @timeupdate="syncMedia"
9
- @durationchange="syncMedia"
7
+ @loadedmetadata="handleLoadedMetadata"
8
+ @timeupdate="handleTimeUpdate"
9
+ @durationchange="handleDurationChange"
10
10
  @play="markPlaying"
11
11
  @pause="markPaused"
12
- @ended="markPaused"
12
+ @ended="markEnded"
13
+ @seeked="handleSeeked"
14
+ @volumechange="handleVolumeChange"
15
+ @ratechange="handleRateChange"
13
16
  ></audio>
14
17
 
15
18
  <div class="art">
@@ -19,28 +22,33 @@
19
22
  <div class="body">
20
23
  <div class="identity">
21
24
  <strong>{{ title }}</strong>
22
- <span>{{ artist }}</span>
25
+ <span :class="{ 'live-badge': isLive }">{{ statusText }}</span>
23
26
  </div>
24
27
 
25
- <div class="timeline">
26
- <span>{{ formatTime(currentTime) }}</span>
27
- <input type="range" min="0" :max="safeDuration" step="0.1" :value="currentTime" @input="seek" />
28
- <span>{{ formatTime(duration) }}</span>
28
+ <div m-if="showTimeline" class="timeline" :class="timelineClass">
29
+ <span m-if="showTimeControl">{{ formatTime(currentTime) }}</span>
30
+ <input m-if="showSeekControl" type="range" min="0" :max="safeDuration" step="0.1" :value="currentTime" @input="seek" />
31
+ <span m-if="showTimeControl">{{ formatTime(duration) }}</span>
29
32
  </div>
30
33
 
31
- <div class="controls">
32
- <button type="button" @click="skipBackward">-10</button>
33
- <button class="primary" type="button" @click="togglePlayback" :aria-label="playLabel">
34
- <span>{{ playText }}</span>
34
+ <div m-if="showControls" class="controls">
35
+ <button m-if="showSkipControl" class="icon-button" type="button" @click="skipBackward" aria-label="Back 10 seconds">
36
+ <span class="fa-icon icon-backward" aria-hidden="true"></span>
35
37
  </button>
36
- <button type="button" @click="skipForward">+10</button>
37
- <button type="button" @click="toggleMute" :aria-label="muteLabel">
38
- <span>Sound</span>
38
+ <button m-if="showPlayControl" class="icon-button primary" type="button" @click="togglePlayback" :aria-label="playLabel">
39
+ <span class="fa-icon" :class="playIconClass" aria-hidden="true"></span>
39
40
  </button>
40
- <label class="volume">
41
+ <button m-if="showSkipControl" class="icon-button" type="button" @click="skipForward" aria-label="Forward 10 seconds">
42
+ <span class="fa-icon icon-forward" aria-hidden="true"></span>
43
+ </button>
44
+ <button m-if="showMuteControl" class="icon-button" type="button" @click="toggleMute" :aria-label="muteLabel">
45
+ <span class="fa-icon" :class="muteIconClass" aria-hidden="true"></span>
46
+ </button>
47
+ <label m-if="showVolumeControl" class="volume">
41
48
  <span>Volume</span>
42
49
  <input type="range" min="0" max="1" step="0.01" :value="volume" @input="setVolume" />
43
50
  </label>
51
+ <span m-if="isLive" class="live-clock">LIVE</span>
44
52
  </div>
45
53
  </div>
46
54
  </section>
@@ -53,24 +61,67 @@ const {
53
61
  src,
54
62
  title = "Mikuru Audio",
55
63
  artist = "Original player component",
56
- preload = "metadata"
64
+ preload = "metadata",
65
+ controls,
66
+ live = false
57
67
  } = defineProps({
58
68
  src: String,
59
69
  title: String,
60
70
  artist: String,
61
- preload: String
71
+ preload: String,
72
+ controls: Array,
73
+ live: Boolean
62
74
  });
63
75
 
76
+ const emit = defineEmits([
77
+ "loadedmetadata",
78
+ "timeupdate",
79
+ "durationchange",
80
+ "play",
81
+ "pause",
82
+ "ended",
83
+ "seeked",
84
+ "volumechange",
85
+ "ratechange"
86
+ ]);
87
+
64
88
  const mediaEl = ref(null);
65
89
  const currentTime = ref(0);
66
90
  const duration = ref(0);
67
91
  const volume = ref(0.75);
68
92
  const muted = ref(false);
69
93
  const isPlaying = ref(false);
94
+ const allControls = ["play", "seek", "time", "skip", "mute", "volume"];
95
+ const liveHiddenControls = ["seek", "time", "skip"];
96
+ const activeControls = computed(() => {
97
+ const configuredControls = Array.isArray(controls.value) ? controls.value : allControls;
98
+ return new Set(configuredControls);
99
+ });
100
+ const isLive = computed(() => live.value === true);
70
101
  const safeDuration = computed(() => duration.value > 0 ? duration.value : 0);
71
102
  const playLabel = computed(() => isPlaying.value ? "Pause audio" : "Play audio");
72
103
  const muteLabel = computed(() => muted.value ? "Unmute audio" : "Mute audio");
73
- const playText = computed(() => isPlaying.value ? "Pause" : "Play");
104
+ const playIconClass = computed(() => isPlaying.value ? "icon-pause" : "icon-play");
105
+ const muteIconClass = computed(() => muted.value ? "icon-mute" : "icon-volume");
106
+ const statusText = computed(() => isLive.value ? "LIVE" : artist.value);
107
+ const showPlayControl = computed(() => hasControl("play"));
108
+ const showSeekControl = computed(() => hasControl("seek"));
109
+ const showTimeControl = computed(() => hasControl("time"));
110
+ const showSkipControl = computed(() => hasControl("skip"));
111
+ const showMuteControl = computed(() => hasControl("mute"));
112
+ const showVolumeControl = computed(() => hasControl("volume"));
113
+ const showTimeline = computed(() => showSeekControl.value || showTimeControl.value);
114
+ const timelineClass = computed(() => ({
115
+ "timeline-seek-only": showSeekControl.value && !showTimeControl.value,
116
+ "timeline-time-only": showTimeControl.value && !showSeekControl.value
117
+ }));
118
+ const showControls = computed(() => (
119
+ showPlayControl.value ||
120
+ showSkipControl.value ||
121
+ showMuteControl.value ||
122
+ showVolumeControl.value ||
123
+ isLive.value
124
+ ));
74
125
  const initials = computed(() => {
75
126
  const words = title.value.split(" ").filter(Boolean);
76
127
  return words.slice(0, 2).map((word) => word[0]).join("").toUpperCase() || "M";
@@ -99,6 +150,11 @@ function shouldIgnoreMediaEvent() {
99
150
  return performance.now() < ignoreMediaEventsUntil;
100
151
  }
101
152
 
153
+ function hasControl(name) {
154
+ if (isLive.value && liveHiddenControls.includes(name)) return false;
155
+ return activeControls.value.has(name);
156
+ }
157
+
102
158
  function syncMedia() {
103
159
  if (isDisposed || shouldIgnoreMediaEvent()) return;
104
160
  const media = getMedia();
@@ -107,6 +163,67 @@ function syncMedia() {
107
163
  duration.value = Number.isFinite(media.duration) ? media.duration : 0;
108
164
  }
109
165
 
166
+ function syncVolumeState() {
167
+ if (isDisposed) return;
168
+ const media = getMedia();
169
+ if (!media) return;
170
+ volume.value = media.volume;
171
+ muted.value = media.muted;
172
+ }
173
+
174
+ function createMediaPayload(event) {
175
+ const media = getMedia();
176
+ if (!media) return null;
177
+ return {
178
+ currentTime: media.currentTime || 0,
179
+ duration: Number.isFinite(media.duration) ? media.duration : 0,
180
+ paused: media.paused,
181
+ ended: media.ended,
182
+ muted: media.muted,
183
+ volume: media.volume,
184
+ playbackRate: media.playbackRate,
185
+ nativeEvent: event
186
+ };
187
+ }
188
+
189
+ function emitMediaPayload(event, dispatch) {
190
+ const payload = createMediaPayload(event);
191
+ if (payload) {
192
+ dispatch(payload);
193
+ }
194
+ }
195
+
196
+ function handleLoadedMetadata(event) {
197
+ syncMedia();
198
+ emitMediaPayload(event, (payload) => emit("loadedmetadata", payload));
199
+ }
200
+
201
+ function handleTimeUpdate(event) {
202
+ syncMedia();
203
+ emitMediaPayload(event, (payload) => emit("timeupdate", payload));
204
+ }
205
+
206
+ function handleDurationChange(event) {
207
+ syncMedia();
208
+ emitMediaPayload(event, (payload) => emit("durationchange", payload));
209
+ }
210
+
211
+ function handleSeeked(event) {
212
+ syncMedia();
213
+ emitMediaPayload(event, (payload) => emit("seeked", payload));
214
+ }
215
+
216
+ function handleVolumeChange(event) {
217
+ syncMedia();
218
+ syncVolumeState();
219
+ emitMediaPayload(event, (payload) => emit("volumechange", payload));
220
+ }
221
+
222
+ function handleRateChange(event) {
223
+ syncMedia();
224
+ emitMediaPayload(event, (payload) => emit("ratechange", payload));
225
+ }
226
+
110
227
  function applyAudioSettings() {
111
228
  if (isDisposed) return;
112
229
  const media = getMedia();
@@ -115,14 +232,22 @@ function applyAudioSettings() {
115
232
  media.muted = muted.value;
116
233
  }
117
234
 
118
- function markPlaying() {
235
+ function markPlaying(event) {
119
236
  if (isDisposed || shouldIgnoreMediaEvent()) return;
120
237
  isPlaying.value = true;
238
+ emitMediaPayload(event, (payload) => emit("play", payload));
239
+ }
240
+
241
+ function markPaused(event) {
242
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
243
+ isPlaying.value = false;
244
+ emitMediaPayload(event, (payload) => emit("pause", payload));
121
245
  }
122
246
 
123
- function markPaused() {
247
+ function markEnded(event) {
124
248
  if (isDisposed || shouldIgnoreMediaEvent()) return;
125
249
  isPlaying.value = false;
250
+ emitMediaPayload(event, (payload) => emit("ended", payload));
126
251
  }
127
252
 
128
253
  async function togglePlayback() {
@@ -160,19 +285,19 @@ function setVolume(event) {
160
285
  if (!media) return;
161
286
  media.volume = nextVolume;
162
287
  media.muted = nextVolume === 0;
288
+ volume.value = nextVolume;
289
+ muted.value = media.muted;
163
290
  }, 0);
164
291
  }
165
292
 
166
- function toggleMute(event) {
293
+ function toggleMute() {
167
294
  if (isDisposed) return;
168
- const button = event.currentTarget;
169
295
  window.setTimeout(() => {
170
296
  if (isDisposed) return;
171
297
  const media = getMedia();
172
298
  if (!media) return;
173
299
  media.muted = !media.muted;
174
- button.textContent = media.muted ? "Muted" : "Sound";
175
- button.setAttribute("aria-label", media.muted ? "Unmute audio" : "Mute audio");
300
+ muted.value = media.muted;
176
301
  }, 0);
177
302
  }
178
303
 
@@ -243,6 +368,32 @@ function formatTime(seconds) {
243
368
  font-size: 0.9rem;
244
369
  }
245
370
 
371
+ .live-badge,
372
+ .live-clock {
373
+ color: #ffffff;
374
+ font-size: 0.78rem;
375
+ font-weight: 800;
376
+ letter-spacing: 0;
377
+ }
378
+
379
+ .live-badge {
380
+ display: inline-flex;
381
+ align-items: center;
382
+ width: max-content;
383
+ padding: 4px 8px;
384
+ border-radius: 999px;
385
+ background: #dc2626;
386
+ }
387
+
388
+ .live-clock {
389
+ display: inline-flex;
390
+ align-items: center;
391
+ min-height: 24px;
392
+ padding: 0 8px;
393
+ border-radius: 999px;
394
+ background: #dc2626;
395
+ }
396
+
246
397
  .timeline {
247
398
  display: grid;
248
399
  grid-template-columns: 42px minmax(0, 1fr) 42px;
@@ -250,6 +401,15 @@ function formatTime(seconds) {
250
401
  align-items: center;
251
402
  }
252
403
 
404
+ .timeline-seek-only {
405
+ grid-template-columns: minmax(0, 1fr);
406
+ }
407
+
408
+ .timeline-time-only {
409
+ grid-template-columns: repeat(2, 42px);
410
+ justify-content: space-between;
411
+ }
412
+
253
413
  .timeline span:last-child {
254
414
  text-align: right;
255
415
  }
@@ -267,6 +427,14 @@ function formatTime(seconds) {
267
427
  gap: 8px;
268
428
  }
269
429
 
430
+ .volume span {
431
+ position: absolute;
432
+ width: 1px;
433
+ height: 1px;
434
+ overflow: hidden;
435
+ clip: rect(0 0 0 0);
436
+ }
437
+
270
438
  input[type="range"] {
271
439
  accent-color: #0f766e;
272
440
  }
@@ -292,6 +460,49 @@ button:hover {
292
460
  background: #0f766e;
293
461
  }
294
462
 
463
+ .icon-button {
464
+ display: inline-grid;
465
+ place-items: center;
466
+ width: 38px;
467
+ padding: 0;
468
+ }
469
+
470
+ .fa-icon {
471
+ display: block;
472
+ width: 18px;
473
+ height: 18px;
474
+ background: currentColor;
475
+ filter: drop-shadow(0 1px 0 rgba(255, 255, 255, 0.28));
476
+ mask-position: center;
477
+ mask-repeat: no-repeat;
478
+ mask-size: contain;
479
+ }
480
+
481
+ .icon-play {
482
+ transform: translateX(1px);
483
+ mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M187.2 100.9C174.8 94.1 159.8 94.4 147.6 101.6C135.4 108.8 128 121.9 128 136L128 504C128 518.1 135.5 531.2 147.6 538.4C159.7 545.6 174.8 545.9 187.2 539.1L523.2 355.1C536 348.1 544 334.6 544 320C544 305.4 536 291.9 523.2 284.9L187.2 100.9z"/></svg>');
484
+ }
485
+
486
+ .icon-pause {
487
+ mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M176 96C149.5 96 128 117.5 128 144L128 496C128 522.5 149.5 544 176 544L240 544C266.5 544 288 522.5 288 496L288 144C288 117.5 266.5 96 240 96L176 96zM400 96C373.5 96 352 117.5 352 144L352 496C352 522.5 373.5 544 400 544L464 544C490.5 544 512 522.5 512 496L512 144C512 117.5 490.5 96 464 96L400 96z"/></svg>');
488
+ }
489
+
490
+ .icon-backward {
491
+ mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M288 128L96 320L288 512L288 128zM544 128L352 320L544 512L544 128z"/></svg>');
492
+ }
493
+
494
+ .icon-forward {
495
+ mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M96 128L288 320L96 512L96 128zM352 128L544 320L352 512L352 128z"/></svg>');
496
+ }
497
+
498
+ .icon-volume {
499
+ mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M112 416L160 416L294.1 535.2C300.5 540.9 308.7 544 317.2 544C336.4 544 352 528.4 352 509.2L352 130.8C352 111.6 336.4 96 317.2 96C308.7 96 300.5 99.1 294.1 104.8L160 224L112 224C85.5 224 64 245.5 64 272L64 368C64 394.5 85.5 416 112 416zM505.1 171C494.8 162.6 479.7 164.2 471.3 174.5C462.9 184.8 464.5 199.9 474.8 208.3C507.3 234.7 528 274.9 528 320C528 365.1 507.3 405.3 474.8 431.8C464.5 440.2 463 455.3 471.3 465.6C479.6 475.9 494.8 477.4 505.1 469.1C548.3 433.9 576 380.2 576 320.1C576 260 548.3 206.3 505.1 171.1zM444.6 245.5C434.3 237.1 419.2 238.7 410.8 249C402.4 259.3 404 274.4 414.3 282.8C425.1 291.6 432 305 432 320C432 335 425.1 348.4 414.3 357.3C404 365.7 402.5 380.8 410.8 391.1C419.1 401.4 434.3 402.9 444.6 394.6C466.1 376.9 480 350.1 480 320C480 289.9 466.1 263.1 444.5 245.5z"/></svg>');
500
+ }
501
+
502
+ .icon-mute {
503
+ mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M80 416L128 416L262.1 535.2C268.5 540.9 276.7 544 285.2 544C304.4 544 320 528.4 320 509.2L320 130.8C320 111.6 304.4 96 285.2 96C276.7 96 268.5 99.1 262.1 104.8L128 224L80 224C53.5 224 32 245.5 32 272L32 368C32 394.5 53.5 416 80 416zM399 239C389.6 248.4 389.6 263.6 399 272.9L446 319.9L399 366.9C389.6 376.3 389.6 391.5 399 400.8C408.4 410.1 423.6 410.2 432.9 400.8L479.9 353.8L526.9 400.8C536.3 410.2 551.5 410.2 560.8 400.8C570.1 391.4 570.2 376.2 560.8 366.9L513.8 319.9L560.8 272.9C570.2 263.5 570.2 248.3 560.8 239C551.4 229.7 536.2 229.6 526.9 239L479.9 286L432.9 239C423.5 229.6 408.3 229.6 399 239z"/></svg>');
504
+ }
505
+
295
506
  @media (max-width: 620px) {
296
507
  .mikuru-audio {
297
508
  grid-template-columns: 1fr;
@@ -17,36 +17,84 @@
17
17
  </template>
18
18
 
19
19
  <script>
20
- import { computed } from "mikuru";
20
+ import { computed, onMounted, onUnmounted, watch } from "mikuru";
21
21
 
22
22
  const {
23
23
  toasts = [],
24
- position = "bottom-right"
24
+ position = "bottom-right",
25
+ duration = 5000
25
26
  } = defineProps({
26
27
  toasts: Array,
27
- position: String
28
+ position: String,
29
+ duration: Number
28
30
  });
29
31
 
30
32
  const emit = defineEmits(["dismiss"]);
33
+ const timers = new Map();
31
34
  const positionClass = computed(() => `position-${position.value}`);
32
35
  const normalizedToasts = computed(() => {
33
36
  const source = Array.isArray(toasts.value) ? toasts.value : [];
34
37
  return source.map((toast, index) => {
35
38
  const id = toast.id ?? index;
36
39
  const tone = toast.tone || "info";
40
+ const toastDuration = typeof toast.duration === "number" ? toast.duration : duration.value;
37
41
  return {
38
42
  id,
39
43
  title: toast.title || "Notification",
40
44
  message: toast.message || "",
41
45
  toneClass: `tone-${tone}`,
42
- dismissLabel: `Dismiss ${toast.title || "notification"}`
46
+ dismissLabel: `Dismiss ${toast.title || "notification"}`,
47
+ duration: toastDuration
43
48
  };
44
49
  });
45
50
  });
46
51
 
52
+ onMounted(() => {
53
+ syncToastTimers();
54
+ });
55
+
56
+ onUnmounted(() => {
57
+ clearToastTimers();
58
+ });
59
+
60
+ watch(toasts, () => {
61
+ syncToastTimers();
62
+ });
63
+
47
64
  function dismissToast(id) {
65
+ clearToastTimer(id);
48
66
  emit("dismiss", id);
49
67
  }
68
+
69
+ function syncToastTimers() {
70
+ const visibleIds = new Set(normalizedToasts.value.map((toast) => toast.id));
71
+ Array.from(timers.keys()).forEach((id) => {
72
+ if (!visibleIds.has(id)) {
73
+ clearToastTimer(id);
74
+ }
75
+ });
76
+
77
+ normalizedToasts.value.forEach((toast) => {
78
+ if (timers.has(toast.id)) return;
79
+ if (!Number.isFinite(toast.duration) || toast.duration <= 0) return;
80
+ const timer = window.setTimeout(() => {
81
+ timers.delete(toast.id);
82
+ emit("dismiss", toast.id);
83
+ }, toast.duration);
84
+ timers.set(toast.id, timer);
85
+ });
86
+ }
87
+
88
+ function clearToastTimer(id) {
89
+ const timer = timers.get(id);
90
+ if (!timer) return;
91
+ window.clearTimeout(timer);
92
+ timers.delete(id);
93
+ }
94
+
95
+ function clearToastTimers() {
96
+ Array.from(timers.keys()).forEach((id) => clearToastTimer(id));
97
+ }
50
98
  </script>
51
99
 
52
100
  <style scoped>
@@ -17,26 +17,30 @@
17
17
  :poster="poster"
18
18
  :preload="preload"
19
19
  playsinline
20
- @loadedmetadata="syncMedia"
21
- @timeupdate="syncMedia"
22
- @durationchange="syncMedia"
20
+ @loadedmetadata="handleLoadedMetadata"
21
+ @timeupdate="handleTimeUpdate"
22
+ @durationchange="handleDurationChange"
23
23
  @play="markPlaying"
24
24
  @pause="markPaused"
25
- @ended="markPaused"
25
+ @ended="markEnded"
26
+ @seeked="handleSeeked"
27
+ @volumechange="handleVolumeChange"
28
+ @ratechange="handleRateChange"
26
29
  @click="togglePlayback"
27
30
  ></video>
28
31
 
29
32
  <div class="top-bar">
30
33
  <strong>{{ title }}</strong>
31
- <span>{{ subtitle }}</span>
34
+ <span :class="{ 'live-badge': isLive }">{{ statusText }}</span>
32
35
  </div>
33
36
 
34
- <button class="center-toggle" type="button" @click="togglePlayback" :aria-label="playLabel">
37
+ <button m-if="showPlayControl" class="center-toggle" type="button" @click="togglePlayback" :aria-label="playLabel">
35
38
  <span class="fa-icon large" :class="playIconClass" aria-hidden="true"></span>
36
39
  </button>
37
40
 
38
- <div class="control-shelf">
41
+ <div m-if="showControlShelf" class="control-shelf">
39
42
  <div
43
+ m-if="showSeekControl"
40
44
  class="seek"
41
45
  role="slider"
42
46
  tabindex="0"
@@ -59,25 +63,22 @@
59
63
 
60
64
  <div class="control-row">
61
65
  <div class="left-controls">
62
- <button class="icon-button" type="button" @click="togglePlayback" :aria-label="playLabel">
66
+ <button m-if="showPlayControl" class="icon-button" type="button" @click="togglePlayback" :aria-label="playLabel">
63
67
  <span class="fa-icon" :class="playIconClass" aria-hidden="true"></span>
64
68
  </button>
65
- <button class="icon-button" type="button" @click="stopPlayback" aria-label="Stop video">
66
- <span class="fa-icon icon-stop" aria-hidden="true"></span>
67
- </button>
68
- <button class="icon-button" type="button" @click="toggleMute" :aria-label="muteLabel">
69
+ <button m-if="showMuteControl" class="icon-button" type="button" @click="toggleMute" :aria-label="muteLabel">
69
70
  <span class="fa-icon icon-volume" aria-hidden="true"></span>
70
71
  </button>
71
- <label class="volume">
72
+ <label m-if="showVolumeControl" class="volume">
72
73
  <span>Volume</span>
73
74
  <input type="range" min="0" max="1" step="0.01" :value="volume" @input="setVolume" />
74
75
  </label>
75
- <span class="clock">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
76
+ <span m-if="showTimeControl" class="clock">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
76
77
  </div>
77
78
 
78
79
  <div class="right-controls">
79
- <button class="pill-button" type="button" @click="cycleRate" aria-label="Change playback speed">1x</button>
80
- <button class="icon-button fullscreen-button" type="button" @click="toggleFullscreen" aria-label="Fullscreen">
80
+ <button m-if="showRateControl" class="pill-button" type="button" @click="cycleRate" aria-label="Change playback speed">1x</button>
81
+ <button m-if="showFullscreenControl" class="icon-button fullscreen-button" type="button" @click="toggleFullscreen" aria-label="Fullscreen">
81
82
  <span class="fa-icon" :class="fullscreenIconClass" aria-hidden="true"></span>
82
83
  </button>
83
84
  </div>
@@ -95,15 +96,31 @@ const {
95
96
  poster = "",
96
97
  title = "Mikuru Video",
97
98
  subtitle = "Original player component",
98
- preload = "metadata"
99
+ preload = "metadata",
100
+ controls,
101
+ live = false
99
102
  } = defineProps({
100
103
  src: String,
101
104
  poster: String,
102
105
  title: String,
103
106
  subtitle: String,
104
- preload: String
107
+ preload: String,
108
+ controls: Array,
109
+ live: Boolean
105
110
  });
106
111
 
112
+ const emit = defineEmits([
113
+ "loadedmetadata",
114
+ "timeupdate",
115
+ "durationchange",
116
+ "play",
117
+ "pause",
118
+ "ended",
119
+ "seeked",
120
+ "volumechange",
121
+ "ratechange"
122
+ ]);
123
+
107
124
  const mediaEl = ref(null);
108
125
  const screenEl = ref(null);
109
126
  const currentTime = ref(0);
@@ -115,6 +132,13 @@ const isFullscreen = ref(false);
115
132
  const isSeeking = ref(false);
116
133
  const pointerInside = ref(false);
117
134
  const controlsVisible = ref(true);
135
+ const allControls = ["play", "seek", "time", "mute", "volume", "rate", "fullscreen"];
136
+ const liveHiddenControls = ["seek", "time", "rate"];
137
+ const activeControls = computed(() => {
138
+ const configuredControls = Array.isArray(controls.value) ? controls.value : allControls;
139
+ return new Set(configuredControls);
140
+ });
141
+ const isLive = computed(() => live.value === true);
118
142
  const safeDuration = computed(() => duration.value > 0 ? duration.value : 0);
119
143
  const seekProgress = computed(() => {
120
144
  if (safeDuration.value <= 0) return 0;
@@ -126,6 +150,24 @@ const playLabel = computed(() => isPlaying.value ? "Pause video" : "Play video")
126
150
  const muteLabel = computed(() => muted.value ? "Unmute video" : "Mute video");
127
151
  const playIconClass = computed(() => isPlaying.value ? "icon-pause" : "icon-play");
128
152
  const fullscreenIconClass = computed(() => isFullscreen.value ? "icon-minimize" : "icon-maximize");
153
+ const statusText = computed(() => isLive.value ? "LIVE" : subtitle.value);
154
+ const showPlayControl = computed(() => hasControl("play"));
155
+ const showSeekControl = computed(() => hasControl("seek"));
156
+ const showTimeControl = computed(() => hasControl("time"));
157
+ const showMuteControl = computed(() => hasControl("mute"));
158
+ const showVolumeControl = computed(() => hasControl("volume"));
159
+ const showRateControl = computed(() => hasControl("rate"));
160
+ const showFullscreenControl = computed(() => hasControl("fullscreen"));
161
+ const showControlShelf = computed(() => (
162
+ showPlayControl.value ||
163
+ showSeekControl.value ||
164
+ showTimeControl.value ||
165
+ showMuteControl.value ||
166
+ showVolumeControl.value ||
167
+ showRateControl.value ||
168
+ showFullscreenControl.value ||
169
+ isLive.value
170
+ ));
129
171
  const rates = [1, 1.25, 1.5, 2, 0.75];
130
172
  let playbackRate = 1;
131
173
  let rateIndex = 0;
@@ -160,6 +202,11 @@ function shouldIgnoreMediaEvent() {
160
202
  return performance.now() < ignoreMediaEventsUntil;
161
203
  }
162
204
 
205
+ function hasControl(name) {
206
+ if (isLive.value && liveHiddenControls.includes(name)) return false;
207
+ return activeControls.value.has(name);
208
+ }
209
+
163
210
  function syncMedia() {
164
211
  if (isDisposed || shouldIgnoreMediaEvent()) return;
165
212
  const media = getMedia();
@@ -168,6 +215,58 @@ function syncMedia() {
168
215
  duration.value = Number.isFinite(media.duration) ? media.duration : 0;
169
216
  }
170
217
 
218
+ function createMediaPayload(event) {
219
+ const media = getMedia();
220
+ if (!media) return null;
221
+ return {
222
+ currentTime: media.currentTime || 0,
223
+ duration: Number.isFinite(media.duration) ? media.duration : 0,
224
+ paused: media.paused,
225
+ ended: media.ended,
226
+ muted: media.muted,
227
+ volume: media.volume,
228
+ playbackRate: media.playbackRate,
229
+ nativeEvent: event
230
+ };
231
+ }
232
+
233
+ function emitMediaPayload(event, dispatch) {
234
+ const payload = createMediaPayload(event);
235
+ if (payload) {
236
+ dispatch(payload);
237
+ }
238
+ }
239
+
240
+ function handleLoadedMetadata(event) {
241
+ syncMedia();
242
+ emitMediaPayload(event, (payload) => emit("loadedmetadata", payload));
243
+ }
244
+
245
+ function handleTimeUpdate(event) {
246
+ syncMedia();
247
+ emitMediaPayload(event, (payload) => emit("timeupdate", payload));
248
+ }
249
+
250
+ function handleDurationChange(event) {
251
+ syncMedia();
252
+ emitMediaPayload(event, (payload) => emit("durationchange", payload));
253
+ }
254
+
255
+ function handleSeeked(event) {
256
+ syncMedia();
257
+ emitMediaPayload(event, (payload) => emit("seeked", payload));
258
+ }
259
+
260
+ function handleVolumeChange(event) {
261
+ syncMedia();
262
+ emitMediaPayload(event, (payload) => emit("volumechange", payload));
263
+ }
264
+
265
+ function handleRateChange(event) {
266
+ syncMedia();
267
+ emitMediaPayload(event, (payload) => emit("ratechange", payload));
268
+ }
269
+
171
270
  function applyAudioSettings() {
172
271
  if (isDisposed) return;
173
272
  const media = getMedia();
@@ -177,16 +276,25 @@ function applyAudioSettings() {
177
276
  media.playbackRate = playbackRate;
178
277
  }
179
278
 
180
- function markPlaying() {
279
+ function markPlaying(event) {
181
280
  if (isDisposed || shouldIgnoreMediaEvent()) return;
182
281
  isPlaying.value = true;
183
282
  controlsVisible.value = pointerInside.value;
283
+ emitMediaPayload(event, (payload) => emit("play", payload));
184
284
  }
185
285
 
186
- function markPaused() {
286
+ function markPaused(event) {
187
287
  if (isDisposed || shouldIgnoreMediaEvent()) return;
188
288
  isPlaying.value = false;
189
289
  controlsVisible.value = true;
290
+ emitMediaPayload(event, (payload) => emit("pause", payload));
291
+ }
292
+
293
+ function markEnded(event) {
294
+ if (isDisposed || shouldIgnoreMediaEvent()) return;
295
+ isPlaying.value = false;
296
+ controlsVisible.value = true;
297
+ emitMediaPayload(event, (payload) => emit("ended", payload));
190
298
  }
191
299
 
192
300
  function updateFullscreen() {
@@ -286,18 +394,6 @@ function seekWithKeyboard(event) {
286
394
  seekTo(nextTime);
287
395
  }
288
396
 
289
- function stopPlayback() {
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);
299
- }
300
-
301
397
  function setVolume(event) {
302
398
  if (isDisposed) return;
303
399
  const nextVolume = Number(event.target.value);
@@ -437,6 +533,20 @@ function formatTime(seconds) {
437
533
  font-size: 0.88rem;
438
534
  }
439
535
 
536
+ .live-badge {
537
+ color: #ffffff;
538
+ font-size: 0.78rem;
539
+ font-weight: 800;
540
+ letter-spacing: 0;
541
+ }
542
+
543
+ .live-badge {
544
+ flex: 0 0 auto;
545
+ padding: 4px 8px;
546
+ border-radius: 999px;
547
+ background: #dc2626;
548
+ }
549
+
440
550
  .center-toggle {
441
551
  position: absolute;
442
552
  top: 50%;
@@ -631,8 +741,7 @@ button:hover {
631
741
  transform: translateX(3px);
632
742
  }
633
743
 
634
- .center-toggle .icon-pause,
635
- .center-toggle .icon-stop {
744
+ .center-toggle .icon-pause {
636
745
  transform: translateX(0);
637
746
  }
638
747
 
@@ -644,10 +753,6 @@ button:hover {
644
753
  mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M176 96C149.5 96 128 117.5 128 144L128 496C128 522.5 149.5 544 176 544L240 544C266.5 544 288 522.5 288 496L288 144C288 117.5 266.5 96 240 96L176 96zM400 96C373.5 96 352 117.5 352 144L352 496C352 522.5 373.5 544 400 544L464 544C490.5 544 512 522.5 512 496L512 144C512 117.5 490.5 96 464 96L400 96z"/></svg>');
645
754
  }
646
755
 
647
- .icon-stop {
648
- mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M160 96L480 96C515.3 96 544 124.7 544 160L544 480C544 515.3 515.3 544 480 544L160 544C124.7 544 96 515.3 96 480L96 160C96 124.7 124.7 96 160 96z"/></svg>');
649
- }
650
-
651
756
  .icon-maximize {
652
757
  mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M264 96L120 96C106.7 96 96 106.7 96 120L96 264C96 273.7 101.8 282.5 110.8 286.2C119.8 289.9 130.1 287.8 137 281L177 241L256 320L177 399L137 359C130.1 352.1 119.8 350.1 110.8 353.8C101.8 357.5 96 366.3 96 376L96 520C96 533.3 106.7 544 120 544L264 544C273.7 544 282.5 538.2 286.2 529.2C289.9 520.2 287.9 509.9 281 503L241 463L320 384L399 463L359 503C352.1 509.9 350.1 520.2 353.8 529.2C357.5 538.2 366.3 544 376 544L520 544C533.3 544 544 533.3 544 520L544 376C544 366.3 538.2 357.5 529.2 353.8C520.2 350.1 509.9 352.1 503 359L463 399L384 320L463 241L503 281C509.9 287.9 520.2 289.9 529.2 286.2C538.2 282.5 544 273.7 544 264L544 120C544 106.7 533.3 96 520 96L376 96C366.3 96 357.5 101.8 353.8 110.8C350.1 119.8 352.2 130.1 359 137L399 177L320 256L241 177L281 137C287.9 130.1 289.9 119.8 286.2 110.8C282.5 101.8 273.7 96 264 96z"/></svg>');
653
758
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikuru",
3
- "version": "1.0.30",
3
+ "version": "1.0.32",
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.",
@@ -1,11 +1,44 @@
1
1
  import type { MikuruComponent } from "../env";
2
2
 
3
+ export type MikuruAudioPlayerEventPayload = {
4
+ currentTime: number;
5
+ duration: number;
6
+ paused: boolean;
7
+ ended: boolean;
8
+ muted: boolean;
9
+ volume: number;
10
+ playbackRate: number;
11
+ nativeEvent?: Event;
12
+ };
13
+
14
+ export type MikuruAudioPlayerEvents = {
15
+ onLoadedmetadata?: (payload: MikuruAudioPlayerEventPayload) => void;
16
+ onTimeupdate?: (payload: MikuruAudioPlayerEventPayload) => void;
17
+ onDurationchange?: (payload: MikuruAudioPlayerEventPayload) => void;
18
+ onPlay?: (payload: MikuruAudioPlayerEventPayload) => void;
19
+ onPause?: (payload: MikuruAudioPlayerEventPayload) => void;
20
+ onEnded?: (payload: MikuruAudioPlayerEventPayload) => void;
21
+ onSeeked?: (payload: MikuruAudioPlayerEventPayload) => void;
22
+ onVolumechange?: (payload: MikuruAudioPlayerEventPayload) => void;
23
+ onRatechange?: (payload: MikuruAudioPlayerEventPayload) => void;
24
+ };
25
+
26
+ export type MikuruAudioPlayerControl =
27
+ | "play"
28
+ | "seek"
29
+ | "time"
30
+ | "skip"
31
+ | "mute"
32
+ | "volume";
33
+
3
34
  export type MikuruAudioPlayerProps = {
4
35
  src: string;
5
36
  title?: string;
6
37
  artist?: string;
7
38
  preload?: string;
8
- };
39
+ controls?: MikuruAudioPlayerControl[];
40
+ live?: boolean;
41
+ } & MikuruAudioPlayerEvents;
9
42
 
10
43
  declare const component: MikuruComponent<MikuruAudioPlayerProps>;
11
44
  export default component;
@@ -5,11 +5,13 @@ export type MikuruToastItem = {
5
5
  title?: string;
6
6
  message?: string;
7
7
  tone?: "info" | "success" | "warning" | "danger" | string;
8
+ duration?: number;
8
9
  };
9
10
 
10
11
  export type MikuruToastProps = {
11
12
  toasts?: MikuruToastItem[];
12
13
  position?: "bottom-right" | "bottom-left" | "top-right" | "top-left" | string;
14
+ duration?: number;
13
15
  };
14
16
 
15
17
  declare const component: MikuruComponent<MikuruToastProps>;
@@ -1,12 +1,46 @@
1
1
  import type { MikuruComponent } from "../env";
2
2
 
3
+ export type MikuruVideoPlayerEventPayload = {
4
+ currentTime: number;
5
+ duration: number;
6
+ paused: boolean;
7
+ ended: boolean;
8
+ muted: boolean;
9
+ volume: number;
10
+ playbackRate: number;
11
+ nativeEvent?: Event;
12
+ };
13
+
14
+ export type MikuruVideoPlayerEvents = {
15
+ onLoadedmetadata?: (payload: MikuruVideoPlayerEventPayload) => void;
16
+ onTimeupdate?: (payload: MikuruVideoPlayerEventPayload) => void;
17
+ onDurationchange?: (payload: MikuruVideoPlayerEventPayload) => void;
18
+ onPlay?: (payload: MikuruVideoPlayerEventPayload) => void;
19
+ onPause?: (payload: MikuruVideoPlayerEventPayload) => void;
20
+ onEnded?: (payload: MikuruVideoPlayerEventPayload) => void;
21
+ onSeeked?: (payload: MikuruVideoPlayerEventPayload) => void;
22
+ onVolumechange?: (payload: MikuruVideoPlayerEventPayload) => void;
23
+ onRatechange?: (payload: MikuruVideoPlayerEventPayload) => void;
24
+ };
25
+
26
+ export type MikuruVideoPlayerControl =
27
+ | "play"
28
+ | "seek"
29
+ | "time"
30
+ | "mute"
31
+ | "volume"
32
+ | "rate"
33
+ | "fullscreen";
34
+
3
35
  export type MikuruVideoPlayerProps = {
4
36
  src: string;
5
37
  poster?: string;
6
38
  title?: string;
7
39
  subtitle?: string;
8
40
  preload?: string;
9
- };
41
+ controls?: MikuruVideoPlayerControl[];
42
+ live?: boolean;
43
+ } & MikuruVideoPlayerEvents;
10
44
 
11
45
  declare const component: MikuruComponent<MikuruVideoPlayerProps>;
12
46
  export default component;