-
+
+
+
+
@@ -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'));
@@ -111,6 +171,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',
@@ -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;
+ }
});
@@ -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
+
+
@@ -308,6 +309,7 @@ const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disable
const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications'));
const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect'));
const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe'));
+const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
diff --git a/packages/frontend/src/scripts/keycode.ts b/packages/frontend/src/scripts/keycode.ts
index bc1f485f5e..7ffceafada 100644
--- a/packages/frontend/src/scripts/keycode.ts
+++ b/packages/frontend/src/scripts/keycode.ts
@@ -15,6 +15,7 @@ export default (input: string): string[] => {
export const aliases = {
'esc': 'Escape',
'enter': ['Enter', 'NumpadEnter'],
+ 'space': [' ', 'Spacebar'],
'up': 'ArrowUp',
'down': 'ArrowDown',
'left': 'ArrowLeft',
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 7742acc60d..faefbd8ce4 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -442,6 +442,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: true,
},
+ useNativeUIForVideoAudioPlayer: {
+ where: 'device',
+ default: false,
+ },
sound_masterVolume: {
where: 'device',
diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts
index 712f3464e5..138eb7dd62 100644
--- a/packages/frontend/src/types/menu.ts
+++ b/packages/frontend/src/types/menu.ts
@@ -6,6 +6,8 @@
import * as Misskey from 'misskey-js';
import { ComputedRef, Ref } from 'vue';
+interface MenuRadioOptionsDef extends Record { }
+
export type MenuAction = (ev: MouseEvent) => void;
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 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 MenuSwitch = { type: 'switch', ref: Ref, text: string, disabled?: boolean | Ref };
+export type MenuSwitch = { type: 'switch', ref: Ref, text: string, icon?: string, disabled?: boolean | Ref };
export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef, avatar?: Misskey.entities.User; action: MenuAction };
+export type MenuRadio = { type: 'radio', text: string, icon?: string, ref: Ref, options: MenuRadioOptionsDef, disabled?: boolean | Ref };
+export type MenuRadioOption = { type: 'radioOption', text: string, action: MenuAction; active?: boolean | ComputedRef };
export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise
- {{ i18n.ts.more }}
+ {{ i18n.ts.more }}