diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2024-03-30 16:02:03 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-03-30 16:02:03 +0900 |
| commit | b96d9c6973b1c861306fdb9f51256cee5325a2b1 (patch) | |
| tree | b6aba03fba4f018e35213da755d4064a602bb1d6 /packages/frontend/src/components | |
| parent | enhance(frontend): 2要素認証セットアップウィザードにアプリ... (diff) | |
| download | sharkey-b96d9c6973b1c861306fdb9f51256cee5325a2b1.tar.gz sharkey-b96d9c6973b1c861306fdb9f51256cee5325a2b1.tar.bz2 sharkey-b96d9c6973b1c861306fdb9f51256cee5325a2b1.zip | |
fix/enhance(frontend): 映像・音声周りの改修 (#13206)
* enhance(frontend): 映像・音声周りの改修
* fix
* fix design
* fix lint
* キーボードショートカットを整備
* Update Changelog
* fix
* feat: ループ再生
* ネイティブの動作と同期されるように
* Update Changelog
* key指定を消す
Diffstat (limited to 'packages/frontend/src/components')
| -rw-r--r-- | packages/frontend/src/components/MkMediaAudio.vue | 112 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkMediaVideo.vue | 131 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkMenu.vue | 91 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSwitch.button.vue | 13 |
4 files changed, 331 insertions, 16 deletions
diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index 96c9b9fd66..5d2edf467e 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -5,11 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div + ref="playerEl" + v-hotkey="keymap" + tabindex="0" :class="[ $style.audioContainer, (audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, ]" @contextmenu.stop + @keydown.stop > <button v-if="hide" :class="$style.hidden" @click="hide = false"> <div :class="$style.hiddenTextWrapper"> @@ -18,6 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only <span style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </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"> <audio ref="audioEl" @@ -72,6 +89,41 @@ const props = defineProps<{ 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>(); // eslint-disable-next-line vue/no-setup-props-destructure @@ -86,6 +138,30 @@ function showMenu(ev: MouseEvent) { menu = [ // 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, icon: 'ti ti-eye-off', action: () => { @@ -147,6 +223,8 @@ const rangePercent = computed({ }, }); const volume = ref(.25); +const speed = ref(1); +const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える const bufferedEnd = ref(0); const bufferedDataRatio = computed(() => { if (!audioEl.value) return 0; @@ -176,6 +254,7 @@ function toggleMute() { } let onceInit = false; +let mediaTickFrameId: number | null = null; let stopAudioElWatch: () => void; function init() { @@ -195,8 +274,12 @@ function init() { } 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(); @@ -234,6 +317,14 @@ watch(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(() => { init(); }); @@ -252,6 +343,10 @@ onDeactivated(() => { hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'); stopAudioElWatch(); onceInit = false; + if (mediaTickFrameId) { + window.cancelAnimationFrame(mediaTickFrameId); + mediaTickFrameId = null; + } }); </script> @@ -262,6 +357,10 @@ onDeactivated(() => { border: .5px solid var(--divider); border-radius: var(--radius); overflow: clip; + + &:focus { + outline: none; + } } .sensitive { @@ -367,4 +466,15 @@ onDeactivated(() => { } } } + +.nativeAudioContainer { + display: flex; + align-items: center; + padding: 6px; +} + +.nativeAudio { + display: block; + width: 100%; +} </style> diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 73c1b6ff9d..1e3868bc36 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="playerEl" + v-hotkey="keymap" + tabindex="0" :class="[ $style.videoContainer, controlsShowing && $style.active, @@ -14,15 +16,37 @@ SPDX-License-Identifier: AGPL-3.0-only @mouseover="onMouseOver" @mouseleave="onMouseLeave" @contextmenu.stop + @keydown.stop > <button v-if="hide" :class="$style.hidden" @click="hide = false"> <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-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> </div> </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 ref="videoEl" :class="$style.video" @@ -31,6 +55,8 @@ SPDX-License-Identifier: AGPL-3.0-only :alt="video.comment" preload="metadata" playsinline + @keydown.prevent + @click.self="togglePlayPause" > <source :src="video.url"> </video> @@ -100,6 +126,40 @@ const props = defineProps<{ 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 const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); @@ -112,6 +172,35 @@ function showMenu(ev: MouseEvent) { menu = [ // 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, icon: 'ti ti-eye-off', action: () => { @@ -186,6 +275,8 @@ const rangePercent = computed({ }, }); const volume = ref(.25); +const speed = ref(1); +const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える const bufferedEnd = ref(0); const bufferedDataRatio = computed(() => { 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() { if (volume.value === 0) { volume.value = .25; @@ -252,6 +353,7 @@ function toggleMute() { } let onceInit = false; +let mediaTickFrameId: number | null = null; let stopVideoElWatch: () => void; function init() { @@ -271,8 +373,12 @@ function init() { } 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(); @@ -316,6 +422,14 @@ watch(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) => { if (to && isFullscreen.value) { 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'); stopVideoElWatch(); onceInit = false; + if (mediaTickFrameId) { + window.cancelAnimationFrame(mediaTickFrameId); + mediaTickFrameId = null; + } }); </script> @@ -349,6 +467,10 @@ onDeactivated(() => { container-type: inline-size; position: relative; overflow: clip; + + &:focus { + outline: none; + } } .sensitive { @@ -412,7 +534,7 @@ onDeactivated(() => { font: inherit; color: inherit; cursor: pointer; - padding: 120px 0; + padding: 60px 0; display: flex; align-items: center; justify-content: center; @@ -436,7 +558,6 @@ onDeactivated(() => { display: block; height: 100%; width: 100%; - pointer-events: none; } .videoOverlayPlayButton { diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index faed6416d0..d91239b9e2 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -42,9 +42,26 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </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)"> - <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"> + <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, $style.switchText]">{{ item.text }}</span> + <span :class="$style.item_content_text">{{ item.text }}</span> </div> </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)"> @@ -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 { focusPrev, focusNext } from '@/scripts/focus.js'; 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 { i18n } from '@/i18n.js'; import { isTouchUsing } from '@/scripts/touch.js'; @@ -168,6 +185,31 @@ function onItemMouseLeave(item) { 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) { const children: MenuItem[] = await (async () => { 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); + + if (!doClose) return; close(true); } @@ -350,6 +394,15 @@ onBeforeUnmount(() => { } } + &.radioActive { + color: var(--accent) !important; + opacity: 1; + + &:before { + background-color: var(--accentedBg) !important; + } + } + &:not(:active):focus-visible { box-shadow: 0 0 0 2px var(--focus) inset; } @@ -417,11 +470,11 @@ onBeforeUnmount(() => { .switchButton { margin-left: -2px; + --height: 1.35em; } .switchText { margin-left: 8px; - margin-top: 2px; overflow: hidden; text-overflow: ellipsis; } @@ -461,4 +514,32 @@ onBeforeUnmount(() => { margin: 8px 0; 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> diff --git a/packages/frontend/src/components/MkSwitch.button.vue b/packages/frontend/src/components/MkSwitch.button.vue index c95c933663..226908e221 100644 --- a/packages/frontend/src/components/MkSwitch.button.vue +++ b/packages/frontend/src/components/MkSwitch.button.vue @@ -41,13 +41,15 @@ const toggle = () => { <style lang="scss" module> .button { + --height: 21px; + position: relative; display: inline-flex; flex-shrink: 0; margin: 0; box-sizing: border-box; - width: 32px; - height: 23px; + width: calc(var(--height) * 1.6); + height: calc(var(--height) + 2px); // 枠線 outline: none; background: var(--switchOffBg); background-clip: content-box; @@ -69,9 +71,10 @@ const toggle = () => { .knob { position: absolute; + box-sizing: border-box; top: 3px; - width: 15px; - height: 15px; + width: calc(var(--height) - 6px); + height: calc(var(--height) - 6px); border-radius: 999px; transition: all 0.2s ease; @@ -82,7 +85,7 @@ const toggle = () => { } .knobChecked { - left: 12px; + left: calc(calc(100% - var(--height)) + 3px); background: var(--switchOnFg); } </style> |