mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-26 16:54:37 +02:00
wip
This commit is contained in:
183
packages/frontend/src/components/MkBlurhash.vue
Normal file
183
packages/frontend/src/components/MkBlurhash.vue
Normal 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>
|
||||
@@ -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'");
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
78
packages/frontend/src/utility/get-file-menu.ts
Normal file
78
packages/frontend/src/utility/get-file-menu.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user