forked from mirror/misskey
fix/enhance(frontend): 映像・音声周りの改修 (#13206)
* enhance(frontend): 映像・音声周りの改修 * fix * fix design * fix lint * キーボードショートカットを整備 * Update Changelog * fix * feat: ループ再生 * ネイティブの動作と同期されるように * Update Changelog * key指定を消す
This commit is contained in:
parent
50da7d2a27
commit
b96d9c6973
@ -19,6 +19,9 @@
|
|||||||
- Enhance: ページのデザインを変更
|
- Enhance: ページのデザインを変更
|
||||||
- Enhance: 2要素認証(ワンタイムパスワード)の入力欄を改善
|
- Enhance: 2要素認証(ワンタイムパスワード)の入力欄を改善
|
||||||
- Enhance: 「今日誕生日のフォロー中ユーザー」ウィジェットを手動でリロードできるように
|
- Enhance: 「今日誕生日のフォロー中ユーザー」ウィジェットを手動でリロードできるように
|
||||||
|
- Enhance: 映像・音声の再生にブラウザのネイティブプレイヤーを使用できるように
|
||||||
|
- Enhance: 映像・音声の再生メニューに「再生速度」「ループ再生」「ピクチャインピクチャ」を追加
|
||||||
|
- Enhance: 映像・音声の再生にキーボードショートカットが使えるように
|
||||||
- Fix: 一部のページ内リンクが正しく動作しない問題を修正
|
- Fix: 一部のページ内リンクが正しく動作しない問題を修正
|
||||||
- Fix: 周年の実績が閏年を考慮しない問題を修正
|
- Fix: 周年の実績が閏年を考慮しない問題を修正
|
||||||
- Fix: ローカルURLのプレビューポップアップが左上に表示される
|
- Fix: ローカルURLのプレビューポップアップが左上に表示される
|
||||||
|
18
locales/index.d.ts
vendored
18
locales/index.d.ts
vendored
@ -4932,6 +4932,10 @@ export interface Locale extends ILocale {
|
|||||||
* アプリを起動
|
* アプリを起動
|
||||||
*/
|
*/
|
||||||
"launchApp": string;
|
"launchApp": string;
|
||||||
|
/**
|
||||||
|
* 動画・音声の再生にブラウザのUIを使用する
|
||||||
|
*/
|
||||||
|
"useNativeUIForVideoAudioPlayer": string;
|
||||||
"_bubbleGame": {
|
"_bubbleGame": {
|
||||||
/**
|
/**
|
||||||
* 遊び方
|
* 遊び方
|
||||||
@ -9834,6 +9838,20 @@ export interface Locale extends ILocale {
|
|||||||
*/
|
*/
|
||||||
"summaryProxyDescription2": string;
|
"summaryProxyDescription2": string;
|
||||||
};
|
};
|
||||||
|
"_mediaControls": {
|
||||||
|
/**
|
||||||
|
* ピクチャインピクチャ
|
||||||
|
*/
|
||||||
|
"pip": string;
|
||||||
|
/**
|
||||||
|
* 再生速度
|
||||||
|
*/
|
||||||
|
"playbackRate": string;
|
||||||
|
/**
|
||||||
|
* ループ再生
|
||||||
|
*/
|
||||||
|
"loop": string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
declare const locales: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[lang: string]: Locale;
|
||||||
|
@ -1229,6 +1229,7 @@ notUsePleaseLeaveBlank: "使用しない場合は空欄にしてください"
|
|||||||
useTotp: "ワンタイムパスワードを使う"
|
useTotp: "ワンタイムパスワードを使う"
|
||||||
useBackupCode: "バックアップコードを使う"
|
useBackupCode: "バックアップコードを使う"
|
||||||
launchApp: "アプリを起動"
|
launchApp: "アプリを起動"
|
||||||
|
useNativeUIForVideoAudioPlayer: "動画・音声の再生にブラウザのUIを使用する"
|
||||||
|
|
||||||
_bubbleGame:
|
_bubbleGame:
|
||||||
howToPlay: "遊び方"
|
howToPlay: "遊び方"
|
||||||
@ -2619,3 +2620,9 @@ _urlPreviewSetting:
|
|||||||
summaryProxy: "プレビューを生成するプロキシのエンドポイント"
|
summaryProxy: "プレビューを生成するプロキシのエンドポイント"
|
||||||
summaryProxyDescription: "Misskey本体ではなく、サマリープロキシを使用してプレビューを生成します。"
|
summaryProxyDescription: "Misskey本体ではなく、サマリープロキシを使用してプレビューを生成します。"
|
||||||
summaryProxyDescription2: "プロキシには下記パラメータがクエリ文字列として連携されます。プロキシ側がこれらをサポートしない場合、設定値は無視されます。"
|
summaryProxyDescription2: "プロキシには下記パラメータがクエリ文字列として連携されます。プロキシ側がこれらをサポートしない場合、設定値は無視されます。"
|
||||||
|
|
||||||
|
_mediaControls:
|
||||||
|
pip: "ピクチャインピクチャ"
|
||||||
|
playbackRate: "再生速度"
|
||||||
|
loop: "ループ再生"
|
||||||
|
|
@ -5,11 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
ref="playerEl"
|
||||||
|
v-hotkey="keymap"
|
||||||
|
tabindex="0"
|
||||||
:class="[
|
:class="[
|
||||||
$style.audioContainer,
|
$style.audioContainer,
|
||||||
(audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive,
|
(audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive,
|
||||||
]"
|
]"
|
||||||
@contextmenu.stop
|
@contextmenu.stop
|
||||||
|
@keydown.stop
|
||||||
>
|
>
|
||||||
<button v-if="hide" :class="$style.hidden" @click="hide = false">
|
<button v-if="hide" :class="$style.hidden" @click="hide = false">
|
||||||
<div :class="$style.hiddenTextWrapper">
|
<div :class="$style.hiddenTextWrapper">
|
||||||
@ -18,6 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.nativeAudioContainer">
|
||||||
|
<audio
|
||||||
|
ref="audioEl"
|
||||||
|
preload="metadata"
|
||||||
|
controls
|
||||||
|
:class="$style.nativeAudio"
|
||||||
|
@keydown.prevent
|
||||||
|
>
|
||||||
|
<source :src="audio.url">
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else :class="$style.audioControls">
|
<div v-else :class="$style.audioControls">
|
||||||
<audio
|
<audio
|
||||||
ref="audioEl"
|
ref="audioEl"
|
||||||
@ -72,6 +89,41 @@ const props = defineProps<{
|
|||||||
audio: Misskey.entities.DriveFile;
|
audio: Misskey.entities.DriveFile;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const keymap = {
|
||||||
|
'up': () => {
|
||||||
|
if (hasFocus() && audioEl.value) {
|
||||||
|
volume.value = Math.min(volume.value + 0.1, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'down': () => {
|
||||||
|
if (hasFocus() && audioEl.value) {
|
||||||
|
volume.value = Math.max(volume.value - 0.1, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'left': () => {
|
||||||
|
if (hasFocus() && audioEl.value) {
|
||||||
|
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'right': () => {
|
||||||
|
if (hasFocus() && audioEl.value) {
|
||||||
|
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'space': () => {
|
||||||
|
if (hasFocus()) {
|
||||||
|
togglePlayPause();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
||||||
|
function hasFocus() {
|
||||||
|
if (!playerEl.value) return false;
|
||||||
|
return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerEl = shallowRef<HTMLDivElement>();
|
||||||
const audioEl = shallowRef<HTMLAudioElement>();
|
const audioEl = shallowRef<HTMLAudioElement>();
|
||||||
|
|
||||||
// eslint-disable-next-line vue/no-setup-props-destructure
|
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||||
@ -85,6 +137,30 @@ function showMenu(ev: MouseEvent) {
|
|||||||
|
|
||||||
menu = [
|
menu = [
|
||||||
// TODO: 再生キューに追加
|
// TODO: 再生キューに追加
|
||||||
|
{
|
||||||
|
type: 'switch',
|
||||||
|
text: i18n.ts._mediaControls.loop,
|
||||||
|
icon: 'ti ti-repeat',
|
||||||
|
ref: loop,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'radio',
|
||||||
|
text: i18n.ts._mediaControls.playbackRate,
|
||||||
|
icon: 'ti ti-clock-play',
|
||||||
|
ref: speed,
|
||||||
|
options: {
|
||||||
|
'0.25x': 0.25,
|
||||||
|
'0.5x': 0.5,
|
||||||
|
'0.75x': 0.75,
|
||||||
|
'1.0x': 1,
|
||||||
|
'1.25x': 1.25,
|
||||||
|
'1.5x': 1.5,
|
||||||
|
'2.0x': 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: i18n.ts.hide,
|
text: i18n.ts.hide,
|
||||||
icon: 'ti ti-eye-off',
|
icon: 'ti ti-eye-off',
|
||||||
@ -147,6 +223,8 @@ const rangePercent = computed({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const volume = ref(.25);
|
const volume = ref(.25);
|
||||||
|
const speed = ref(1);
|
||||||
|
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||||
const bufferedEnd = ref(0);
|
const bufferedEnd = ref(0);
|
||||||
const bufferedDataRatio = computed(() => {
|
const bufferedDataRatio = computed(() => {
|
||||||
if (!audioEl.value) return 0;
|
if (!audioEl.value) return 0;
|
||||||
@ -176,6 +254,7 @@ function toggleMute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let onceInit = false;
|
let onceInit = false;
|
||||||
|
let mediaTickFrameId: number | null = null;
|
||||||
let stopAudioElWatch: () => void;
|
let stopAudioElWatch: () => void;
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
@ -195,8 +274,12 @@ function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
elapsedTimeMs.value = audioEl.value.currentTime * 1000;
|
elapsedTimeMs.value = audioEl.value.currentTime * 1000;
|
||||||
|
|
||||||
|
if (audioEl.value.loop !== loop.value) {
|
||||||
|
loop.value = audioEl.value.loop;
|
||||||
}
|
}
|
||||||
window.requestAnimationFrame(updateMediaTick);
|
}
|
||||||
|
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMediaTick();
|
updateMediaTick();
|
||||||
@ -234,6 +317,14 @@ watch(volume, (to) => {
|
|||||||
if (audioEl.value) audioEl.value.volume = to;
|
if (audioEl.value) audioEl.value.volume = to;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(speed, (to) => {
|
||||||
|
if (audioEl.value) audioEl.value.playbackRate = to;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(loop, (to) => {
|
||||||
|
if (audioEl.value) audioEl.value.loop = to;
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
init();
|
init();
|
||||||
});
|
});
|
||||||
@ -252,6 +343,10 @@ onDeactivated(() => {
|
|||||||
hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore');
|
hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore');
|
||||||
stopAudioElWatch();
|
stopAudioElWatch();
|
||||||
onceInit = false;
|
onceInit = false;
|
||||||
|
if (mediaTickFrameId) {
|
||||||
|
window.cancelAnimationFrame(mediaTickFrameId);
|
||||||
|
mediaTickFrameId = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -262,6 +357,10 @@ onDeactivated(() => {
|
|||||||
border: .5px solid var(--divider);
|
border: .5px solid var(--divider);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sensitive {
|
.sensitive {
|
||||||
@ -367,4 +466,15 @@ onDeactivated(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nativeAudioContainer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nativeAudio {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="playerEl"
|
ref="playerEl"
|
||||||
|
v-hotkey="keymap"
|
||||||
|
tabindex="0"
|
||||||
:class="[
|
:class="[
|
||||||
$style.videoContainer,
|
$style.videoContainer,
|
||||||
controlsShowing && $style.active,
|
controlsShowing && $style.active,
|
||||||
@ -14,15 +16,37 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
@mouseover="onMouseOver"
|
@mouseover="onMouseOver"
|
||||||
@mouseleave="onMouseLeave"
|
@mouseleave="onMouseLeave"
|
||||||
@contextmenu.stop
|
@contextmenu.stop
|
||||||
|
@keydown.stop
|
||||||
>
|
>
|
||||||
<button v-if="hide" :class="$style.hidden" @click="hide = false">
|
<button v-if="hide" :class="$style.hidden" @click="hide = false">
|
||||||
<div :class="$style.hiddenTextWrapper">
|
<div :class="$style.hiddenTextWrapper">
|
||||||
<b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
|
<b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
|
||||||
<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
|
<b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
|
||||||
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div v-else :class="$style.videoRoot" @click.self="togglePlayPause">
|
|
||||||
|
<div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.videoRoot">
|
||||||
|
<video
|
||||||
|
ref="videoEl"
|
||||||
|
:class="$style.video"
|
||||||
|
:poster="video.thumbnailUrl ?? undefined"
|
||||||
|
:title="video.comment ?? undefined"
|
||||||
|
:alt="video.comment"
|
||||||
|
preload="metadata"
|
||||||
|
controls
|
||||||
|
@keydown.prevent
|
||||||
|
>
|
||||||
|
<source :src="video.url">
|
||||||
|
</video>
|
||||||
|
<i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i>
|
||||||
|
<div :class="$style.indicators">
|
||||||
|
<div v-if="video.comment" :class="$style.indicator">ALT</div>
|
||||||
|
<div v-if="video.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else :class="$style.videoRoot">
|
||||||
<video
|
<video
|
||||||
ref="videoEl"
|
ref="videoEl"
|
||||||
:class="$style.video"
|
:class="$style.video"
|
||||||
@ -31,6 +55,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
:alt="video.comment"
|
:alt="video.comment"
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
playsinline
|
playsinline
|
||||||
|
@keydown.prevent
|
||||||
|
@click.self="togglePlayPause"
|
||||||
>
|
>
|
||||||
<source :src="video.url">
|
<source :src="video.url">
|
||||||
</video>
|
</video>
|
||||||
@ -100,6 +126,40 @@ const props = defineProps<{
|
|||||||
video: Misskey.entities.DriveFile;
|
video: Misskey.entities.DriveFile;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const keymap = {
|
||||||
|
'up': () => {
|
||||||
|
if (hasFocus() && videoEl.value) {
|
||||||
|
volume.value = Math.min(volume.value + 0.1, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'down': () => {
|
||||||
|
if (hasFocus() && videoEl.value) {
|
||||||
|
volume.value = Math.max(volume.value - 0.1, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'left': () => {
|
||||||
|
if (hasFocus() && videoEl.value) {
|
||||||
|
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'right': () => {
|
||||||
|
if (hasFocus() && videoEl.value) {
|
||||||
|
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'space': () => {
|
||||||
|
if (hasFocus()) {
|
||||||
|
togglePlayPause();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
||||||
|
function hasFocus() {
|
||||||
|
if (!playerEl.value) return false;
|
||||||
|
return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line vue/no-setup-props-destructure
|
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||||
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
|
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
|
||||||
|
|
||||||
@ -111,6 +171,35 @@ function showMenu(ev: MouseEvent) {
|
|||||||
|
|
||||||
menu = [
|
menu = [
|
||||||
// TODO: 再生キューに追加
|
// TODO: 再生キューに追加
|
||||||
|
{
|
||||||
|
type: 'switch',
|
||||||
|
text: i18n.ts._mediaControls.loop,
|
||||||
|
icon: 'ti ti-repeat',
|
||||||
|
ref: loop,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'radio',
|
||||||
|
text: i18n.ts._mediaControls.playbackRate,
|
||||||
|
icon: 'ti ti-clock-play',
|
||||||
|
ref: speed,
|
||||||
|
options: {
|
||||||
|
'0.25x': 0.25,
|
||||||
|
'0.5x': 0.5,
|
||||||
|
'0.75x': 0.75,
|
||||||
|
'1.0x': 1,
|
||||||
|
'1.25x': 1.25,
|
||||||
|
'1.5x': 1.5,
|
||||||
|
'2.0x': 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(document.pictureInPictureEnabled ? [{
|
||||||
|
text: i18n.ts._mediaControls.pip,
|
||||||
|
icon: 'ti ti-picture-in-picture',
|
||||||
|
action: togglePictureInPicture,
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: i18n.ts.hide,
|
text: i18n.ts.hide,
|
||||||
icon: 'ti ti-eye-off',
|
icon: 'ti ti-eye-off',
|
||||||
@ -186,6 +275,8 @@ const rangePercent = computed({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const volume = ref(.25);
|
const volume = ref(.25);
|
||||||
|
const speed = ref(1);
|
||||||
|
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||||
const bufferedEnd = ref(0);
|
const bufferedEnd = ref(0);
|
||||||
const bufferedDataRatio = computed(() => {
|
const bufferedDataRatio = computed(() => {
|
||||||
if (!videoEl.value) return 0;
|
if (!videoEl.value) return 0;
|
||||||
@ -243,6 +334,16 @@ function toggleFullscreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePictureInPicture() {
|
||||||
|
if (videoEl.value) {
|
||||||
|
if (document.pictureInPictureElement) {
|
||||||
|
document.exitPictureInPicture();
|
||||||
|
} else {
|
||||||
|
videoEl.value.requestPictureInPicture();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleMute() {
|
function toggleMute() {
|
||||||
if (volume.value === 0) {
|
if (volume.value === 0) {
|
||||||
volume.value = .25;
|
volume.value = .25;
|
||||||
@ -252,6 +353,7 @@ function toggleMute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let onceInit = false;
|
let onceInit = false;
|
||||||
|
let mediaTickFrameId: number | null = null;
|
||||||
let stopVideoElWatch: () => void;
|
let stopVideoElWatch: () => void;
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
@ -271,8 +373,12 @@ function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
|
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
|
||||||
|
|
||||||
|
if (videoEl.value.loop !== loop.value) {
|
||||||
|
loop.value = videoEl.value.loop;
|
||||||
}
|
}
|
||||||
window.requestAnimationFrame(updateMediaTick);
|
}
|
||||||
|
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMediaTick();
|
updateMediaTick();
|
||||||
@ -316,6 +422,14 @@ watch(volume, (to) => {
|
|||||||
if (videoEl.value) videoEl.value.volume = to;
|
if (videoEl.value) videoEl.value.volume = to;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(speed, (to) => {
|
||||||
|
if (videoEl.value) videoEl.value.playbackRate = to;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(loop, (to) => {
|
||||||
|
if (videoEl.value) videoEl.value.loop = to;
|
||||||
|
});
|
||||||
|
|
||||||
watch(hide, (to) => {
|
watch(hide, (to) => {
|
||||||
if (to && isFullscreen.value) {
|
if (to && isFullscreen.value) {
|
||||||
document.exitFullscreen();
|
document.exitFullscreen();
|
||||||
@ -341,6 +455,10 @@ onDeactivated(() => {
|
|||||||
hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore');
|
hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore');
|
||||||
stopVideoElWatch();
|
stopVideoElWatch();
|
||||||
onceInit = false;
|
onceInit = false;
|
||||||
|
if (mediaTickFrameId) {
|
||||||
|
window.cancelAnimationFrame(mediaTickFrameId);
|
||||||
|
mediaTickFrameId = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -349,6 +467,10 @@ onDeactivated(() => {
|
|||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sensitive {
|
.sensitive {
|
||||||
@ -412,7 +534,7 @@ onDeactivated(() => {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 120px 0;
|
padding: 60px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -436,7 +558,6 @@ onDeactivated(() => {
|
|||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.videoOverlayPlayButton {
|
.videoOverlayPlayButton {
|
||||||
|
@ -42,9 +42,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<MkSwitchButton :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
|
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||||
|
<MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
|
||||||
<div :class="$style.item_content">
|
<div :class="$style.item_content">
|
||||||
<span :class="[$style.item_content_text, $style.switchText]">{{ item.text }}</span>
|
<span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span>
|
||||||
|
<MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button v-else-if="item.type === 'radio'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showRadioOptions(item, $event)" @click="!preferClick ? null : showRadioOptions(item, $event)">
|
||||||
|
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
|
||||||
|
<div :class="$style.item_content">
|
||||||
|
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
|
||||||
|
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button v-else-if="item.type === 'radioOption'" :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.radioActive]: item.active }]" @click="clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
|
<div :class="$style.icon">
|
||||||
|
<span :class="[$style.radio, { [$style.radioChecked]: item.active }]"></span>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.item_content">
|
||||||
|
<span :class="$style.item_content_text">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)">
|
<button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)">
|
||||||
@ -77,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
||||||
import { focusPrev, focusNext } from '@/scripts/focus.js';
|
import { focusPrev, focusNext } from '@/scripts/focus.js';
|
||||||
import MkSwitchButton from '@/components/MkSwitch.button.vue';
|
import MkSwitchButton from '@/components/MkSwitch.button.vue';
|
||||||
import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuParent } from '@/types/menu.js';
|
import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { isTouchUsing } from '@/scripts/touch.js';
|
import { isTouchUsing } from '@/scripts/touch.js';
|
||||||
@ -168,6 +185,31 @@ function onItemMouseLeave(item) {
|
|||||||
if (childCloseTimer) window.clearTimeout(childCloseTimer);
|
if (childCloseTimer) window.clearTimeout(childCloseTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
|
||||||
|
const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => {
|
||||||
|
const value = item.options[key];
|
||||||
|
return {
|
||||||
|
type: 'radioOption',
|
||||||
|
text: key,
|
||||||
|
action: () => {
|
||||||
|
item.ref = value;
|
||||||
|
},
|
||||||
|
active: computed(() => item.ref === value),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (props.asDrawer) {
|
||||||
|
os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
|
||||||
|
emit('close');
|
||||||
|
});
|
||||||
|
emit('hide');
|
||||||
|
} else {
|
||||||
|
childTarget.value = (ev.currentTarget ?? ev.target) as HTMLElement;
|
||||||
|
childMenu.value = children;
|
||||||
|
childShowingItem.value = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function showChildren(item: MenuParent, ev: MouseEvent) {
|
async function showChildren(item: MenuParent, ev: MouseEvent) {
|
||||||
const children: MenuItem[] = await (async () => {
|
const children: MenuItem[] = await (async () => {
|
||||||
if (childrenCache.has(item)) {
|
if (childrenCache.has(item)) {
|
||||||
@ -196,8 +238,10 @@ async function showChildren(item: MenuParent, ev: MouseEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clicked(fn: MenuAction, ev: MouseEvent) {
|
function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) {
|
||||||
fn(ev);
|
fn(ev);
|
||||||
|
|
||||||
|
if (!doClose) return;
|
||||||
close(true);
|
close(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,6 +394,15 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.radioActive {
|
||||||
|
color: var(--accent) !important;
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
background-color: var(--accentedBg) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:not(:active):focus-visible {
|
&:not(:active):focus-visible {
|
||||||
box-shadow: 0 0 0 2px var(--focus) inset;
|
box-shadow: 0 0 0 2px var(--focus) inset;
|
||||||
}
|
}
|
||||||
@ -417,11 +470,11 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.switchButton {
|
.switchButton {
|
||||||
margin-left: -2px;
|
margin-left: -2px;
|
||||||
|
--height: 1.35em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.switchText {
|
.switchText {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
margin-top: 2px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
@ -461,4 +514,32 @@ onBeforeUnmount(() => {
|
|||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
border-top: solid 0.5px var(--divider);
|
border-top: solid 0.5px var(--divider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: -.125em;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: solid 2px var(--divider);
|
||||||
|
background-color: var(--panel);
|
||||||
|
|
||||||
|
&.radioChecked {
|
||||||
|
border-color: var(--accent);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 50%;
|
||||||
|
height: 50%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -41,13 +41,15 @@ const toggle = () => {
|
|||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.button {
|
.button {
|
||||||
|
--height: 21px;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 32px;
|
width: calc(var(--height) * 1.6);
|
||||||
height: 23px;
|
height: calc(var(--height) + 2px); // 枠線
|
||||||
outline: none;
|
outline: none;
|
||||||
background: var(--switchOffBg);
|
background: var(--switchOffBg);
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
@ -69,9 +71,10 @@ const toggle = () => {
|
|||||||
|
|
||||||
.knob {
|
.knob {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
top: 3px;
|
top: 3px;
|
||||||
width: 15px;
|
width: calc(var(--height) - 6px);
|
||||||
height: 15px;
|
height: calc(var(--height) - 6px);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
@ -82,7 +85,7 @@ const toggle = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.knobChecked {
|
.knobChecked {
|
||||||
left: 12px;
|
left: calc(calc(100% - var(--height)) + 3px);
|
||||||
background: var(--switchOnFg);
|
background: var(--switchOnFg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -132,6 +132,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
|
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
|
||||||
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
|
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
|
||||||
<MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch>
|
<MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch>
|
||||||
|
<MkSwitch v-model="useNativeUIForVideoAudioPlayer">{{ i18n.ts.useNativeUIForVideoAudioPlayer }}</MkSwitch>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<MkRadios v-model="emojiStyle">
|
<MkRadios v-model="emojiStyle">
|
||||||
@ -308,6 +309,7 @@ const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disable
|
|||||||
const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications'));
|
const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications'));
|
||||||
const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect'));
|
const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect'));
|
||||||
const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe'));
|
const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe'));
|
||||||
|
const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer'));
|
||||||
|
|
||||||
watch(lang, () => {
|
watch(lang, () => {
|
||||||
miLocalStorage.setItem('lang', lang.value as string);
|
miLocalStorage.setItem('lang', lang.value as string);
|
||||||
|
@ -15,6 +15,7 @@ export default (input: string): string[] => {
|
|||||||
export const aliases = {
|
export const aliases = {
|
||||||
'esc': 'Escape',
|
'esc': 'Escape',
|
||||||
'enter': ['Enter', 'NumpadEnter'],
|
'enter': ['Enter', 'NumpadEnter'],
|
||||||
|
'space': [' ', 'Spacebar'],
|
||||||
'up': 'ArrowUp',
|
'up': 'ArrowUp',
|
||||||
'down': 'ArrowDown',
|
'down': 'ArrowDown',
|
||||||
'left': 'ArrowLeft',
|
'left': 'ArrowLeft',
|
||||||
|
@ -442,6 +442,10 @@ export const defaultStore = markRaw(new Storage('base', {
|
|||||||
where: 'device',
|
where: 'device',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
useNativeUIForVideoAudioPlayer: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
sound_masterVolume: {
|
sound_masterVolume: {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
|
@ -6,6 +6,8 @@
|
|||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { ComputedRef, Ref } from 'vue';
|
import { ComputedRef, Ref } from 'vue';
|
||||||
|
|
||||||
|
interface MenuRadioOptionsDef extends Record<string, any> { }
|
||||||
|
|
||||||
export type MenuAction = (ev: MouseEvent) => void;
|
export type MenuAction = (ev: MouseEvent) => void;
|
||||||
|
|
||||||
export type MenuDivider = { type: 'divider' };
|
export type MenuDivider = { type: 'divider' };
|
||||||
@ -14,13 +16,15 @@ export type MenuLabel = { type: 'label', text: string };
|
|||||||
export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
|
export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
|
||||||
export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean };
|
export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean };
|
||||||
export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction };
|
export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction };
|
||||||
export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean | Ref<boolean> };
|
export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, icon?: string, disabled?: boolean | Ref<boolean> };
|
||||||
export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction };
|
export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction };
|
||||||
|
export type MenuRadio = { type: 'radio', text: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> };
|
||||||
|
export type MenuRadioOption = { type: 'radioOption', text: string, action: MenuAction; active?: boolean | ComputedRef<boolean> };
|
||||||
export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) };
|
export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) };
|
||||||
|
|
||||||
export type MenuPending = { type: 'pending' };
|
export type MenuPending = { type: 'pending' };
|
||||||
|
|
||||||
type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent;
|
type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuParent;
|
||||||
type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent>;
|
type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent>;
|
||||||
export type MenuItem = OuterMenuItem | OuterPromiseMenuItem;
|
export type MenuItem = OuterMenuItem | OuterPromiseMenuItem;
|
||||||
export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent;
|
export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuParent;
|
||||||
|
Loading…
Reference in New Issue
Block a user