1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-26 16:54:37 +02:00
This commit is contained in:
kakkokari-gtyih
2026-07-11 18:26:39 +09:00
parent 1538df1c74
commit b4d1157311
11 changed files with 609 additions and 1202 deletions

View File

@@ -0,0 +1,183 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<canvas
ref="canvas"
v-show="show"
:width="canvasWidth"
:height="canvasHeight"
draggable="false"
tabindex="-1"
style="-webkit-user-drag: none;"
></canvas>
</template>
<script lang="ts">
import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
// テスト環境で Web Worker インスタンスは作成できない
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const isTest = (import.meta.env.MODE === 'test' || window.isPlaywright);
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
if (isTest) {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
return;
}
const testWorker = new TestWebGL2();
testWorker.addEventListener('message', event => {
if (event.data.result) {
const workers = new WorkerMultiDispatch(
() => new DrawBlurhash(),
Math.min(navigator.hardwareConcurrency - 1, 4),
);
resolve(workers);
} else {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
}
testWorker.terminate();
});
});
</script>
<script lang="ts" setup>
import { watch, ref, shallowRef, useTemplateRef, onMounted, onUnmounted } from 'vue';
import { genId } from '@/utility/id.js';
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
const props = withDefaults(defineProps<{
blurhash: string | null;
onlyAvgColor?: boolean;
width?: number;
height?: number;
// v-showが何故か動作しないため
show?: boolean;
}>(), {
onlyAvgColor: false,
width: 64,
height: 64,
show: true,
});
const canvas = useTemplateRef('canvas');
const canvasWidth = ref(64);
const canvasHeight = ref(64);
const viewId = genId();
const bitmapTmp = shallowRef<CanvasImageSource | undefined>();
watch([() => props.width, () => props.height, canvas], () => {
const ratio = props.width / props.height;
if (ratio > 1) {
canvasWidth.value = Math.round(64 * ratio);
canvasHeight.value = 64;
} else {
canvasWidth.value = 64;
canvasHeight.value = Math.round(64 / ratio);
}
}, {
immediate: true,
});
watch(() => props.blurhash, () => {
draw();
});
function drawImage(bitmap: CanvasImageSource) {
// canvasがないmountedされていない場合はTmpに保存しておく
if (!canvas.value) {
bitmapTmp.value = bitmap;
return;
}
// canvasがあれば描画する
bitmapTmp.value = undefined;
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
}
function drawAvg() {
if (!canvas.value) return;
const color = (props.blurhash != null && extractAvgColorFromBlurhash(props.blurhash)) || '#888';
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
// avgColorでお茶をにごす
ctx.beginPath();
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
}
async function draw() {
if (isTest && props.blurhash == null) return;
drawAvg();
if (props.blurhash == null) return;
if (props.onlyAvgColor) return;
const work = await canvasPromise;
if (work instanceof WorkerMultiDispatch) {
work.postMessage(
{
id: viewId,
hash: props.blurhash,
},
undefined,
);
} else {
try {
const { render } = await import('buraha');
render(props.blurhash, work);
drawImage(work);
} catch (error) {
console.error('Error occurred during drawing blurhash', error);
}
}
}
function workerOnMessage(event: MessageEvent) {
if (event.data.id !== viewId) return;
drawImage(event.data.bitmap as ImageBitmap);
}
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.addListener(workerOnMessage);
}
draw();
});
onMounted(() => {
// drawImageがmountedより先に呼ばれている場合はここで描画する
if (bitmapTmp.value) {
drawImage(bitmapTmp.value);
}
});
onUnmounted(() => {
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.removeListener(workerOnMessage);
}
});
});
</script>

View File

@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@touchcancel.passive="cancelPointerGesture"
@contextmenu="cancelPointerGesture"
@wheel="onWheel"
@click="onCLick"
@click="onClick"
>
<div
:class="[$style.transformer, { [$style.transition]: enableTransition }]"
@@ -29,39 +29,59 @@ SPDX-License-Identifier: AGPL-3.0-only
@transitioncancel.self="enableTransition = false"
>
<div :class="[$style.contentWrapper, { [$style.hideForFallback]: hideForFallback }]">
<img
v-if="(!originalContentLoaded || !thumbnailContentLoaded) && (content.thumbnailUrl != null)"
:class="[$style.content, $style.thumbnail]"
:src="content.thumbnailUrl"
draggable="false"
@load="thumbnailContentLoaded = true"
>
<template v-if="activated">
<img
<div v-if="hide" :class="[
$style.hidden,
{ [$style.sensitive]: content.file.isSensitive && prefer.s.highlightSensitiveMedia },
]" :style="hiddenStyle">
<MkBlurhash
v-if="content.type === 'image'"
:class="[$style.content, $style.original]"
:src="content.url"
draggable="false"
@load="originalContentLoaded = true"
>
<video
v-else-if="content.type === 'video'"
:id="videoElId"
ref="videoEl"
:class="[$style.content, $style.original]"
:src="content.url"
draggable="false"
loop
autoplay
playsinline
@loadedmetadata="originalContentLoaded = true"
></video>
</template>
<div v-if="activated && !originalContentLoaded" :class="$style.loading">
<MkLoading/>
:class="$style.hiddenBlurhash"
:blurhash="content.file.blurhash ?? null"
:height="content?.height"
:width="content?.width"
/>
<div :class="$style.hiddenText">
<div :class="$style.hiddenTextWrapper">
<b v-if="content.file.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</b>
<b v-else style="display: block;"><i class="ti" :class="content.type === 'image' ? 'ti-photo' : 'ti-movie'"></i> {{ i18n.ts[content.type] }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</div>
</div>
<template v-else>
<img
v-if="(!originalContentLoaded || !thumbnailContentLoaded) && (content.file.thumbnailUrl != null)"
:class="[$style.content, $style.thumbnail]"
:src="content.file.thumbnailUrl"
draggable="false"
@load="thumbnailContentLoaded = true"
>
<template v-if="activated">
<img
v-if="content.type === 'image'"
:class="$style.content"
:src="content.file.url"
draggable="false"
@load="originalContentLoaded = true"
>
<video
v-else-if="content.type === 'video'"
ref="videoEl"
:class="$style.content"
:src="content.file.url"
draggable="false"
:controls="prefer.s.useNativeUiForVideoAudioPlayer"
loop
playsinline
@loadedmetadata="originalContentLoaded = true"
></video>
</template>
<div v-if="activated && !originalContentLoaded" :class="$style.loading">
<MkLoading/>
</div>
</template>
</div>
</div>
</div>
@@ -70,21 +90,24 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.title" class="_acrylic">
<button class="_button" :class="$style.titleButton"><i class="ti ti-dots" @click="openMenu"></i></button>
<div style="flex: 1; min-width: 0;">
<MkCondensedLine :minScale="0.5">{{ content.comment ?? content.filename }}</MkCondensedLine>
<MkCondensedLine :minScale="0.5">{{ content.file.name }}</MkCondensedLine>
</div>
<button class="_button" :class="$style.titleButton"><i class="ti ti-x" @click="closeThis"></i></button>
</div>
</div>
<div :class="[$style.footer, { [$style.infoShowing]: infoShowing && !isZooming }]">
<div v-if="content.type === 'video'" :class="$style.mediaControl">
<MkVideoContol v-if="videoEl != null" :videoElId="videoElId"/>
<div v-if="content.type === 'video' && !hide && !prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.mediaControl">
<MkVideoContol v-if="videoEl != null" :videoEl="videoEl"/>
</div>
</div>
</div>
</template>
<script lang="ts">
import * as Misskey from 'misskey-js';
import MkBlurhash from './MkBlurhash.vue';
type Size = {
width: number;
height: number;
@@ -98,12 +121,9 @@ type Rect = Size & {
export type Content = {
id: string;
type: 'image' | 'video';
url: string;
thumbnailUrl?: string | null;
width?: number | null;
height?: number | null;
filename?: string | null;
comment?: string | null;
width?: number;
height?: number;
file: Misskey.entities.DriveFile;
sourceElement?: HTMLElement | null;
};
@@ -134,18 +154,23 @@ export function calculateSourceTransform({
</script>
<script lang="ts" setup>
import { markRaw, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import MkVideoContol from './MkVideoContol.vue';
import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import { i18n } from '@/i18n.js';
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
import { makeDoubleTapDetector } from '@/utility/double-tap.js';
import { deviceKind } from '@/utility/device-kind.js';
import { isTouchUsing } from '@/utility/touch.js';
import { genId } from '@/utility/id.js';
import { getFileMenu } from '@/utility/get-file-menu.js';
const props = withDefaults(defineProps<{
content: Content;
activated: boolean;
initiallyOpened?: boolean;
}>(), {
initiallyOpened: false,
});
const emit = defineEmits<{
@@ -159,20 +184,14 @@ const emit = defineEmits<{
const rootEl = useTemplateRef('rootEl');
const mainEl = useTemplateRef('mainEl');
const videoEl = useTemplateRef('videoEl');
const videoElId = genId();
const originalContentLoaded = ref(false);
const thumbnailContentLoaded = ref(false);
const enableTransition = ref(false);
const infoShowing = ref(false);
const hide = ref(true);
let canOpenAnimation = false;
onMounted(() => {
if (rootEl.value == null) return;
rootEl.value.offsetHeight; // reflow
infoShowing.value = true;
});
const headerSize = 30;
const footerSize = props.content.type === 'video' ? 80 : 0;
@@ -212,6 +231,33 @@ const contentRenderingRect = contentRenderingSize != null ? {
width: contentRenderingSize.width,
height: contentRenderingSize.height,
} : null;
const hiddenStyle = computed(() => {
if (contentRenderingSize == null) {
return {
width: '100%',
height: '100%',
};
}
return {
width: `${contentRenderingSize.width}px`,
height: `${contentRenderingSize.height}px`,
};
});
function shouldHideInGallery(content: Content): boolean {
const hiddenByDefault = shouldHideFileByDefault(content.file, true);
if (!hiddenByDefault) return false;
// ギャラリー起動時に最初に開いたセンシティブ画像だけは初期表示で隠さない
if (content.file.isSensitive && prefer.s.nsfw !== 'force' && props.initiallyOpened) {
return false;
}
return true;
}
const transform = ref({ x: 0, y: 0, scale: 1 });
// 元のimg要素の位置・サイズ(とobject-fitの設定値)を取得して、そこからneutralの位置にアニメーションするためのscaleとtranslationを計算する
@@ -293,11 +339,6 @@ function closeThis() {
}
}
onMounted(() => {
rootEl.value.offsetHeight; // reflow
hideForFallback.value = false;
});
function onWheel(event: WheelEvent) {
event.preventDefault();
@@ -546,26 +587,53 @@ watch(isZooming, () => {
});
//#endregion
watch(thumbnailContentLoaded, () => {
function animateFromSourceToNeutral() {
if (rootEl.value == null) return;
const sourceElement = props.content.sourceElement;
if (sourceElement != null && props.activated) {
enableTransition.value = true;
rootEl.value.offsetHeight; // reflow
transform.value.x = 0;
transform.value.y = 0;
transform.value.scale = 1;
if (sourceElement == null || !props.activated) return;
nextTick(() => {
sourceElement.style.visibility = 'hidden';
});
}
enableTransition.value = true;
rootEl.value.offsetHeight; // reflow
transform.value.x = 0;
transform.value.y = 0;
transform.value.scale = 1;
nextTick(() => {
sourceElement.style.visibility = 'hidden';
});
}
watch(thumbnailContentLoaded, () => {
animateFromSourceToNeutral();
}, { once: true });
function onCLick() {
watch([rootEl, hide], ([newRootEl, isHidden]) => {
if (newRootEl == null || !isHidden) return;
animateFromSourceToNeutral();
}, { immediate: true });
watch(props.content, (newContent) => {
hide.value = shouldHideInGallery(newContent);
}, { deep: true, immediate: true });
watch(rootEl, (newRootEl) => {
if (newRootEl == null) return;
infoShowing.value = true;
newRootEl.offsetHeight; // reflow
hideForFallback.value = false;
}, { immediate: true });
async function onClick() {
if (!isClick) return;
if (!isZooming.value && hide.value) {
if (await canRevealFile(props.content.file)) {
hide.value = false;
}
}
if (!isTouchUsing) {
if (isZooming.value) {
isZooming.value = false;
@@ -576,9 +644,31 @@ function onCLick() {
}
}
function openMenu() {
// TODO
function openMenu(ev: PointerEvent) {
os.popupMenu(getFileMenu(props.content.file), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
function onActive() {
console.log('onActive');
if (videoEl.value != null) {
videoEl.value.play();
}
}
function onDeactive() {
if (isZooming.value) {
isZooming.value = false;
resetToNeutral();
}
if (videoEl.value != null && props.activated) {
videoEl.value.pause();
}
}
defineExpose({
onActive,
onDeactive,
});
</script>
<style lang="scss" module>
@@ -646,6 +736,45 @@ function openMenu() {
opacity: 0 !important;
}
.hidden {
position: absolute;
inset: 0;
margin: auto;
&.sensitive::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
border-radius: inherit;
box-shadow: inset 0 0 0 4px var(--MI_THEME-warn);
}
}
.hiddenBlurhash {
display: block;
width: 100%;
height: 100%;
filter: brightness(0.7);
}
.hiddenText {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
color: #fff;
}
.footer {
position: absolute;
bottom: v-bind("-footerSize + 'px'");

View File

@@ -25,9 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
@transitionend.self="onSlideTransitionFinished"
@transitioncancel.self="onSlideTransitionFinished"
>
<div v-for="(content, i) in contents" :key="content.url" ref="itemEl" :class="$style.item">
<div v-for="(content, i) in contents" :key="content.file.url" ref="itemEl" :class="$style.item">
<XItem
:ref="(comp) => { items.set(i, comp as InstanceType<typeof XItem>); }"
:content="content"
:initiallyOpened="i === (props.defaultIndex ?? 0)"
:activated="activatedIndexes.has(i)"
@close="onItemClose"
@horizontalSwipe="onHorizontalSwipe"
@@ -46,11 +48,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import { ref, watch, nextTick } from 'vue';
import XItem from './MkImageGallery.item.vue';
import type { Content } from './MkImageGallery.item.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { isTouchUsing } from '@/utility/touch.js';
@@ -65,10 +66,22 @@ const emit = defineEmits<{
}>();
const activatedIndexes = ref(new Set<number>());
const items = new Map<number, InstanceType<typeof XItem>>();
const currentIndex = ref(props.defaultIndex ?? 0);
watch(currentIndex, (newIndex) => {
watch(currentIndex, (newIndex, oldIndex) => {
activatedIndexes.value.add(newIndex);
nextTick(() => {
if (oldIndex != null && items.has(oldIndex)) {
items.get(oldIndex)!.onDeactive();
}
if (items.has(newIndex)) {
items.get(newIndex)!.onActive();
}
});
}, { immediate: true });
watch(currentIndex, (newIndex) => {
for (let i = 0; i < props.contents.length; i++) {
const content = props.contents[i];

View File

@@ -14,18 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only
:enterToClass="prefer.s.animation && props.transition?.enterToClass || undefined"
:leaveFromClass="prefer.s.animation && props.transition?.leaveFromClass || undefined"
>
<canvas
v-show="hide"
<MkBlurhash
key="canvas"
ref="canvas"
:class="$style.canvas"
:width="canvasWidth"
:height="canvasHeight"
:title="title ?? undefined"
draggable="false"
tabindex="-1"
style="-webkit-user-drag: none;"
></canvas>
:blurhash="hash ?? null"
:height="imgHeight ?? undefined"
:width="imgWidth ?? undefined"
:onlyAvgColor="props.onlyAvgColor"
:show="hide"
/>
<img
v-show="!hide"
key="img"
@@ -47,50 +44,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<script lang="ts">
import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
// テスト環境で Web Worker インスタンスは作成できない
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const isTest = (import.meta.env.MODE === 'test' || window.isPlaywright);
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
if (isTest) {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
return;
}
const testWorker = new TestWebGL2();
testWorker.addEventListener('message', event => {
if (event.data.result) {
const workers = new WorkerMultiDispatch(
() => new DrawBlurhash(),
Math.min(navigator.hardwareConcurrency - 1, 4),
);
resolve(workers);
} else {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
}
testWorker.terminate();
});
});
</script>
<script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue';
import { render } from 'buraha';
import { genId } from '@/utility/id.js';
import { computed, nextTick, useTemplateRef, watch, ref } from 'vue';
import { prefer } from '@/preferences.js';
import MkBlurhash from '@/components/MkBlurhash.vue';
const props = withDefaults(defineProps<{
transition?: {
@@ -124,16 +81,11 @@ const props = withDefaults(defineProps<{
onlyAvgColor: false,
});
const viewId = genId();
const canvas = useTemplateRef('canvas');
const root = useTemplateRef('root');
const img = useTemplateRef('img');
const loaded = ref(false);
const canvasWidth = ref(64);
const canvasHeight = ref(64);
const imgWidth = ref(props.width);
const imgHeight = ref(props.height);
const bitmapTmp = ref<CanvasImageSource | undefined>();
const hide = computed(() => !loaded.value || props.forceBlurhash);
function waitForDecode() {
@@ -152,14 +104,6 @@ function waitForDecode() {
watch([() => props.width, () => props.height, root], () => {
const ratio = props.width / props.height;
if (ratio > 1) {
canvasWidth.value = Math.round(64 * ratio);
canvasHeight.value = 64;
} else {
canvasWidth.value = 64;
canvasHeight.value = Math.round(64 / ratio);
}
const clientWidth = root.value?.clientWidth ?? 300;
imgWidth.value = clientWidth;
imgHeight.value = Math.round(clientWidth / ratio);
@@ -167,97 +111,10 @@ watch([() => props.width, () => props.height, root], () => {
immediate: true,
});
function drawImage(bitmap: CanvasImageSource) {
// canvasがないmountedされていない場合はTmpに保存しておく
if (!canvas.value) {
bitmapTmp.value = bitmap;
return;
}
// canvasがあれば描画する
bitmapTmp.value = undefined;
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
}
function drawAvg() {
if (!canvas.value) return;
const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888';
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
// avgColorでお茶をにごす
ctx.beginPath();
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
}
async function draw() {
if (isTest && props.hash == null) return;
drawAvg();
if (props.hash == null) return;
if (props.onlyAvgColor) return;
const work = await canvasPromise;
if (work instanceof WorkerMultiDispatch) {
work.postMessage(
{
id: viewId,
hash: props.hash,
},
undefined,
);
} else {
try {
render(props.hash, work);
drawImage(work);
} catch (error) {
console.error('Error occurred during drawing blurhash', error);
}
}
}
function workerOnMessage(event: MessageEvent) {
if (event.data.id !== viewId) return;
drawImage(event.data.bitmap as ImageBitmap);
}
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.addListener(workerOnMessage);
}
draw();
});
watch(() => props.src, () => {
waitForDecode();
});
watch(() => props.hash, () => {
draw();
});
onMounted(() => {
// drawImageがmountedより先に呼ばれている場合はここで描画する
if (bitmapTmp.value) {
drawImage(bitmapTmp.value);
}
waitForDecode();
});
onUnmounted(() => {
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.removeListener(workerOnMessage);
}
});
}, {
immediate: true,
});
</script>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal" @contextmenu.stop="onContextmenu">
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onClick" @contextmenu.stop="onContextmenu">
<component
:is="disableImageLink ? 'div' : 'a'"
v-bind="disableImageLink ? {
@@ -70,16 +70,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { watch, ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import type { MenuItem } from '@/types/menu.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard';
import { getStaticImageUrl } from '@/utility/media-proxy.js';
import bytes from '@/filters/bytes.js';
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { $i, iAmModerator } from '@/i.js';
import { prefer } from '@/preferences.js';
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
import { getFileMenu } from '@/utility/get-file-menu.js';
const props = withDefaults(defineProps<{
image: Misskey.entities.DriveFile;
@@ -94,6 +92,10 @@ const props = withDefaults(defineProps<{
controls: true,
});
const emit = defineEmits<{
(event: 'mediaClick', ev: PointerEvent): void;
}>();
const hide = ref(true);
const url = computed(() => (props.raw || prefer.s.loadRawImages)
@@ -103,8 +105,9 @@ const url = computed(() => (props.raw || prefer.s.loadRawImages)
: props.image.thumbnailUrl!,
);
async function reveal(ev: PointerEvent) {
async function onClick(ev: PointerEvent) {
if (!props.controls) {
emit('mediaClick', ev);
return;
}
@@ -115,6 +118,8 @@ async function reveal(ev: PointerEvent) {
}
hide.value = false;
} else {
emit('mediaClick', ev);
}
}
@@ -126,80 +131,12 @@ watch(() => props.image, (newImage) => {
immediate: true,
});
function getMenu() {
const menuItems: MenuItem[] = [];
menuItems.push({
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
hide.value = true;
},
});
if (iAmModerator) {
menuItems.push({
text: props.image.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: 'ti ti-eye-exclamation',
danger: true,
action: async () => {
const { canceled } = await os.confirm({
type: 'warning',
text: props.image.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
});
if (canceled) return;
os.apiWithDialog('drive/files/update', {
fileId: props.image.id,
isSensitive: !props.image.isSensitive,
});
},
});
}
const details: MenuItem[] = [];
if ($i?.id === props.image.userId) {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.image.id}`,
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${props.image.id}`,
});
}
if (details.length > 0) {
menuItems.push({ type: 'divider' }, ...details);
}
if (prefer.s.devMode) {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-hash',
text: i18n.ts.copyFileId,
action: () => {
copyToClipboard(props.image.id);
},
});
}
return menuItems;
}
function showMenu(ev: PointerEvent) {
os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
os.popupMenu(getFileMenu(props.image, (newHide) => { hide.value = newHide; }), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
function onContextmenu(ev: PointerEvent) {
os.contextMenu(getMenu(), ev);
os.contextMenu(getFileMenu(props.image, (newHide) => { hide.value = newHide; }), ev);
}
</script>

View File

@@ -20,8 +20,23 @@ SPDX-License-Identifier: AGPL-3.0-only
]"
>
<template v-for="media in mediaList.filter(media => previewable(media))">
<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.media" :video="media" @click="openGallery(media.id)"/>
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :marker="`${markerId}:${media.id}`" :disableImageLink="true" :class="$style.media" :image="media" :raw="raw" @click="openGallery(media.id)"/>
<XVideo
v-if="media.type.startsWith('video')"
:key="`video:${media.id}`"
:class="$style.media"
:video="media"
@mediaClick="openGallery(media.id)"
/>
<XImage
v-else-if="media.type.startsWith('image')"
:key="`image:${media.id}`"
:marker="`${markerId}:${media.id}`"
:disableImageLink="true"
:class="$style.media"
:image="media"
:raw="raw"
@mediaClick="openGallery(media.id)"
/>
</template>
</div>
</div>
@@ -39,6 +54,7 @@ import * as os from '@/os.js';
import { focusParent } from '@/utility/focus.js';
import { prefer } from '@/preferences.js';
import { genId } from '@/utility/id.js';
import type { Content } from '@/components/MkImageGallery.item.vue';
const props = defineProps<{
mediaList: Misskey.entities.DriveFile[];
@@ -101,23 +117,23 @@ async function openGallery(id?: string) {
if (firstImage == null) return;
id = firstImage.id;
}
const getElementByMarker = (marker: string) => {
if (gallery.value == null) return null;
const found = gallery.value.querySelector(`[data-marker="${marker}"]`) as HTMLElement | null;
if (found == null) return null;
return markRaw(found);
};
const contents = props.mediaList.filter(media => previewable(media)).map(media => ({
const contents = props.mediaList.filter(media => previewable(media)).map<Content>(media => ({
id: media.id,
type: media.type.startsWith('video') ? 'video' : 'image',
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.properties.width ?? 0,
height: media.properties.height ?? 0,
filename: media.name,
comment: media.comment,
file: media,
sourceElement: getElementByMarker(`${markerId}:${media.id}`),
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageGallery.vue').then(x => x.default), {
defaultIndex: contents.findIndex(conten => conten.id === id),
contents: contents,

View File

@@ -1,793 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
ref="playerEl"
v-hotkey="keymap"
tabindex="0"
:class="[
$style.videoContainer,
controlsShowing && $style.active,
(video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
]"
@mouseover.passive="onMouseOver"
@mousemove.passive="onMouseMove"
@mouseleave.passive="onMouseLeave"
@contextmenu.stop
@keydown.stop
>
<button v-if="hide" :class="$style.hidden" @click="reveal">
<div :class="$style.hiddenTextWrapper">
<b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
<b v-else style="display: block;"><i class="ti ti-movie"></i> {{ prefer.s.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</button>
<div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :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(--MI_THEME-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"
:poster="video.thumbnailUrl ?? undefined"
:title="video.comment ?? undefined"
:alt="video.comment"
preload="metadata"
playsinline
@keydown.prevent
@click.self="togglePlayPause"
>
<source :src="video.url">
</video>
<button v-if="isReady && !isPlaying" class="_button" :class="$style.videoOverlayPlayButton" @click="togglePlayPause"><i class="ti ti-player-play-filled"></i></button>
<div v-else-if="!isActuallyPlaying" :class="$style.videoLoading">
<MkLoading/>
</div>
<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(--MI_THEME-warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
</div>
<div :class="$style.videoControls" @click.self="togglePlayPause">
<div :class="[$style.controlsChild, $style.controlsLeft]">
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
<i v-else class="ti ti-player-play-filled"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsRight]">
<button class="_button" :class="$style.controlButton" @click="showMenu">
<i class="ti ti-settings"></i>
</button>
<button class="_button" :class="$style.controlButton" @click="toggleFullscreen">
<i v-if="isFullscreen" class="ti ti-arrows-minimize"></i>
<i v-else class="ti ti-arrows-maximize"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
<div :class="[$style.controlsChild, $style.controlsVolume]">
<button class="_button" :class="$style.controlButton" @click="toggleMute">
<i v-if="volume === 0" class="ti ti-volume-3"></i>
<i v-else class="ti ti-volume"></i>
</button>
<MkMediaRange
v-model="volume"
:sliderBgWhite="true"
:class="$style.volumeSeekbar"
/>
</div>
<MkMediaRange
v-model="rangePercent"
:sliderBgWhite="true"
:class="$style.seekbarRoot"
:buffer="bufferedDataRatio"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import type { MenuItem } from '@/types/menu.js';
import type { Keymap } from '@/utility/hotkey.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { exitFullscreen, requestFullscreen } from '@/utility/fullscreen.js';
import hasAudio from '@/utility/media-has-audio.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import { $i, iAmModerator } from '@/i.js';
import { prefer } from '@/preferences.js';
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
const props = defineProps<{
video: Misskey.entities.DriveFile;
}>();
const keymap = {
'up': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
},
},
'down': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
},
},
'left': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
}
},
},
'right': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
}
},
},
'space': () => {
if (hasFocus()) {
togglePlayPause();
}
},
} as const satisfies Keymap;
// PlayerElもしくはその子要素にフォーカスがあるかどうか
function hasFocus() {
if (!playerEl.value) return false;
return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement);
}
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const hide = ref(shouldHideFileByDefault(props.video));
async function reveal() {
if (!(await canRevealFile(props.video))) {
return;
}
hide.value = false;
}
// Menu
const menuShowing = ref(false);
function showMenu(ev: PointerEvent) {
const menu: MenuItem[] = [
// 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: [{
label: '0.25x',
value: 0.25,
}, {
label: '0.5x',
value: 0.5,
}, {
label: '0.75x',
value: 0.75,
}, {
label: '1.0x',
value: 1,
}, {
label: '1.25x',
value: 1.25,
}, {
label: '1.5x',
value: 1.5,
}, {
label: '2.0x',
value: 2,
}],
},
...(window.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: () => {
hide.value = true;
},
},
];
if (iAmModerator) {
menu.push({
text: props.video.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: props.video.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
danger: true,
action: () => toggleSensitive(props.video),
});
}
const details: MenuItem[] = [];
if ($i?.id === props.video.userId) {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.video.id}`,
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${props.video.id}`,
});
}
if (details.length > 0) {
menu.push({ type: 'divider' }, ...details);
}
if (prefer.s.devMode) {
menu.push({ type: 'divider' }, {
icon: 'ti ti-hash',
text: i18n.ts.copyFileId,
action: () => {
copyToClipboard(props.video.id);
},
});
}
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
align: 'right',
onClosing: () => {
menuShowing.value = false;
},
});
}
async function toggleSensitive(file: Misskey.entities.DriveFile) {
const { canceled } = await os.confirm({
type: 'warning',
text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
});
if (canceled) return;
os.apiWithDialog('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive,
});
}
// MediaControl: Video State
const videoEl = useTemplateRef('videoEl');
const playerEl = useTemplateRef('playerEl');
const isHoverring = ref(false);
const controlsShowing = computed(() => {
if (!oncePlayed.value) return true;
if (isHoverring.value) return true;
if (menuShowing.value) return true;
return false;
});
const isFullscreen = ref(false);
let controlStateTimer: number | null = null;
// MediaControl: Common State
const oncePlayed = ref(false);
const isReady = ref(false);
const isPlaying = ref(false);
const isActuallyPlaying = ref(false);
const elapsedTimeMs = ref(0);
const durationMs = ref(0);
const rangePercent = computed({
get: () => {
return (elapsedTimeMs.value / durationMs.value) || 0;
},
set: (to) => {
if (!videoEl.value) return;
videoEl.value.currentTime = to * durationMs.value / 1000;
},
});
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;
return bufferedEnd.value / videoEl.value.duration;
});
// MediaControl Events
function onMouseOver() {
if (controlStateTimer) {
window.clearTimeout(controlStateTimer);
}
isHoverring.value = true;
controlStateTimer = window.setTimeout(() => {
isHoverring.value = false;
}, 3000);
}
function onMouseMove() {
if (controlStateTimer) {
window.clearTimeout(controlStateTimer);
}
isHoverring.value = true;
controlStateTimer = window.setTimeout(() => {
isHoverring.value = false;
}, 3000);
}
function onMouseLeave() {
if (controlStateTimer) {
window.clearTimeout(controlStateTimer);
}
controlStateTimer = window.setTimeout(() => {
isHoverring.value = false;
}, 100);
}
function togglePlayPause() {
if (!isReady.value || !videoEl.value) return;
if (isPlaying.value) {
videoEl.value.pause();
isPlaying.value = false;
} else {
videoEl.value.play();
isPlaying.value = true;
oncePlayed.value = true;
}
}
function toggleFullscreen() {
if (playerEl.value == null || videoEl.value == null) return;
if (isFullscreen.value) {
exitFullscreen({
videoEl: videoEl.value,
});
isFullscreen.value = false;
} else {
requestFullscreen({
videoEl: videoEl.value,
playerEl: playerEl.value,
options: {
navigationUI: 'hide',
},
});
isFullscreen.value = true;
}
}
function togglePictureInPicture() {
if (videoEl.value) {
if (window.document.pictureInPictureElement) {
window.document.exitPictureInPicture();
} else {
videoEl.value.requestPictureInPicture();
}
}
}
function toggleMute() {
if (volume.value === 0) {
volume.value = .25;
} else {
volume.value = 0;
}
}
let onceInit = false;
let mediaTickFrameId: number | null = null;
let stopVideoElWatch: () => void;
function init() {
if (onceInit) return;
onceInit = true;
stopVideoElWatch = watch(videoEl, () => {
if (videoEl.value) {
isReady.value = true;
function updateMediaTick() {
if (videoEl.value) {
try {
bufferedEnd.value = videoEl.value.buffered.end(0);
} catch (err) {
bufferedEnd.value = 0;
}
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
if (videoEl.value.loop !== loop.value) {
loop.value = videoEl.value.loop;
}
}
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
}
updateMediaTick();
videoEl.value.addEventListener('play', () => {
isActuallyPlaying.value = true;
});
videoEl.value.addEventListener('pause', () => {
isActuallyPlaying.value = false;
isPlaying.value = false;
});
videoEl.value.addEventListener('ended', () => {
oncePlayed.value = false;
isActuallyPlaying.value = false;
isPlaying.value = false;
});
durationMs.value = videoEl.value.duration * 1000;
videoEl.value.addEventListener('durationchange', () => {
if (videoEl.value) {
durationMs.value = videoEl.value.duration * 1000;
}
});
videoEl.value.volume = volume.value;
hasAudio(videoEl.value).then(had => {
if (!had && videoEl.value) {
videoEl.value.loop = videoEl.value.muted = true;
videoEl.value.play();
}
});
}
}, {
immediate: true,
});
}
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 (videoEl.value && to && isFullscreen.value) {
exitFullscreen({
videoEl: videoEl.value,
});
isFullscreen.value = false;
}
});
onMounted(() => {
init();
});
onActivated(() => {
init();
});
onDeactivated(() => {
isReady.value = false;
isPlaying.value = false;
isActuallyPlaying.value = false;
elapsedTimeMs.value = 0;
durationMs.value = 0;
bufferedEnd.value = 0;
hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore');
stopVideoElWatch();
onceInit = false;
if (mediaTickFrameId) {
window.cancelAnimationFrame(mediaTickFrameId);
mediaTickFrameId = null;
}
if (controlStateTimer) {
window.clearTimeout(controlStateTimer);
controlStateTimer = null;
}
});
</script>
<style lang="scss" module>
.videoContainer {
container-type: inline-size;
position: relative;
overflow: clip;
&:focus-visible {
outline: none;
}
}
.sensitive {
position: relative;
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
border-radius: inherit;
box-shadow: inset 0 0 0 4px var(--MI_THEME-warn);
}
}
.indicators {
display: inline-flex;
position: absolute;
top: 10px;
left: 10px;
pointer-events: none;
opacity: .5;
gap: 6px;
}
.indicator {
/* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */
background-color: black;
border-radius: 6px;
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
display: inline-block;
font-weight: bold;
font-size: 0.8em;
padding: 2px 5px;
}
.hide {
display: block;
position: absolute;
border-radius: 6px;
background-color: var(--MI_THEME-fg);
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
font-size: 12px;
opacity: .5;
padding: 5px 8px;
text-align: center;
cursor: pointer;
top: 12px;
right: 12px;
}
.hidden {
width: 100%;
height: 100%;
background: #000;
border: none;
outline: none;
font: inherit;
color: inherit;
cursor: pointer;
padding: 60px 0;
display: flex;
align-items: center;
justify-content: center;
}
.hiddenTextWrapper {
text-align: center;
font-size: 0.8em;
color: #fff;
}
.videoRoot {
background: #000;
position: relative;
width: 100%;
height: 100%;
object-fit: contain;
}
.video {
display: block;
height: 100%;
width: 100%;
}
.videoOverlayPlayButton {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
opacity: 0;
transition: opacity .4s ease-in-out;
background: var(--MI_THEME-accent);
color: #fff;
padding: 1rem;
border-radius: 99rem;
font-size: 1.1rem;
&:focus-visible {
outline: none;
}
}
.videoLoading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.videoControls {
display: grid;
grid-template-areas:
"left time . volume right"
"seekbar seekbar seekbar seekbar seekbar";
grid-template-columns: auto auto 1fr auto auto;
align-items: center;
gap: 4px 8px;
padding: 35px 10px 10px 10px;
background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, .75));
position: absolute;
left: 0;
right: 0;
bottom: 0;
transform: translateY(100%);
pointer-events: none;
opacity: 0;
transition: opacity .4s ease-in-out, transform .4s ease-in-out;
}
.active {
.videoControls {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.videoOverlayPlayButton {
opacity: 1;
}
}
.controlsChild {
display: flex;
align-items: center;
gap: 4px;
color: #fff;
.controlButton {
padding: 6px;
border-radius: calc(var(--MI-radius) / 2);
transition: background-color .15s ease;
font-size: 1.05rem;
&:hover {
background-color: var(--MI_THEME-accent);
}
&:focus-visible {
outline: none;
}
}
}
.controlsLeft {
grid-area: left;
}
.controlsRight {
grid-area: right;
}
.controlsTime {
grid-area: time;
font-size: .9rem;
}
.controlsVolume {
grid-area: volume;
.volumeSeekbar {
display: none;
}
}
.seekbarRoot {
grid-area: seekbar;
/* ▼シークバー操作をやりやすくするためにクリックイベントが伝播されないエリアを拡張する */
margin: -10px;
padding: 10px;
}
@container (min-width: 500px) {
.videoControls {
grid-template-areas: "left seekbar time volume right";
grid-template-columns: auto 1fr auto auto auto;
}
.controlsVolume {
.volumeSeekbar {
max-width: 90px;
display: block;
flex-grow: 1;
}
}
}
@container (max-width: 300px) {
.videoControls {
grid-template-areas:
"left . right"
"seekbar seekbar seekbar";
grid-template-columns: auto 1fr auto;
}
.controlsTime {
display: none;
}
.controlsVolume {
display: none;
}
}
</style>

View File

@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
$style.videoContainer,
(video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
]"
@contextmenu.stop
@contextmenu.stop="onContextmenu"
@keydown.stop
>
<button v-if="hide" :class="$style.hidden" @click="reveal">
@@ -22,9 +22,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</button>
<div v-else :class="$style.videoRoot">
<div v-else :class="$style.videoRoot" @click="emit('mediaClick', $event)">
<img
v-if="video.thumbnailUrl"
:class="$style.video"
:src="video.thumbnailUrl"
:alt="video.comment ?? undefined"
/>
<video
ref="videoEl"
v-else
:class="$style.video"
:poster="video.thumbnailUrl ?? undefined"
:alt="video.comment"
@@ -33,25 +39,33 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<source :src="video.url">
</video>
<i class="ti ti-player-play-filled"></i>
<button class="_button" :class="$style.videoOverlayPlayButton">
<i class="ti ti-player-play-filled"></i>
</button>
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button>
<i class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import * as os from '@/os.js';
import { getFileMenu } from '@/utility/get-file-menu.js';
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
const props = defineProps<{
video: Misskey.entities.DriveFile;
}>();
const emit = defineEmits<{
(event: 'mediaClick', ev: PointerEvent): void;
}>();
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const hide = ref(shouldHideFileByDefault(props.video));
@@ -62,6 +76,14 @@ async function reveal() {
hide.value = false;
}
function showMenu(ev: PointerEvent) {
os.popupMenu(getFileMenu(props.video, (newHide) => { hide.value = newHide; }), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
function onContextmenu(ev: PointerEvent) {
os.contextMenu(getFileMenu(props.video, (newHide) => { hide.value = newHide; }), ev);
}
</script>
<style lang="scss" module>
@@ -91,42 +113,6 @@ async function reveal() {
}
}
.indicators {
display: inline-flex;
position: absolute;
top: 10px;
left: 10px;
pointer-events: none;
opacity: .5;
gap: 6px;
}
.indicator {
/* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */
background-color: black;
border-radius: 6px;
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
display: inline-block;
font-weight: bold;
font-size: 0.8em;
padding: 2px 5px;
}
.hide {
display: block;
position: absolute;
border-radius: 6px;
background-color: var(--MI_THEME-fg);
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
font-size: 12px;
opacity: .5;
padding: 5px 8px;
text-align: center;
cursor: pointer;
top: 12px;
right: 12px;
}
.hidden {
width: 100%;
height: 100%;
@@ -160,6 +146,7 @@ async function reveal() {
display: block;
height: 100%;
width: 100%;
object-fit: contain;
}
.videoOverlayPlayButton {
@@ -168,29 +155,49 @@ async function reveal() {
left: 50%;
transform: translate(-50%,-50%);
opacity: 0;
transition: opacity .4s ease-in-out;
background: var(--MI_THEME-accent);
color: #fff;
color: var(--MI_THEME-fgOnAccent);
padding: 1rem;
border-radius: 99rem;
font-size: 1.1rem;
pointer-events: none;
&:focus-visible {
outline: none;
}
}
.videoLoading {
.menu {
display: block;
position: absolute;
background-color: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-radius: 9px 0 0 0;
color: #fff;
font-size: 0.8em;
width: 28px;
height: 28px;
text-align: center;
bottom: 0;
right: 0;
}
.hide {
display: block;
position: absolute;
background-color: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-radius: 0 0 0 9px;
color: #fff;
font-size: 12px;
opacity: .5;
padding: 5px 8px;
text-align: center;
cursor: pointer;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
right: 0;
}
</style>

View File

@@ -43,22 +43,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
import { ref, computed, watch, onActivated, onMounted } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import { hms } from '@/filters/hms.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { exitFullscreen, requestFullscreen } from '@/utility/fullscreen.js';
import hasAudio from '@/utility/media-has-audio.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import { prefer } from '@/preferences.js';
const props = defineProps<{
videoElId: string;
videoEl: HTMLVideoElement;
}>();
const videoEl = window.document.getElementById(props.videoElId) as HTMLVideoElement;
// Menu
const menuShowing = ref(false);
@@ -132,7 +128,7 @@ const rangePercent = computed({
return (elapsedTimeMs.value / durationMs.value) || 0;
},
set: (to) => {
videoEl.currentTime = to * durationMs.value / 1000;
props.videoEl.currentTime = to * durationMs.value / 1000;
},
});
const volume = ref(.25);
@@ -140,46 +136,30 @@ const speed = ref(1);
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => {
return bufferedEnd.value / videoEl.duration;
return bufferedEnd.value / props.videoEl.duration;
});
function togglePlayPause() {
if (!isReady.value) return;
if (isPlaying.value) {
videoEl.pause();
props.videoEl.pause();
isPlaying.value = false;
} else {
videoEl.play();
props.videoEl.play();
isPlaying.value = true;
oncePlayed.value = true;
}
}
function toggleFullscreen() {
if (playerEl.value == null) return;
if (isFullscreen.value) {
exitFullscreen({
videoEl: videoEl,
});
isFullscreen.value = false;
} else {
requestFullscreen({
videoEl: videoEl,
playerEl: playerEl.value,
options: {
navigationUI: 'hide',
},
});
isFullscreen.value = true;
}
}
function togglePictureInPicture() {
if (window.document.pictureInPictureElement) {
window.document.exitPictureInPicture();
} else {
videoEl.requestPictureInPicture();
props.videoEl.requestPictureInPicture();
}
}
@@ -203,60 +183,60 @@ function init() {
function updateMediaTick() {
try {
bufferedEnd.value = videoEl.buffered.end(0);
bufferedEnd.value = props.videoEl.buffered.end(0);
} catch (err) {
bufferedEnd.value = 0;
}
elapsedTimeMs.value = videoEl.currentTime * 1000;
elapsedTimeMs.value = props.videoEl.currentTime * 1000;
if (videoEl.loop !== loop.value) {
loop.value = videoEl.loop;
if (props.videoEl.loop !== loop.value) {
loop.value = props.videoEl.loop;
}
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
}
updateMediaTick();
videoEl.addEventListener('play', () => {
props.videoEl.addEventListener('play', () => {
isActuallyPlaying.value = true;
});
videoEl.addEventListener('pause', () => {
props.videoEl.addEventListener('pause', () => {
isActuallyPlaying.value = false;
isPlaying.value = false;
});
videoEl.addEventListener('ended', () => {
props.videoEl.addEventListener('ended', () => {
oncePlayed.value = false;
isActuallyPlaying.value = false;
isPlaying.value = false;
});
durationMs.value = videoEl.duration * 1000;
videoEl.addEventListener('durationchange', () => {
durationMs.value = videoEl.duration * 1000;
durationMs.value = props.videoEl.duration * 1000;
props.videoEl.addEventListener('durationchange', () => {
durationMs.value = props.videoEl.duration * 1000;
});
videoEl.volume = volume.value;
hasAudio(videoEl).then(had => {
props.videoEl.volume = volume.value;
hasAudio(props.videoEl).then(had => {
if (!had) {
videoEl.loop = videoEl.muted = true;
videoEl.play();
props.videoEl.loop = props.videoEl.muted = true;
props.videoEl.play();
}
});
}
watch(volume, (to) => {
videoEl.volume = to;
props.videoEl.volume = to;
});
watch(speed, (to) => {
videoEl.playbackRate = to;
props.videoEl.playbackRate = to;
});
watch(loop, (to) => {
videoEl.loop = to;
props.videoEl.loop = to;
});
onMounted(() => {

View File

@@ -0,0 +1,78 @@
import * as Misskey from 'misskey-js';
import { $i, iAmModerator } from '@/i.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import * as os from '@/os.js';
import type { MenuItem } from '@/types/menu.js';
import type { Content } from '@/components/MkImageGallery.item.vue';
export function getFileMenu(file: Misskey.entities.DriveFile, onHideStateUpdated?: (newState: boolean) => void): MenuItem[] {
const menuItems: MenuItem[] = [];
if (onHideStateUpdated != null) {
menuItems.push({
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
onHideStateUpdated(true);
},
});
}
if (iAmModerator) {
menuItems.push({
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: 'ti ti-eye-exclamation',
danger: true,
action: async () => {
const { canceled } = await os.confirm({
type: 'warning',
text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
});
if (canceled) return;
os.apiWithDialog('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive,
});
},
});
}
const details: MenuItem[] = [];
if ($i?.id === file.userId) {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${file.id}`,
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${file.id}`,
});
}
if (details.length > 0) {
menuItems.push({ type: 'divider' }, ...details);
}
if (prefer.s.devMode) {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-hash',
text: i18n.ts.copyFileId,
action: () => {
copyToClipboard(file.id);
},
});
}
return menuItems;
}

View File

@@ -8,8 +8,8 @@ import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import { i18n } from '@/i18n.js';
export function shouldHideFileByDefault(file: Misskey.entities.DriveFile): boolean {
if (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) {
export function shouldHideFileByDefault(file: Misskey.entities.DriveFile, ignoreDataSaver = false): boolean {
if (prefer.s.nsfw === 'force' || (!ignoreDataSaver && prefer.s.dataSaver.media)) {
return true;
}