mikuru 1.0.32 → 1.0.34

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.34 - 2026-05-16
4
+
5
+ - Stabilized package component internals so repeated mounts and parent rerenders no longer recreate equivalent derived arrays, Sets, or style objects unnecessarily.
6
+ - Hardened `MikuruCarousel`, `MikuruDropdown`, `MikuruToast`, `MikuruCodeBlock`, and `MikuruVideoPlayer` against recursive update loops when parents pass freshly-created array props with unchanged contents.
7
+ - Switched package component style bindings in `MikuruImageViewer`, `MikuruProgress`, and `MikuruVideoPlayer` to stable string styles to avoid avoidable reactive object churn.
8
+ - Updated `MikuruAudioPlayer` timeline class derivation to return stable class strings.
9
+
10
+ ## 1.0.33 - 2026-05-16
11
+
12
+ - Added `MikuruVideoPlayer` sizing props for width, height, and aspect ratio.
13
+ - Added a `MikuruVideoPlayer` settings menu for quality selection, playback speed, and keyboard skip seconds.
14
+
3
15
  ## 1.0.32 - 2026-05-16
4
16
 
5
17
  - 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.
@@ -111,10 +111,11 @@ const showSkipControl = computed(() => hasControl("skip"));
111
111
  const showMuteControl = computed(() => hasControl("mute"));
112
112
  const showVolumeControl = computed(() => hasControl("volume"));
113
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
- }));
114
+ const timelineClass = computed(() => {
115
+ if (showSeekControl.value && !showTimeControl.value) return "timeline-seek-only";
116
+ if (showTimeControl.value && !showSeekControl.value) return "timeline-time-only";
117
+ return "";
118
+ });
118
119
  const showControls = computed(() => (
119
120
  showPlayControl.value ||
120
121
  showSkipControl.value ||
@@ -4,7 +4,7 @@
4
4
  <div class="carousel-track" :style="trackStyle">
5
5
  <article
6
6
  class="carousel-slide"
7
- m-for="slide in normalizedSlides"
7
+ m-for="slide in slides"
8
8
  :key="slide.id"
9
9
  :aria-label="slide.label"
10
10
  >
@@ -33,7 +33,7 @@
33
33
  <span>{{ positionLabel }}</span>
34
34
  <div class="carousel-dots" role="tablist" aria-label="Carousel slides">
35
35
  <button
36
- m-for="slide in normalizedSlides"
36
+ m-for="slide in slides"
37
37
  :key="slide.id"
38
38
  type="button"
39
39
  :class="{ active: slide.index === activeIndex }"
@@ -46,7 +46,7 @@
46
46
  </template>
47
47
 
48
48
  <script>
49
- import { computed, onMounted, onUnmounted, ref } from "mikuru";
49
+ import { computed, onMounted, onUnmounted, ref, watch } from "mikuru";
50
50
 
51
51
  const {
52
52
  images = [],
@@ -65,11 +65,24 @@ const {
65
65
  });
66
66
 
67
67
  const activeIndex = ref(0);
68
+ const slides = ref([]);
69
+ let slidesSignature = "";
68
70
  let timer = null;
71
+ let mounted = false;
69
72
 
70
- const normalizedSlides = computed(() => {
73
+ const slideCount = computed(() => slides.value.length);
74
+ const isEmpty = computed(() => slideCount.value === 0);
75
+ const trackStyle = computed(() => `transform: translateX(-${activeIndex.value * 100}%)`);
76
+ const positionLabel = computed(() => {
77
+ if (slideCount.value === 0) return "0 / 0";
78
+ return `${activeIndex.value + 1} / ${slideCount.value}`;
79
+ });
80
+
81
+ watch(images, syncSlides, { immediate: true });
82
+
83
+ function syncSlides() {
71
84
  const source = Array.isArray(images.value) ? images.value : [];
72
- return source.map((item, index) => {
85
+ const nextSlides = source.map((item, index) => {
73
86
  const src = typeof item === "string" ? item : item.src;
74
87
  const alt = typeof item === "string" ? "" : item.alt || item.title || `Slide ${index + 1}`;
75
88
  const slideTitle = typeof item === "string" ? `Slide ${index + 1}` : item.title || `Slide ${index + 1}`;
@@ -84,22 +97,25 @@ const normalizedSlides = computed(() => {
84
97
  label: `${slideTitle}, ${index + 1} of ${source.length}`
85
98
  };
86
99
  });
87
- });
88
- const slideCount = computed(() => normalizedSlides.value.length);
89
- const isEmpty = computed(() => slideCount.value === 0);
90
- const trackStyle = computed(() => ({
91
- transform: `translateX(-${activeIndex.value * 100}%)`
92
- }));
93
- const positionLabel = computed(() => {
94
- if (slideCount.value === 0) return "0 / 0";
95
- return `${activeIndex.value + 1} / ${slideCount.value}`;
96
- });
100
+ const nextSignature = nextSlides
101
+ .map((slide) => `${slide.id}\u0000${slide.alt}\u0000${slide.title}\u0000${slide.caption}`)
102
+ .join("\u0001");
103
+ if (nextSignature === slidesSignature) return;
104
+ slidesSignature = nextSignature;
105
+ slides.value = nextSlides;
106
+ activeIndex.value = clampIndex(activeIndex.value);
107
+ if (mounted) {
108
+ startAutoplay();
109
+ }
110
+ }
97
111
 
98
112
  onMounted(() => {
113
+ mounted = true;
99
114
  startAutoplay();
100
115
  });
101
116
 
102
117
  onUnmounted(() => {
118
+ mounted = false;
103
119
  stopAutoplay();
104
120
  });
105
121
 
@@ -10,7 +10,7 @@
10
10
  </template>
11
11
 
12
12
  <script>
13
- import { computed, ref } from "mikuru";
13
+ import { computed, ref, watch } from "mikuru";
14
14
 
15
15
  const {
16
16
  code = "",
@@ -23,12 +23,16 @@ const {
23
23
  });
24
24
 
25
25
  const copied = ref(false);
26
+ const lines = ref([]);
26
27
  const languageLabel = computed(() => language.value || "text");
27
28
  const copyLabel = computed(() => copied.value ? "Copied" : "Copy");
28
- const lines = computed(() => code.value.split("\n").map((text, index) => ({
29
- number: index + 1,
30
- text
31
- })));
29
+
30
+ watch(code, () => {
31
+ lines.value = code.value.split("\n").map((text, index) => ({
32
+ number: index + 1,
33
+ text
34
+ }));
35
+ }, { immediate: true });
32
36
 
33
37
  async function copyCode() {
34
38
  if (!navigator.clipboard) return;
@@ -29,7 +29,7 @@
29
29
  </template>
30
30
 
31
31
  <script>
32
- import { computed, onMounted, onUnmounted, ref } from "mikuru";
32
+ import { onMounted, onUnmounted, ref, watch } from "mikuru";
33
33
 
34
34
  const {
35
35
  label = "Menu",
@@ -42,9 +42,14 @@ const {
42
42
  const emit = defineEmits(["select"]);
43
43
  const rootEl = ref(null);
44
44
  const isOpen = ref(false);
45
- const normalizedItems = computed(() => {
45
+ const normalizedItems = ref([]);
46
+ let itemsSignature = "";
47
+
48
+ watch(items, syncItems, { immediate: true });
49
+
50
+ function syncItems() {
46
51
  const source = Array.isArray(items.value) ? items.value : [];
47
- return source.map((item, index) => {
52
+ const nextItems = source.map((item, index) => {
48
53
  if (typeof item === "string") {
49
54
  return { label: item, value: item, description: "", disabled: false };
50
55
  }
@@ -55,7 +60,13 @@ const normalizedItems = computed(() => {
55
60
  disabled: Boolean(item.disabled)
56
61
  };
57
62
  });
58
- });
63
+ const nextSignature = nextItems
64
+ .map((item) => `${item.value}\u0000${item.label}\u0000${item.description}\u0000${item.disabled}`)
65
+ .join("\u0001");
66
+ if (nextSignature === itemsSignature) return;
67
+ itemsSignature = nextSignature;
68
+ normalizedItems.value = nextItems;
69
+ }
59
70
 
60
71
  onMounted(() => {
61
72
  document.addEventListener("pointerdown", handleDocumentPointer);
@@ -82,9 +82,7 @@ const label = computed(() => caption.value || alt.value);
82
82
  const captionText = computed(() => caption.value || alt.value);
83
83
  const zoomLabel = computed(() => `${Math.round(zoom.value * 100)}%`);
84
84
  const fullscreenLabel = computed(() => isFullscreen.value ? "Exit fullscreen" : "Enter fullscreen");
85
- const imageStyle = computed(() => ({
86
- transform: `translate(${offsetX.value}px, ${offsetY.value}px) scale(${zoom.value}) rotate(${rotation.value}deg)`
87
- }));
85
+ const imageStyle = computed(() => `transform: translate(${offsetX.value}px, ${offsetY.value}px) scale(${zoom.value}) rotate(${rotation.value}deg)`);
88
86
 
89
87
  onMounted(() => {
90
88
  document.addEventListener("fullscreenchange", syncFullscreen);
@@ -36,9 +36,7 @@ const safeMax = computed(() => max.value > 0 ? max.value : 100);
36
36
  const clampedValue = computed(() => Math.min(Math.max(value.value, 0), safeMax.value));
37
37
  const percent = computed(() => Math.round((clampedValue.value / safeMax.value) * 100));
38
38
  const percentLabel = computed(() => `${percent.value}%`);
39
- const fillStyle = computed(() => ({
40
- width: indeterminate.value ? "45%" : `${percent.value}%`
41
- }));
39
+ const fillStyle = computed(() => `width: ${indeterminate.value ? "45%" : `${percent.value}%`}`);
42
40
  </script>
43
41
 
44
42
  <style scoped>
@@ -17,7 +17,7 @@
17
17
  </template>
18
18
 
19
19
  <script>
20
- import { computed, onMounted, onUnmounted, watch } from "mikuru";
20
+ import { computed, onMounted, onUnmounted, ref, watch } from "mikuru";
21
21
 
22
22
  const {
23
23
  toasts = [],
@@ -32,9 +32,14 @@ const {
32
32
  const emit = defineEmits(["dismiss"]);
33
33
  const timers = new Map();
34
34
  const positionClass = computed(() => `position-${position.value}`);
35
- const normalizedToasts = computed(() => {
35
+ const normalizedToasts = ref([]);
36
+ let toastsSignature = "";
37
+
38
+ watch([toasts, duration], syncToasts, { immediate: true });
39
+
40
+ function syncToasts() {
36
41
  const source = Array.isArray(toasts.value) ? toasts.value : [];
37
- return source.map((toast, index) => {
42
+ const nextToasts = source.map((toast, index) => {
38
43
  const id = toast.id ?? index;
39
44
  const tone = toast.tone || "info";
40
45
  const toastDuration = typeof toast.duration === "number" ? toast.duration : duration.value;
@@ -47,7 +52,14 @@ const normalizedToasts = computed(() => {
47
52
  duration: toastDuration
48
53
  };
49
54
  });
50
- });
55
+ const nextSignature = nextToasts
56
+ .map((toast) => `${toast.id}\u0000${toast.title}\u0000${toast.message}\u0000${toast.toneClass}\u0000${toast.duration}`)
57
+ .join("\u0001");
58
+ if (nextSignature === toastsSignature) return;
59
+ toastsSignature = nextSignature;
60
+ normalizedToasts.value = nextToasts;
61
+ syncToastTimers();
62
+ }
51
63
 
52
64
  onMounted(() => {
53
65
  syncToastTimers();
@@ -57,10 +69,6 @@ onUnmounted(() => {
57
69
  clearToastTimers();
58
70
  });
59
71
 
60
- watch(toasts, () => {
61
- syncToastTimers();
62
- });
63
-
64
72
  function dismissToast(id) {
65
73
  clearToastTimer(id);
66
74
  emit("dismiss", id);
@@ -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>
@@ -89,7 +144,7 @@
89
144
  </template>
90
145
 
91
146
  <script>
92
- import { computed, onBeforeUnmount, onMounted, onUnmounted, ref } from "mikuru";
147
+ import { computed, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from "mikuru";
93
148
 
94
149
  const {
95
150
  src,
@@ -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,13 +193,66 @@ 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"];
137
- const activeControls = computed(() => {
202
+ const settingsEl = ref(null);
203
+ const allControls = ["play", "seek", "time", "mute", "volume", "settings", "fullscreen"];
204
+ const liveHiddenControls = ["seek", "time"];
205
+ const activeControls = ref(new Set(allControls));
206
+ const normalizedQualityOptions = ref([]);
207
+ let controlsSignature = "";
208
+ let qualityOptionsSignature = "";
209
+
210
+ watch(controls, syncControls, { immediate: true });
211
+ watch([qualityOptions, src, poster], syncQualityOptions, { immediate: true });
212
+
213
+ function syncControls() {
138
214
  const configuredControls = Array.isArray(controls.value) ? controls.value : allControls;
139
- return new Set(configuredControls);
215
+ const nextSignature = configuredControls.join("\u0001");
216
+ if (nextSignature === controlsSignature) return;
217
+ controlsSignature = nextSignature;
218
+ activeControls.value = new Set(configuredControls);
219
+ }
220
+
221
+ function syncQualityOptions() {
222
+ const source = Array.isArray(qualityOptions.value) ? qualityOptions.value : [];
223
+ const options = source
224
+ .map((option, index) => normalizeQualityOption(option, index))
225
+ .filter(Boolean);
226
+ const nextOptions = options.length > 0
227
+ ? options
228
+ : [{ id: "auto", label: "Auto", src: src.value, poster: poster.value }];
229
+ const nextSignature = nextOptions
230
+ .map((option) => `${option.id}\u0000${option.label}\u0000${option.src}\u0000${option.poster}`)
231
+ .join("\u0001");
232
+ if (nextSignature === qualityOptionsSignature) return;
233
+ qualityOptionsSignature = nextSignature;
234
+ normalizedQualityOptions.value = nextOptions;
235
+ if (!nextOptions.some((option) => option.id === selectedQualityId.value)) {
236
+ selectedQualityId.value = nextOptions[0]?.id || "auto";
237
+ }
238
+ }
239
+ const selectedQuality = computed(() =>
240
+ normalizedQualityOptions.value.find((option) => option.id === selectedQualityId.value) ?? normalizedQualityOptions.value[0]
241
+ );
242
+ const activeQualityId = computed(() => selectedQuality.value?.id);
243
+ const selectedVideoSrc = computed(() => selectedQuality.value?.src || src.value);
244
+ const selectedPoster = computed(() => selectedQuality.value?.poster ?? poster.value);
245
+ const playerStyle = computed(() => {
246
+ const normalizedWidth = normalizeCssSize(width.value);
247
+ return normalizedWidth ? `width: ${normalizedWidth}` : "";
248
+ });
249
+ const screenStyle = computed(() => {
250
+ const normalizedHeight = normalizeCssSize(height.value);
251
+ const normalizedAspectRatio = normalizeAspectRatio(aspectRatio.value);
252
+ const declarations = [];
253
+ if (normalizedHeight) declarations.push(`height: ${normalizedHeight}`);
254
+ if (normalizedAspectRatio) declarations.push(`aspect-ratio: ${normalizedAspectRatio}`);
255
+ return declarations.join("; ");
140
256
  });
141
257
  const isLive = computed(() => live.value === true);
142
258
  const safeDuration = computed(() => duration.value > 0 ? duration.value : 0);
@@ -151,12 +267,25 @@ const muteLabel = computed(() => muted.value ? "Unmute video" : "Mute video");
151
267
  const playIconClass = computed(() => isPlaying.value ? "icon-pause" : "icon-play");
152
268
  const fullscreenIconClass = computed(() => isFullscreen.value ? "icon-minimize" : "icon-maximize");
153
269
  const statusText = computed(() => isLive.value ? "LIVE" : subtitle.value);
270
+ const rateOptions = [
271
+ { value: 0.75, label: "0.75x" },
272
+ { value: 1, label: "1x" },
273
+ { value: 1.25, label: "1.25x" },
274
+ { value: 1.5, label: "1.5x" },
275
+ { value: 2, label: "2x" }
276
+ ];
277
+ const skipOptions = [
278
+ { value: 5, label: "5s" },
279
+ { value: 10, label: "10s" },
280
+ { value: 15, label: "15s" },
281
+ { value: 30, label: "30s" }
282
+ ];
154
283
  const showPlayControl = computed(() => hasControl("play"));
155
284
  const showSeekControl = computed(() => hasControl("seek"));
156
285
  const showTimeControl = computed(() => hasControl("time"));
157
286
  const showMuteControl = computed(() => hasControl("mute"));
158
287
  const showVolumeControl = computed(() => hasControl("volume"));
159
- const showRateControl = computed(() => hasControl("rate"));
288
+ const showSettingsControl = computed(() => hasControl("settings") || hasControl("rate"));
160
289
  const showFullscreenControl = computed(() => hasControl("fullscreen"));
161
290
  const showControlShelf = computed(() => (
162
291
  showPlayControl.value ||
@@ -164,19 +293,17 @@ const showControlShelf = computed(() => (
164
293
  showTimeControl.value ||
165
294
  showMuteControl.value ||
166
295
  showVolumeControl.value ||
167
- showRateControl.value ||
296
+ showSettingsControl.value ||
168
297
  showFullscreenControl.value ||
169
298
  isLive.value
170
299
  ));
171
- const rates = [1, 1.25, 1.5, 2, 0.75];
172
- let playbackRate = 1;
173
- let rateIndex = 0;
174
300
  let isDisposed = false;
175
301
  let ignoreMediaEventsUntil = 0;
176
302
 
177
303
  onMounted(() => {
178
304
  applyAudioSettings();
179
305
  document.addEventListener("fullscreenchange", updateFullscreen);
306
+ document.addEventListener("pointerdown", handleDocumentPointerDown);
180
307
  });
181
308
 
182
309
  onBeforeUnmount(() => {
@@ -187,6 +314,7 @@ onBeforeUnmount(() => {
187
314
 
188
315
  onUnmounted(() => {
189
316
  document.removeEventListener("fullscreenchange", updateFullscreen);
317
+ document.removeEventListener("pointerdown", handleDocumentPointerDown);
190
318
  });
191
319
 
192
320
  function getMedia() {
@@ -202,6 +330,41 @@ function shouldIgnoreMediaEvent() {
202
330
  return performance.now() < ignoreMediaEventsUntil;
203
331
  }
204
332
 
333
+ function normalizeQualityOption(option, index) {
334
+ if (typeof option === "string") {
335
+ return { id: option || `quality-${index}`, label: option || `Quality ${index + 1}`, src: src.value, poster: poster.value };
336
+ }
337
+ if (!option || typeof option !== "object") {
338
+ return null;
339
+ }
340
+ const optionSrc = typeof option.src === "string" && option.src ? option.src : src.value;
341
+ const label = typeof option.label === "string" && option.label ? option.label : `Quality ${index + 1}`;
342
+ return {
343
+ id: typeof option.id === "string" || typeof option.id === "number" ? String(option.id) : `${label}-${index}`,
344
+ label,
345
+ src: optionSrc,
346
+ poster: typeof option.poster === "string" ? option.poster : poster.value
347
+ };
348
+ }
349
+
350
+ function normalizeCssSize(value) {
351
+ if (typeof value === "number" && Number.isFinite(value)) {
352
+ return value >= 0 ? value + "px" : "";
353
+ }
354
+ if (typeof value !== "string") return "";
355
+ const trimmed = value.trim();
356
+ if (!trimmed) return "";
357
+ return /^\d+(\.\d+)?$/.test(trimmed) ? trimmed + "px" : trimmed;
358
+ }
359
+
360
+ function normalizeAspectRatio(value) {
361
+ if (typeof value === "number" && Number.isFinite(value)) {
362
+ return value > 0 ? String(value) : "";
363
+ }
364
+ if (typeof value !== "string") return "";
365
+ return value.trim();
366
+ }
367
+
205
368
  function hasControl(name) {
206
369
  if (isLive.value && liveHiddenControls.includes(name)) return false;
207
370
  return activeControls.value.has(name);
@@ -273,7 +436,7 @@ function applyAudioSettings() {
273
436
  if (!media) return;
274
437
  media.volume = volume.value;
275
438
  media.muted = muted.value;
276
- media.playbackRate = playbackRate;
439
+ media.playbackRate = playbackRateValue.value;
277
440
  }
278
441
 
279
442
  function markPlaying(event) {
@@ -389,7 +552,7 @@ function seekWithKeyboard(event) {
389
552
  return;
390
553
  }
391
554
 
392
- const offset = event.key === "ArrowRight" ? 5 : -5;
555
+ const offset = event.key === "ArrowRight" ? skipSeconds.value : -skipSeconds.value;
393
556
  const nextTime = Math.min(Math.max(currentTime.value + offset, 0), safeDuration.value);
394
557
  seekTo(nextTime);
395
558
  }
@@ -421,21 +584,68 @@ function toggleMute(event) {
421
584
  }, 0);
422
585
  }
423
586
 
424
- function cycleRate(event) {
587
+ function toggleSettings() {
425
588
  if (isDisposed) return;
426
- const button = event.currentTarget;
589
+ settingsOpen.value = !settingsOpen.value;
590
+ controlsVisible.value = true;
591
+ }
592
+
593
+ function selectQuality(id) {
594
+ if (isDisposed) return;
595
+ const media = getMedia();
596
+ const wasPlaying = media ? !media.paused : false;
597
+ const previousTime = media?.currentTime || 0;
598
+ selectedQualityId.value = id;
599
+ settingsOpen.value = false;
600
+ if (!media) return;
427
601
  window.setTimeout(() => {
428
602
  if (isDisposed) return;
429
- rateIndex = (rateIndex + 1) % rates.length;
430
- playbackRate = rates[rateIndex];
431
- const media = getMedia();
432
- if (media) {
433
- media.playbackRate = playbackRate;
603
+ const nextMedia = getMedia();
604
+ if (!nextMedia) return;
605
+ if (Number.isFinite(previousTime) && previousTime > 0 && safeDuration.value > 0) {
606
+ nextMedia.currentTime = Math.min(previousTime, safeDuration.value);
607
+ currentTime.value = nextMedia.currentTime;
608
+ }
609
+ nextMedia.playbackRate = playbackRateValue.value;
610
+ if (wasPlaying) {
611
+ nextMedia.play().catch((error) => {
612
+ if (error?.name !== "AbortError") {
613
+ throw error;
614
+ }
615
+ });
434
616
  }
435
- button.textContent = playbackRate + "x";
436
617
  }, 0);
437
618
  }
438
619
 
620
+ function setPlaybackRate(nextRate) {
621
+ if (isDisposed) return;
622
+ playbackRateValue.value = nextRate;
623
+ settingsOpen.value = false;
624
+ window.setTimeout(() => {
625
+ if (isDisposed) return;
626
+ const media = getMedia();
627
+ if (!media) return;
628
+ media.playbackRate = nextRate;
629
+ }, 0);
630
+ }
631
+
632
+ function setSkipSeconds(nextSeconds) {
633
+ if (isDisposed) return;
634
+ skipSeconds.value = nextSeconds;
635
+ settingsOpen.value = false;
636
+ }
637
+
638
+ function handleDocumentPointerDown(event) {
639
+ if (isDisposed || !settingsOpen.value) return;
640
+ const menu = settingsEl.value;
641
+ if (menu?.contains?.(event.target)) return;
642
+ settingsOpen.value = false;
643
+ }
644
+
645
+ function stopEvent(event) {
646
+ event.stopPropagation();
647
+ }
648
+
439
649
  function toggleFullscreen() {
440
650
  if (isDisposed) return;
441
651
  if (document.fullscreenElement) {
@@ -696,6 +906,53 @@ function formatTime(seconds) {
696
906
  font-size: 0.84rem;
697
907
  }
698
908
 
909
+ .settings {
910
+ position: relative;
911
+ }
912
+
913
+ .settings-menu {
914
+ position: absolute;
915
+ right: 0;
916
+ bottom: calc(100% + 10px);
917
+ z-index: 4;
918
+ display: grid;
919
+ gap: 12px;
920
+ width: min(240px, calc(100vw - 32px));
921
+ padding: 12px;
922
+ border: 1px solid rgba(255, 255, 255, 0.16);
923
+ border-radius: 8px;
924
+ color: #ffffff;
925
+ background: rgba(3, 7, 18, 0.94);
926
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.36);
927
+ }
928
+
929
+ .settings-group {
930
+ display: grid;
931
+ grid-template-columns: repeat(3, minmax(0, 1fr));
932
+ gap: 6px;
933
+ }
934
+
935
+ .settings-label {
936
+ grid-column: 1 / -1;
937
+ color: rgba(255, 255, 255, 0.72);
938
+ font-size: 0.78rem;
939
+ font-weight: 700;
940
+ }
941
+
942
+ .settings-option {
943
+ min-height: 30px;
944
+ padding: 0 8px;
945
+ border-radius: 6px;
946
+ font-size: 0.8rem;
947
+ white-space: nowrap;
948
+ }
949
+
950
+ .settings-option.active {
951
+ border-color: #ef4444;
952
+ color: #ffffff;
953
+ background: #ef4444;
954
+ }
955
+
699
956
  button {
700
957
  min-height: 36px;
701
958
  padding: 0 12px;
@@ -769,6 +1026,10 @@ button:hover {
769
1026
  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
1027
  }
771
1028
 
1029
+ .icon-settings {
1030
+ 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>');
1031
+ }
1032
+
772
1033
  @media (max-width: 620px) {
773
1034
  .top-bar {
774
1035
  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.34",
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;