mikuru 1.0.32 → 1.0.33

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.33 - 2026-05-16
4
+
5
+ - Added `MikuruVideoPlayer` sizing props for width, height, and aspect ratio.
6
+ - Added a `MikuruVideoPlayer` settings menu for quality selection, playback speed, and keyboard skip seconds.
7
+
3
8
  ## 1.0.32 - 2026-05-16
4
9
 
5
10
  - Added `controls` and `live` props to `MikuruVideoPlayer` and `MikuruAudioPlayer` so callers can choose visible controls and render live-stream UI without seek controls.
package/README.md CHANGED
@@ -270,7 +270,7 @@ 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, configurable control visibility, live mode, div-based seeking, volume/mute, playback rate, and fullscreen controls.
273
+ - `MikuruVideoPlayer.mikuru`: overlay video controls, configurable sizing, configurable control visibility, live mode, settings menu, div-based seeking, volume/mute, playback rate, and fullscreen controls.
274
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.
@@ -2,6 +2,7 @@
2
2
  <section
3
3
  class="mikuru-video"
4
4
  :class="{ 'is-playing': isPlaying, 'controls-visible': controlsVisible }"
5
+ :style="playerStyle"
5
6
  :aria-label="title"
6
7
  @mouseenter="showControls"
7
8
  @mouseleave="hideControls"
@@ -9,12 +10,12 @@
9
10
  @pointerleave="hideControls"
10
11
  @focusin="showControls"
11
12
  >
12
- <div class="screen" ref="screenEl">
13
+ <div class="screen" ref="screenEl" :style="screenStyle">
13
14
  <video
14
15
  ref="mediaEl"
15
16
  class="media"
16
- :src="src"
17
- :poster="poster"
17
+ :src="selectedVideoSrc"
18
+ :poster="selectedPoster"
18
19
  :preload="preload"
19
20
  playsinline
20
21
  @loadedmetadata="handleLoadedMetadata"
@@ -77,7 +78,61 @@
77
78
  </div>
78
79
 
79
80
  <div class="right-controls">
80
- <button m-if="showRateControl" class="pill-button" type="button" @click="cycleRate" aria-label="Change playback speed">1x</button>
81
+ <div m-if="showSettingsControl" class="settings" ref="settingsEl" @click="stopEvent">
82
+ <button
83
+ class="icon-button settings-button"
84
+ type="button"
85
+ @click="toggleSettings"
86
+ aria-label="Video settings"
87
+ :aria-expanded="settingsOpen"
88
+ >
89
+ <span class="fa-icon icon-settings" aria-hidden="true"></span>
90
+ </button>
91
+
92
+ <div m-if="settingsOpen" class="settings-menu" role="menu" aria-label="Video settings">
93
+ <div class="settings-group">
94
+ <span class="settings-label">Quality</span>
95
+ <button
96
+ m-for="quality in normalizedQualityOptions"
97
+ :key="quality.id"
98
+ type="button"
99
+ class="settings-option"
100
+ :class="{ active: quality.id === activeQualityId }"
101
+ @click="selectQuality(quality.id)"
102
+ >
103
+ {{ quality.label }}
104
+ </button>
105
+ </div>
106
+
107
+ <div class="settings-group">
108
+ <span class="settings-label">Speed</span>
109
+ <button
110
+ m-for="rate in rateOptions"
111
+ :key="rate.value"
112
+ type="button"
113
+ class="settings-option"
114
+ :class="{ active: rate.value === playbackRateValue }"
115
+ @click="setPlaybackRate(rate.value)"
116
+ >
117
+ {{ rate.label }}
118
+ </button>
119
+ </div>
120
+
121
+ <div class="settings-group">
122
+ <span class="settings-label">Skip</span>
123
+ <button
124
+ m-for="option in skipOptions"
125
+ :key="option.value"
126
+ type="button"
127
+ class="settings-option"
128
+ :class="{ active: option.value === skipSeconds }"
129
+ @click="setSkipSeconds(option.value)"
130
+ >
131
+ {{ option.label }}
132
+ </button>
133
+ </div>
134
+ </div>
135
+ </div>
81
136
  <button m-if="showFullscreenControl" class="icon-button fullscreen-button" type="button" @click="toggleFullscreen" aria-label="Fullscreen">
82
137
  <span class="fa-icon" :class="fullscreenIconClass" aria-hidden="true"></span>
83
138
  </button>
@@ -97,6 +152,10 @@ const {
97
152
  title = "Mikuru Video",
98
153
  subtitle = "Original player component",
99
154
  preload = "metadata",
155
+ width,
156
+ height,
157
+ aspectRatio,
158
+ qualityOptions = [],
100
159
  controls,
101
160
  live = false
102
161
  } = defineProps({
@@ -105,6 +164,10 @@ const {
105
164
  title: String,
106
165
  subtitle: String,
107
166
  preload: String,
167
+ width: String,
168
+ height: String,
169
+ aspectRatio: String,
170
+ qualityOptions: Array,
108
171
  controls: Array,
109
172
  live: Boolean
110
173
  });
@@ -130,14 +193,46 @@ const muted = ref(false);
130
193
  const isPlaying = ref(false);
131
194
  const isFullscreen = ref(false);
132
195
  const isSeeking = ref(false);
196
+ const settingsOpen = ref(false);
197
+ const selectedQualityId = ref("auto");
198
+ const playbackRateValue = ref(1);
199
+ const skipSeconds = ref(5);
133
200
  const pointerInside = ref(false);
134
201
  const controlsVisible = ref(true);
135
- const allControls = ["play", "seek", "time", "mute", "volume", "rate", "fullscreen"];
136
- const liveHiddenControls = ["seek", "time", "rate"];
202
+ const settingsEl = ref(null);
203
+ const allControls = ["play", "seek", "time", "mute", "volume", "settings", "fullscreen"];
204
+ const liveHiddenControls = ["seek", "time"];
137
205
  const activeControls = computed(() => {
138
206
  const configuredControls = Array.isArray(controls.value) ? controls.value : allControls;
139
207
  return new Set(configuredControls);
140
208
  });
209
+ const normalizedQualityOptions = computed(() => {
210
+ const source = Array.isArray(qualityOptions.value) ? qualityOptions.value : [];
211
+ const options = source
212
+ .map((option, index) => normalizeQualityOption(option, index))
213
+ .filter(Boolean);
214
+ return options.length > 0
215
+ ? options
216
+ : [{ id: "auto", label: "Auto", src: src.value, poster: poster.value }];
217
+ });
218
+ const selectedQuality = computed(() =>
219
+ normalizedQualityOptions.value.find((option) => option.id === selectedQualityId.value) ?? normalizedQualityOptions.value[0]
220
+ );
221
+ const activeQualityId = computed(() => selectedQuality.value?.id);
222
+ const selectedVideoSrc = computed(() => selectedQuality.value?.src || src.value);
223
+ const selectedPoster = computed(() => selectedQuality.value?.poster ?? poster.value);
224
+ const playerStyle = computed(() => {
225
+ const normalizedWidth = normalizeCssSize(width.value);
226
+ return normalizedWidth ? { width: normalizedWidth } : null;
227
+ });
228
+ const screenStyle = computed(() => {
229
+ const normalizedHeight = normalizeCssSize(height.value);
230
+ const normalizedAspectRatio = normalizeAspectRatio(aspectRatio.value);
231
+ return {
232
+ height: normalizedHeight || null,
233
+ aspectRatio: normalizedAspectRatio || null
234
+ };
235
+ });
141
236
  const isLive = computed(() => live.value === true);
142
237
  const safeDuration = computed(() => duration.value > 0 ? duration.value : 0);
143
238
  const seekProgress = computed(() => {
@@ -151,12 +246,25 @@ const muteLabel = computed(() => muted.value ? "Unmute video" : "Mute video");
151
246
  const playIconClass = computed(() => isPlaying.value ? "icon-pause" : "icon-play");
152
247
  const fullscreenIconClass = computed(() => isFullscreen.value ? "icon-minimize" : "icon-maximize");
153
248
  const statusText = computed(() => isLive.value ? "LIVE" : subtitle.value);
249
+ const rateOptions = [
250
+ { value: 0.75, label: "0.75x" },
251
+ { value: 1, label: "1x" },
252
+ { value: 1.25, label: "1.25x" },
253
+ { value: 1.5, label: "1.5x" },
254
+ { value: 2, label: "2x" }
255
+ ];
256
+ const skipOptions = [
257
+ { value: 5, label: "5s" },
258
+ { value: 10, label: "10s" },
259
+ { value: 15, label: "15s" },
260
+ { value: 30, label: "30s" }
261
+ ];
154
262
  const showPlayControl = computed(() => hasControl("play"));
155
263
  const showSeekControl = computed(() => hasControl("seek"));
156
264
  const showTimeControl = computed(() => hasControl("time"));
157
265
  const showMuteControl = computed(() => hasControl("mute"));
158
266
  const showVolumeControl = computed(() => hasControl("volume"));
159
- const showRateControl = computed(() => hasControl("rate"));
267
+ const showSettingsControl = computed(() => hasControl("settings") || hasControl("rate"));
160
268
  const showFullscreenControl = computed(() => hasControl("fullscreen"));
161
269
  const showControlShelf = computed(() => (
162
270
  showPlayControl.value ||
@@ -164,19 +272,17 @@ const showControlShelf = computed(() => (
164
272
  showTimeControl.value ||
165
273
  showMuteControl.value ||
166
274
  showVolumeControl.value ||
167
- showRateControl.value ||
275
+ showSettingsControl.value ||
168
276
  showFullscreenControl.value ||
169
277
  isLive.value
170
278
  ));
171
- const rates = [1, 1.25, 1.5, 2, 0.75];
172
- let playbackRate = 1;
173
- let rateIndex = 0;
174
279
  let isDisposed = false;
175
280
  let ignoreMediaEventsUntil = 0;
176
281
 
177
282
  onMounted(() => {
178
283
  applyAudioSettings();
179
284
  document.addEventListener("fullscreenchange", updateFullscreen);
285
+ document.addEventListener("pointerdown", handleDocumentPointerDown);
180
286
  });
181
287
 
182
288
  onBeforeUnmount(() => {
@@ -187,6 +293,7 @@ onBeforeUnmount(() => {
187
293
 
188
294
  onUnmounted(() => {
189
295
  document.removeEventListener("fullscreenchange", updateFullscreen);
296
+ document.removeEventListener("pointerdown", handleDocumentPointerDown);
190
297
  });
191
298
 
192
299
  function getMedia() {
@@ -202,6 +309,41 @@ function shouldIgnoreMediaEvent() {
202
309
  return performance.now() < ignoreMediaEventsUntil;
203
310
  }
204
311
 
312
+ function normalizeQualityOption(option, index) {
313
+ if (typeof option === "string") {
314
+ return { id: option || `quality-${index}`, label: option || `Quality ${index + 1}`, src: src.value, poster: poster.value };
315
+ }
316
+ if (!option || typeof option !== "object") {
317
+ return null;
318
+ }
319
+ const optionSrc = typeof option.src === "string" && option.src ? option.src : src.value;
320
+ const label = typeof option.label === "string" && option.label ? option.label : `Quality ${index + 1}`;
321
+ return {
322
+ id: typeof option.id === "string" || typeof option.id === "number" ? String(option.id) : `${label}-${index}`,
323
+ label,
324
+ src: optionSrc,
325
+ poster: typeof option.poster === "string" ? option.poster : poster.value
326
+ };
327
+ }
328
+
329
+ function normalizeCssSize(value) {
330
+ if (typeof value === "number" && Number.isFinite(value)) {
331
+ return value >= 0 ? value + "px" : "";
332
+ }
333
+ if (typeof value !== "string") return "";
334
+ const trimmed = value.trim();
335
+ if (!trimmed) return "";
336
+ return /^\d+(\.\d+)?$/.test(trimmed) ? trimmed + "px" : trimmed;
337
+ }
338
+
339
+ function normalizeAspectRatio(value) {
340
+ if (typeof value === "number" && Number.isFinite(value)) {
341
+ return value > 0 ? String(value) : "";
342
+ }
343
+ if (typeof value !== "string") return "";
344
+ return value.trim();
345
+ }
346
+
205
347
  function hasControl(name) {
206
348
  if (isLive.value && liveHiddenControls.includes(name)) return false;
207
349
  return activeControls.value.has(name);
@@ -273,7 +415,7 @@ function applyAudioSettings() {
273
415
  if (!media) return;
274
416
  media.volume = volume.value;
275
417
  media.muted = muted.value;
276
- media.playbackRate = playbackRate;
418
+ media.playbackRate = playbackRateValue.value;
277
419
  }
278
420
 
279
421
  function markPlaying(event) {
@@ -389,7 +531,7 @@ function seekWithKeyboard(event) {
389
531
  return;
390
532
  }
391
533
 
392
- const offset = event.key === "ArrowRight" ? 5 : -5;
534
+ const offset = event.key === "ArrowRight" ? skipSeconds.value : -skipSeconds.value;
393
535
  const nextTime = Math.min(Math.max(currentTime.value + offset, 0), safeDuration.value);
394
536
  seekTo(nextTime);
395
537
  }
@@ -421,21 +563,68 @@ function toggleMute(event) {
421
563
  }, 0);
422
564
  }
423
565
 
424
- function cycleRate(event) {
566
+ function toggleSettings() {
425
567
  if (isDisposed) return;
426
- const button = event.currentTarget;
568
+ settingsOpen.value = !settingsOpen.value;
569
+ controlsVisible.value = true;
570
+ }
571
+
572
+ function selectQuality(id) {
573
+ if (isDisposed) return;
574
+ const media = getMedia();
575
+ const wasPlaying = media ? !media.paused : false;
576
+ const previousTime = media?.currentTime || 0;
577
+ selectedQualityId.value = id;
578
+ settingsOpen.value = false;
579
+ if (!media) return;
427
580
  window.setTimeout(() => {
428
581
  if (isDisposed) return;
429
- rateIndex = (rateIndex + 1) % rates.length;
430
- playbackRate = rates[rateIndex];
431
- const media = getMedia();
432
- if (media) {
433
- media.playbackRate = playbackRate;
582
+ const nextMedia = getMedia();
583
+ if (!nextMedia) return;
584
+ if (Number.isFinite(previousTime) && previousTime > 0 && safeDuration.value > 0) {
585
+ nextMedia.currentTime = Math.min(previousTime, safeDuration.value);
586
+ currentTime.value = nextMedia.currentTime;
434
587
  }
435
- button.textContent = playbackRate + "x";
588
+ nextMedia.playbackRate = playbackRateValue.value;
589
+ if (wasPlaying) {
590
+ nextMedia.play().catch((error) => {
591
+ if (error?.name !== "AbortError") {
592
+ throw error;
593
+ }
594
+ });
595
+ }
596
+ }, 0);
597
+ }
598
+
599
+ function setPlaybackRate(nextRate) {
600
+ if (isDisposed) return;
601
+ playbackRateValue.value = nextRate;
602
+ settingsOpen.value = false;
603
+ window.setTimeout(() => {
604
+ if (isDisposed) return;
605
+ const media = getMedia();
606
+ if (!media) return;
607
+ media.playbackRate = nextRate;
436
608
  }, 0);
437
609
  }
438
610
 
611
+ function setSkipSeconds(nextSeconds) {
612
+ if (isDisposed) return;
613
+ skipSeconds.value = nextSeconds;
614
+ settingsOpen.value = false;
615
+ }
616
+
617
+ function handleDocumentPointerDown(event) {
618
+ if (isDisposed || !settingsOpen.value) return;
619
+ const menu = settingsEl.value;
620
+ if (menu?.contains?.(event.target)) return;
621
+ settingsOpen.value = false;
622
+ }
623
+
624
+ function stopEvent(event) {
625
+ event.stopPropagation();
626
+ }
627
+
439
628
  function toggleFullscreen() {
440
629
  if (isDisposed) return;
441
630
  if (document.fullscreenElement) {
@@ -696,6 +885,53 @@ function formatTime(seconds) {
696
885
  font-size: 0.84rem;
697
886
  }
698
887
 
888
+ .settings {
889
+ position: relative;
890
+ }
891
+
892
+ .settings-menu {
893
+ position: absolute;
894
+ right: 0;
895
+ bottom: calc(100% + 10px);
896
+ z-index: 4;
897
+ display: grid;
898
+ gap: 12px;
899
+ width: min(240px, calc(100vw - 32px));
900
+ padding: 12px;
901
+ border: 1px solid rgba(255, 255, 255, 0.16);
902
+ border-radius: 8px;
903
+ color: #ffffff;
904
+ background: rgba(3, 7, 18, 0.94);
905
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.36);
906
+ }
907
+
908
+ .settings-group {
909
+ display: grid;
910
+ grid-template-columns: repeat(3, minmax(0, 1fr));
911
+ gap: 6px;
912
+ }
913
+
914
+ .settings-label {
915
+ grid-column: 1 / -1;
916
+ color: rgba(255, 255, 255, 0.72);
917
+ font-size: 0.78rem;
918
+ font-weight: 700;
919
+ }
920
+
921
+ .settings-option {
922
+ min-height: 30px;
923
+ padding: 0 8px;
924
+ border-radius: 6px;
925
+ font-size: 0.8rem;
926
+ white-space: nowrap;
927
+ }
928
+
929
+ .settings-option.active {
930
+ border-color: #ef4444;
931
+ color: #ffffff;
932
+ background: #ef4444;
933
+ }
934
+
699
935
  button {
700
936
  min-height: 36px;
701
937
  padding: 0 12px;
@@ -769,6 +1005,10 @@ button:hover {
769
1005
  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>');
770
1006
  }
771
1007
 
1008
+ .icon-settings {
1009
+ mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M259.1 73.5C262.1 59 274.9 48 290 48L350 48C365.1 48 377.9 59 380.9 73.5L389.8 116.8C404.9 122.5 419.2 130 432.5 139.1L474.3 125.1C488.6 120.3 504.4 126.1 512 139.1L542 190.9C549.6 203.9 546.8 220.5 535.5 230.5L502.7 259.8C504 267.7 504.7 275.8 504.7 284C504.7 292.2 504 300.3 502.7 308.2L535.5 337.5C546.8 347.5 549.6 364.1 542 377.1L512 428.9C504.4 441.9 488.6 447.7 474.3 442.9L432.5 428.9C419.2 438 404.9 445.5 389.8 451.2L380.9 494.5C377.9 509 365.1 520 350 520L290 520C274.9 520 262.1 509 259.1 494.5L250.2 451.2C235.1 445.5 220.8 438 207.5 428.9L165.7 442.9C151.4 447.7 135.6 441.9 128 428.9L98 377.1C90.4 364.1 93.2 347.5 104.5 337.5L137.3 308.2C136 300.3 135.3 292.2 135.3 284C135.3 275.8 136 267.7 137.3 259.8L104.5 230.5C93.2 220.5 90.4 203.9 98 190.9L128 139.1C135.6 126.1 151.4 120.3 165.7 125.1L207.5 139.1C220.8 130 235.1 122.5 250.2 116.8L259.1 73.5zM320 356C359.8 356 392 323.8 392 284C392 244.2 359.8 212 320 212C280.2 212 248 244.2 248 284C248 323.8 280.2 356 320 356z"/></svg>');
1010
+ }
1011
+
772
1012
  @media (max-width: 620px) {
773
1013
  .top-bar {
774
1014
  align-items: flex-start;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikuru",
3
- "version": "1.0.32",
3
+ "version": "1.0.33",
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.",
@@ -30,14 +30,26 @@ export type MikuruVideoPlayerControl =
30
30
  | "mute"
31
31
  | "volume"
32
32
  | "rate"
33
+ | "settings"
33
34
  | "fullscreen";
34
35
 
36
+ export type MikuruVideoPlayerQualityOption = {
37
+ id?: string | number;
38
+ label: string;
39
+ src: string;
40
+ poster?: string;
41
+ };
42
+
35
43
  export type MikuruVideoPlayerProps = {
36
44
  src: string;
37
45
  poster?: string;
38
46
  title?: string;
39
47
  subtitle?: string;
40
48
  preload?: string;
49
+ width?: string | number;
50
+ height?: string | number;
51
+ aspectRatio?: string | number;
52
+ qualityOptions?: MikuruVideoPlayerQualityOption[];
41
53
  controls?: MikuruVideoPlayerControl[];
42
54
  live?: boolean;
43
55
  } & MikuruVideoPlayerEvents;