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 +12 -0
- package/README.md +1 -1
- package/components/MikuruAudioPlayer.mikuru +5 -4
- package/components/MikuruCarousel.mikuru +31 -15
- package/components/MikuruCodeBlock.mikuru +9 -5
- package/components/MikuruDropdown.mikuru +15 -4
- package/components/MikuruImageViewer.mikuru +1 -3
- package/components/MikuruProgress.mikuru +1 -3
- package/components/MikuruToast.mikuru +16 -8
- package/components/MikuruVideoPlayer.mikuru +285 -24
- package/package.json +1 -1
- package/types/components/MikuruVideoPlayer.d.ts +12 -0
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
|
-
|
|
116
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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="
|
|
17
|
-
: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
|
-
<
|
|
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
|
|
136
|
-
const
|
|
137
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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" ?
|
|
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
|
|
587
|
+
function toggleSettings() {
|
|
425
588
|
if (isDisposed) return;
|
|
426
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
@@ -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;
|