summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2024-03-30 16:02:03 +0900
committerGitHub <noreply@github.com>2024-03-30 16:02:03 +0900
commitb96d9c6973b1c861306fdb9f51256cee5325a2b1 (patch)
treeb6aba03fba4f018e35213da755d4064a602bb1d6 /packages/frontend/src/components
parentenhance(frontend): 2要素認証セットアップウィザードにアプリ... (diff)
downloadsharkey-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.vue112
-rw-r--r--packages/frontend/src/components/MkMediaVideo.vue131
-rw-r--r--packages/frontend/src/components/MkMenu.vue91
-rw-r--r--packages/frontend/src/components/MkSwitch.button.vue13
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>