mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-10-25 18:54:52 +00:00 
			
		
		
		
	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
					
				
					 11 changed files with 373 additions and 19 deletions
				
			
		|  | @ -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…
	
	Add table
		
		Reference in a new issue