mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-11 20:14:42 +02:00
Compare commits
71 Commits
frontend-b
...
improve-im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aabd9e9cba | ||
|
|
7a31bfdbc2 | ||
|
|
3ba648b3d5 | ||
|
|
8f5949e1a2 | ||
|
|
37213d1c43 | ||
|
|
520ede4d63 | ||
|
|
ef034dc2ab | ||
|
|
491eba6772 | ||
|
|
9db840c17b | ||
|
|
5d92214ccc | ||
|
|
d55112ec7b | ||
|
|
9f37206f14 | ||
|
|
292005b27b | ||
|
|
43caa68594 | ||
|
|
1538df1c74 | ||
|
|
ea7f97c07a | ||
|
|
159d4e00dc | ||
|
|
955418a2e1 | ||
|
|
bebd318730 | ||
|
|
bf7986f2a4 | ||
|
|
10df70b3f5 | ||
|
|
a21a93190e | ||
|
|
afb72ac3b0 | ||
|
|
0ad2606ee5 | ||
|
|
734a19c06f | ||
|
|
7f3cbace05 | ||
|
|
4a941252f8 | ||
|
|
2335eb7b7f | ||
|
|
4e0aa269eb | ||
|
|
abae0b5eb1 | ||
|
|
4f6830fdd6 | ||
|
|
be09eb9d47 | ||
|
|
1398686cb7 | ||
|
|
bb315a8cbc | ||
|
|
b7be82bd41 | ||
|
|
965a7bcc41 | ||
|
|
fc59574f58 | ||
|
|
c8229fc5f2 | ||
|
|
7dd9574d32 | ||
|
|
c5dd17e7e2 | ||
|
|
a129b453b0 | ||
|
|
392fb2c40d | ||
|
|
3ecb2b4d13 | ||
|
|
f3dcca2d31 | ||
|
|
327c447b9a | ||
|
|
62afbc32fb | ||
|
|
5677646bd5 | ||
|
|
53b4d184ac | ||
|
|
1cabf88445 | ||
|
|
b47303f6d6 | ||
|
|
b48321154f | ||
|
|
f70048bd6c | ||
|
|
4b592f9c94 | ||
|
|
f5f5eab09e | ||
|
|
324aea004d | ||
|
|
dc07f0c6de | ||
|
|
2e54afd3a7 | ||
|
|
e41aedeb06 | ||
|
|
c3f8c0a871 | ||
|
|
95901e5220 | ||
|
|
d5609abf25 | ||
|
|
cf3b206ef4 | ||
|
|
951e22242d | ||
|
|
cc356cf542 | ||
|
|
f0b3a7241d | ||
|
|
e7bc3821d1 | ||
|
|
d2647e066e | ||
|
|
b7ca70bd1e | ||
|
|
564ed21b74 | ||
|
|
5c7f547342 | ||
|
|
75e97da41e |
@@ -21,6 +21,15 @@
|
||||
### Client
|
||||
- 2025.4.0 以前の設定情報の移行処理が削除されました
|
||||
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
|
||||
- Enhance: 画像ビューワーを独自実装に変更・動画プレイヤーを統合
|
||||
- 操作性の改善
|
||||
- ビューワーとMisskeyの各種機能との統合を強化
|
||||
- パフォーマンスの向上
|
||||
- Enhance: マウスホイールで拡大縮小できるように
|
||||
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
|
||||
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
|
||||
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
|
||||
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
|
||||
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
|
||||
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
|
||||
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
|
||||
|
||||
@@ -3,6 +3,7 @@ import { version as summalyVersion } from '@misskey-dev/summaly';
|
||||
import type { Plugin, ExternalOption } from 'rolldown';
|
||||
import { execa, execaNode } from 'execa';
|
||||
import type { ResultPromise } from 'execa';
|
||||
import fkill from 'fkill';
|
||||
import esmShim from '@rollup/plugin-esm-shim';
|
||||
|
||||
/**
|
||||
@@ -10,6 +11,7 @@ import esmShim from '@rollup/plugin-esm-shim';
|
||||
*/
|
||||
function backendDevServerPlugin(): Plugin {
|
||||
let backendProcess: ResultPromise | null = null;
|
||||
let backendShutdownPromise: Promise<void> | null = null;
|
||||
|
||||
async function runBuildAssets() {
|
||||
await execa('pnpm', ['run', 'build-assets'], {
|
||||
@@ -20,12 +22,31 @@ function backendDevServerPlugin(): Plugin {
|
||||
}
|
||||
|
||||
async function killBackendProcess() {
|
||||
if (backendProcess) {
|
||||
backendProcess.catch(() => {}); // backendProcess.kill()によって発生する例外を無視するためにcatch()を呼び出す
|
||||
backendProcess.kill();
|
||||
await new Promise(resolve => backendProcess!.on('exit', resolve));
|
||||
backendProcess = null;
|
||||
}
|
||||
if (backendShutdownPromise) return backendShutdownPromise;
|
||||
if (!backendProcess) return;
|
||||
|
||||
const processToKill = backendProcess;
|
||||
backendProcess = null;
|
||||
processToKill.catch(() => {}); // プロセスの終了によって発生する例外を無視するためにcatch()を呼び出す
|
||||
|
||||
backendShutdownPromise = (async () => {
|
||||
if (process.platform === 'win32' && processToKill.pid != null) {
|
||||
await fkill(processToKill.pid, {
|
||||
force: true,
|
||||
tree: true,
|
||||
silent: true,
|
||||
waitForExit: 5000,
|
||||
});
|
||||
} else {
|
||||
processToKill.kill();
|
||||
}
|
||||
|
||||
await processToKill.catch(() => {});
|
||||
})().finally(() => {
|
||||
backendShutdownPromise = null;
|
||||
});
|
||||
|
||||
return backendShutdownPromise;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -49,6 +70,9 @@ function backendDevServerPlugin(): Plugin {
|
||||
await runBuildAssets();
|
||||
}
|
||||
},
|
||||
async closeWatcher() {
|
||||
await killBackendProcess();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ it('Composition API (standard)', () => {
|
||||
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
|
||||
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
|
||||
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
|
||||
import './photoswipe-!~{003}~.js';
|
||||
|
||||
const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
|
||||
const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
@@ -179,7 +178,6 @@ export { index_photos as default };
|
||||
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
|
||||
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
|
||||
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
|
||||
import './photoswipe-!~{003}~.js';
|
||||
|
||||
const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
|
||||
const index_photos = /* @__PURE__ */ defineComponent({
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"photoswipe": "5.4.4",
|
||||
"punycode.js": "2.3.1",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qr-scanner": "1.4.2",
|
||||
|
||||
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
|
||||
v-show="show"
|
||||
ref="canvas"
|
||||
: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>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root" class="_gaps_s">
|
||||
<MkKeyValue v-if="content.filename != null">
|
||||
<template #key>{{ i18n.ts.fileName }}</template>
|
||||
<template #value>{{ content.filename }}</template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="content.file != null">
|
||||
<template #key>{{ i18n.ts.description }}</template>
|
||||
<template #value>
|
||||
<div :class="$style.pre">{{ content.file.comment ? content.file.comment : `(${i18n.ts.none})` }}</div>
|
||||
</template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import type { Content } from '@/components/MkImageGallery.item.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
content: Content;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 4px 14px;
|
||||
}
|
||||
|
||||
.pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
1059
packages/frontend/src/components/MkImageGallery.item.vue
Normal file
1059
packages/frontend/src/components/MkImageGallery.item.vue
Normal file
File diff suppressed because it is too large
Load Diff
303
packages/frontend/src/components/MkImageGallery.vue
Normal file
303
packages/frontend/src/components/MkImageGallery.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<!-- durationは子Itemコンポーネントがフェードイン/アウトするdurationと合わせる -->
|
||||
<Transition
|
||||
:enterActiveClass="prefer.s.animation ? $style.transition_root_enterActive : ''"
|
||||
:leaveActiveClass="prefer.s.animation ? $style.transition_root_leaveActive : ''"
|
||||
:enterFromClass="prefer.s.animation ? $style.transition_root_enterFrom : ''"
|
||||
:leaveToClass="prefer.s.animation ? $style.transition_root_leaveTo : ''"
|
||||
:duration="{ enter: prefer.s.animation ? openAnimDuration : 0, leave: prefer.s.animation ? closeAnimDuration : 0 }"
|
||||
appear
|
||||
@afterLeave="onAfterLeave"
|
||||
>
|
||||
<!-- v-ifを使うとfalseになったとき(transitionが行われている間)子コンポーネントの更新が停止するのか子コンポーネントがアニメーションされなくなる -->
|
||||
<div v-show="showing" ref="rootEl" v-hotkey.global="keymap" :class="$style.root" :style="{ zIndex }">
|
||||
<div :class="[$style.bg]" class="_modalBg"></div>
|
||||
<div ref="mainEl" :class="$style.main">
|
||||
<div
|
||||
ref="itemsEl"
|
||||
:class="[$style.items, { [$style.itemsTransition]: enableSlideTransition }]"
|
||||
:style="{ translate: `${contentsOffset}px 0` }"
|
||||
@transitionend.self="onSlideTransitionFinished"
|
||||
@transitioncancel.self="onSlideTransitionFinished"
|
||||
>
|
||||
<div v-for="(content, i) in contents" :key="content.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"
|
||||
@prev="onPrev"
|
||||
@next="onNext"
|
||||
@cancelHorizontalSwipe="onCancelHorizontalSwipe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button v-if="!isTouchUsing && currentIndex > 0" class="_button" :class="[$style.prevButton]" @click="onPrev"><div :class="$style.buttonIcon"><i class="ti ti-arrow-left"></i></div></button>
|
||||
<button v-if="!isTouchUsing && currentIndex < contents.length - 1" class="_button" :class="[$style.nextButton]" @click="onNext"><div :class="$style.buttonIcon"><i class="ti ti-arrow-right"></i></div></button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, nextTick, onBeforeUnmount, onMounted } from 'vue';
|
||||
import XItem from './MkImageGallery.item.vue';
|
||||
import type { Content } from './MkImageGallery.item.vue';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
import * as os from '@/os.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { isTouchUsing } from '@/utility/touch.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultIndex?: number;
|
||||
contents: Content[];
|
||||
}>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const activatedIndexes = ref(new Set<number>());
|
||||
const items = new Map<number, InstanceType<typeof XItem>>();
|
||||
const currentIndex = ref(props.defaultIndex ?? 0);
|
||||
|
||||
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];
|
||||
if (content.sourceElement != null) {
|
||||
content.sourceElement.style.visibility = i === newIndex ? 'hidden' : '';
|
||||
}
|
||||
}
|
||||
}, { immediate: false });
|
||||
|
||||
const openAnimDuration = 200;
|
||||
const closeAnimDuration = 200;
|
||||
const slideAnimDuration = 300;
|
||||
const zIndex = os.claimZIndex('high');
|
||||
const showing = ref(true);
|
||||
const screenWidth = ref(window.innerWidth);
|
||||
const contentsOffset = ref(currentIndex.value * -window.innerWidth);
|
||||
const enableSlideTransition = ref(false);
|
||||
let currentScrollLeft = contentsOffset.value;
|
||||
|
||||
function onResize() {
|
||||
screenWidth.value = window.innerWidth;
|
||||
scrollToCurrentIndex();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', onResize, { passive: true });
|
||||
|
||||
function onHorizontalSwipe(offset: number) {
|
||||
if (currentIndex.value === 0 && offset > 0) { // これ以上戻れない
|
||||
contentsOffset.value = currentScrollLeft + (offset / 3);
|
||||
} else if (currentIndex.value === props.contents.length - 1 && offset < 0) { // これ以上進めない
|
||||
contentsOffset.value = currentScrollLeft + (offset / 3);
|
||||
} else {
|
||||
contentsOffset.value = currentScrollLeft + offset;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToCurrentIndex() {
|
||||
const targetOffset = currentIndex.value * -screenWidth.value;
|
||||
currentScrollLeft = targetOffset;
|
||||
|
||||
if (!prefer.s.animation || contentsOffset.value === targetOffset) {
|
||||
enableSlideTransition.value = false;
|
||||
contentsOffset.value = targetOffset;
|
||||
return;
|
||||
}
|
||||
|
||||
enableSlideTransition.value = true;
|
||||
contentsOffset.value = targetOffset;
|
||||
}
|
||||
|
||||
/** ギャラリーそのものを閉じるための内部処理(閉じる処理を書く場合は `close` か、item内では `closeThis` を使う) */
|
||||
function closeGallery() {
|
||||
showing.value = false;
|
||||
if (window.location.hash === '#pswp') {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (items.has(currentIndex.value)) {
|
||||
items.get(currentIndex.value)!.closeThis();
|
||||
} else {
|
||||
closeGallery();
|
||||
}
|
||||
}
|
||||
|
||||
function onSlideTransitionFinished(ev: TransitionEvent) {
|
||||
if (ev.propertyName !== 'translate') return;
|
||||
enableSlideTransition.value = false;
|
||||
}
|
||||
|
||||
function onCancelHorizontalSwipe() {
|
||||
scrollToCurrentIndex();
|
||||
}
|
||||
|
||||
function onNext() {
|
||||
if (currentIndex.value < props.contents.length - 1) {
|
||||
currentIndex.value++;
|
||||
}
|
||||
scrollToCurrentIndex();
|
||||
}
|
||||
|
||||
function onPrev() {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--;
|
||||
}
|
||||
scrollToCurrentIndex();
|
||||
}
|
||||
|
||||
function onItemClose() {
|
||||
closeGallery();
|
||||
}
|
||||
|
||||
function onAfterLeave() {
|
||||
for (const content of props.contents) {
|
||||
if (content.sourceElement != null) {
|
||||
content.sourceElement.style.visibility = '';
|
||||
}
|
||||
}
|
||||
emit('closed');
|
||||
}
|
||||
|
||||
function onPopState() {
|
||||
if (showing.value) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.history.pushState(null, '', '#pswp');
|
||||
window.addEventListener('popstate', onPopState);
|
||||
});
|
||||
|
||||
const keymap = {
|
||||
'esc': {
|
||||
allowRepeat: true,
|
||||
callback: () => onItemClose(),
|
||||
},
|
||||
'arrowleft': {
|
||||
allowRepeat: true,
|
||||
callback: () => onPrev(),
|
||||
},
|
||||
'arrowright': {
|
||||
allowRepeat: true,
|
||||
callback: () => onNext(),
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('popstate', onPopState);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.transition_root_enterActive,
|
||||
.transition_root_leaveActive {
|
||||
> .bg {
|
||||
transition: opacity v-bind("closeAnimDuration + 'ms'"); // 子Itemコンポーネントがフェードイン/アウトするdurationと合わせる
|
||||
}
|
||||
}
|
||||
.transition_root_enterFrom,
|
||||
.transition_root_leaveTo {
|
||||
pointer-events: none;
|
||||
> .bg {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.root {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bg {
|
||||
}
|
||||
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.items {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: calc(v-bind("screenWidth + 'px'") * v-bind("contents.length"));
|
||||
height: 100dvh;
|
||||
overflow: clip;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.itemsTransition {
|
||||
pointer-events: none;
|
||||
transition: translate v-bind("slideAnimDuration + 'ms'") cubic-bezier(0.45, 0, 0.55, 1);
|
||||
}
|
||||
|
||||
.item {
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
overflow: clip;
|
||||
contain: strict;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prevButton,
|
||||
.nextButton {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 70px;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.prevButton {
|
||||
left: 0;
|
||||
}
|
||||
.nextButton {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.buttonIcon {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 100%;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -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"
|
||||
@@ -36,6 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:src="src ?? undefined"
|
||||
:title="title ?? undefined"
|
||||
:alt="alt ?? undefined"
|
||||
:data-marker="marker ?? undefined"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
@@ -46,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 { genId } from '@/utility/id.js';
|
||||
import { render } from 'buraha';
|
||||
import { computed, nextTick, useTemplateRef, watch, ref } from 'vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import MkBlurhash from '@/components/MkBlurhash.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
transition?: {
|
||||
@@ -110,6 +68,7 @@ const props = withDefaults(defineProps<{
|
||||
cover?: boolean;
|
||||
forceBlurhash?: boolean;
|
||||
onlyAvgColor?: boolean; // 軽量化のためにBlurhashを使わずに平均色だけを描画
|
||||
marker?: string;
|
||||
}>(), {
|
||||
transition: null,
|
||||
src: null,
|
||||
@@ -122,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() {
|
||||
@@ -150,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);
|
||||
@@ -165,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>
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
tabindex="-1"
|
||||
@click.stop="togglePlayPause"
|
||||
>
|
||||
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
|
||||
<i v-else class="ti ti-player-play-filled"></i>
|
||||
<i v-if="isPlaying" class="ti ti-player-pause"></i>
|
||||
<i v-else class="ti ti-player-play"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
@@ -100,6 +100,7 @@ import { hms } from '@/filters/hms.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getFileMenu } from '@/utility/get-file-menu.js';
|
||||
import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -208,56 +209,11 @@ function showMenu(ev: MouseEvent) {
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
text: i18n.ts.hide,
|
||||
icon: 'ti ti-eye-off',
|
||||
action: () => {
|
||||
hide.value = true;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (iAmModerator) {
|
||||
menu.push({
|
||||
text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
|
||||
icon: props.audio.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
|
||||
danger: true,
|
||||
action: () => toggleSensitive(props.audio),
|
||||
});
|
||||
}
|
||||
|
||||
const details: MenuItem[] = [];
|
||||
if ($i?.id === props.audio.userId) {
|
||||
details.push({
|
||||
type: 'link',
|
||||
text: i18n.ts._fileViewer.title,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: `/my/drive/file/${props.audio.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (iAmModerator) {
|
||||
details.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.moderation,
|
||||
icon: 'ti ti-photo-exclamation',
|
||||
to: `/admin/file/${props.audio.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.audio.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
menu.push(...getFileMenu(props.audio, (newState) => {
|
||||
hide.value = newState;
|
||||
}));
|
||||
|
||||
menuShowing.value = true;
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
|
||||
@@ -268,20 +224,6 @@ function showMenu(ev: MouseEvent) {
|
||||
});
|
||||
}
|
||||
|
||||
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: Common State
|
||||
const oncePlayed = ref(false);
|
||||
const isReady = ref(false);
|
||||
|
||||
@@ -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 ? {
|
||||
@@ -29,6 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:height="image.properties.height"
|
||||
:style="hide ? 'filter: brightness(0.7);' : null"
|
||||
:class="$style.image"
|
||||
:marker="marker"
|
||||
/>
|
||||
<div
|
||||
v-else-if="prefer.s.dataSaver.media || hide"
|
||||
@@ -42,6 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:alt="image.comment || image.name"
|
||||
:title="image.comment || image.name"
|
||||
:class="$style.image"
|
||||
:data-marker="marker"
|
||||
/>
|
||||
</component>
|
||||
<template v-if="hide">
|
||||
@@ -68,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;
|
||||
@@ -85,12 +85,17 @@ const props = withDefaults(defineProps<{
|
||||
cover?: boolean;
|
||||
disableImageLink?: boolean;
|
||||
controls?: boolean;
|
||||
marker?: string;
|
||||
}>(), {
|
||||
cover: false,
|
||||
disableImageLink: false,
|
||||
controls: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'mediaClick', ev: PointerEvent): void;
|
||||
}>();
|
||||
|
||||
const hide = ref(true);
|
||||
|
||||
const url = computed(() => (props.raw || prefer.s.loadRawImages)
|
||||
@@ -100,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;
|
||||
}
|
||||
|
||||
@@ -112,6 +118,8 @@ async function reveal(ev: PointerEvent) {
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
} else {
|
||||
emit('mediaClick', ev);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,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"/>
|
||||
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/>
|
||||
<XVideo
|
||||
v-if="media.type.startsWith('video')"
|
||||
:key="`video:${media.id}`"
|
||||
:class="$style.media"
|
||||
:video="media"
|
||||
@mediaClick="onMediaClick(media)"
|
||||
/>
|
||||
<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="onMediaClick(media)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,18 +44,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import { computed, markRaw, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
||||
import PhotoSwipe from 'photoswipe';
|
||||
import 'photoswipe/style.css';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
|
||||
import XBanner from '@/components/MkMediaBanner.vue';
|
||||
import XImage from '@/components/MkMediaImage.vue';
|
||||
import XVideo from '@/components/MkMediaVideo.vue';
|
||||
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[];
|
||||
@@ -48,18 +61,8 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const gallery = useTemplateRef('gallery');
|
||||
const pswpZIndex = os.claimZIndex('middle');
|
||||
window.document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
|
||||
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
|
||||
let lightbox: PhotoSwipeLightbox | null = null;
|
||||
|
||||
let activeEl: HTMLElement | null = null;
|
||||
|
||||
const popstateHandler = (): void => {
|
||||
if (lightbox?.pswp && lightbox.pswp.isOpen === true) {
|
||||
lightbox.pswp.close();
|
||||
}
|
||||
};
|
||||
const markerId = genId();
|
||||
|
||||
async function calcAspectRatio() {
|
||||
if (!gallery.value) return;
|
||||
@@ -96,121 +99,9 @@ onMounted(() => {
|
||||
calcAspectRatio();
|
||||
|
||||
if (gallery.value == null) return; // TSを黙らすため
|
||||
|
||||
lightbox = new PhotoSwipeLightbox({
|
||||
dataSource: props.mediaList
|
||||
.filter(media => {
|
||||
if (media.type === 'image/svg+xml') return true; // svgのwebpublicはpngなのでtrue
|
||||
return media.type.startsWith('image') && FILE_TYPE_BROWSERSAFE.includes(media.type);
|
||||
})
|
||||
.map(media => {
|
||||
const item = {
|
||||
src: media.url,
|
||||
w: media.properties.width,
|
||||
h: media.properties.height,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
alt: media.comment || media.name,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
comment: media.comment || media.name,
|
||||
};
|
||||
if (media.properties.orientation != null && media.properties.orientation >= 5) {
|
||||
[item.w, item.h] = [item.h, item.w];
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
gallery: gallery.value,
|
||||
mainClass: 'pswp',
|
||||
children: '.image',
|
||||
thumbSelector: '.image',
|
||||
loop: false,
|
||||
padding: window.innerWidth > 500 ? {
|
||||
top: 32,
|
||||
bottom: 90,
|
||||
left: 32,
|
||||
right: 32,
|
||||
} : {
|
||||
top: 0,
|
||||
bottom: 78,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
imageClickAction: 'close',
|
||||
tapAction: 'close',
|
||||
bgOpacity: 1,
|
||||
showAnimationDuration: 100,
|
||||
hideAnimationDuration: 100,
|
||||
returnFocus: false,
|
||||
pswpModule: PhotoSwipe,
|
||||
});
|
||||
|
||||
lightbox.addFilter('itemData', (itemData) => {
|
||||
// element is children
|
||||
const { element } = itemData;
|
||||
|
||||
const id = element?.dataset.id;
|
||||
const file = props.mediaList.find(media => media.id === id);
|
||||
if (!file) return itemData;
|
||||
|
||||
itemData.src = file.url;
|
||||
itemData.w = Number(file.properties.width);
|
||||
itemData.h = Number(file.properties.height);
|
||||
if (file.properties.orientation != null && file.properties.orientation >= 5) {
|
||||
[itemData.w, itemData.h] = [itemData.h, itemData.w];
|
||||
}
|
||||
itemData.msrc = file.thumbnailUrl ?? undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
itemData.alt = file.comment || file.name;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
itemData.comment = file.comment || file.name;
|
||||
itemData.thumbCropped = true;
|
||||
|
||||
return itemData;
|
||||
});
|
||||
|
||||
lightbox.on('uiRegister', () => {
|
||||
lightbox?.pswp?.ui?.registerElement({
|
||||
name: 'altText',
|
||||
className: 'pswp__alt-text-container',
|
||||
appendTo: 'wrapper',
|
||||
onInit: (el, pswp) => {
|
||||
const textBox = window.document.createElement('p');
|
||||
textBox.className = 'pswp__alt-text _acrylic';
|
||||
el.appendChild(textBox);
|
||||
|
||||
pswp.on('change', () => {
|
||||
textBox.textContent = pswp.currSlide?.data.comment;
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
lightbox.on('afterInit', () => {
|
||||
activeEl = window.document.activeElement instanceof HTMLElement ? window.document.activeElement : null;
|
||||
focusParent(activeEl, true, true);
|
||||
lightbox?.pswp?.element?.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
window.history.pushState(null, '', '#pswp');
|
||||
});
|
||||
|
||||
lightbox.on('destroy', () => {
|
||||
focusParent(activeEl, true, false);
|
||||
activeEl = null;
|
||||
if (window.location.hash === '#pswp') {
|
||||
window.history.back();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', popstateHandler);
|
||||
|
||||
lightbox.init();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('popstate', popstateHandler);
|
||||
lightbox?.destroy();
|
||||
lightbox = null;
|
||||
activeEl = null;
|
||||
});
|
||||
|
||||
const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
||||
@@ -219,11 +110,47 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
||||
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
|
||||
};
|
||||
|
||||
const openGallery = () => {
|
||||
if (props.mediaList.filter(media => previewable(media)).length > 0) {
|
||||
lightbox?.loadAndOpen(0);
|
||||
function onMediaClick(file: Misskey.entities.DriveFile) {
|
||||
if (prefer.s.imageNewTab) {
|
||||
window.open(file.url, '_blank');
|
||||
return;
|
||||
}
|
||||
};
|
||||
openGallery(file.id);
|
||||
}
|
||||
|
||||
async function openGallery(id?: string) {
|
||||
if (id == null) {
|
||||
const firstImage = props.mediaList.find(media => previewable(media));
|
||||
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<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,
|
||||
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,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openGallery,
|
||||
@@ -316,6 +243,7 @@ defineExpose({
|
||||
.media {
|
||||
overflow: hidden; // clipにするとバグる
|
||||
border-radius: 8px;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
@container (min-width: 500px) {
|
||||
@@ -331,42 +259,4 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.pswp) {
|
||||
--pswp-root-z-index: var(--mk-pswp-root-z-index, 2000700) !important;
|
||||
--pswp-bg: var(--MI_THEME-modalBg) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.pswp__bg {
|
||||
background: var(--MI_THEME-modalBg);
|
||||
backdrop-filter: var(--MI-modalBgFilter);
|
||||
}
|
||||
|
||||
.pswp__alt-text-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
width: 75%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.pswp__alt-text {
|
||||
color: var(--MI_THEME-fg);
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
padding: var(--MI-margin);
|
||||
border-radius: var(--MI-radius);
|
||||
max-height: 8em;
|
||||
overflow-y: auto;
|
||||
text-shadow: var(--MI_THEME-bg) 0 0 10px, var(--MI_THEME-bg) 0 0 3px, var(--MI_THEME-bg) 0 0 3px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,11 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<!-- Media系専用のinput range -->
|
||||
<template>
|
||||
<div :style="sliderBgWhite ? '--sliderBg: rgba(255,255,255,.25);' : '--sliderBg: var(--MI_THEME-scrollbarHandle);'">
|
||||
<div :class="$style.controlsSeekbar">
|
||||
<progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress>
|
||||
<input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/>
|
||||
</div>
|
||||
<div :class="$style.controlsSeekbar">
|
||||
<progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress>
|
||||
<input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,10 +16,8 @@ import { computed } from 'vue';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
buffer?: number;
|
||||
sliderBgWhite?: boolean;
|
||||
}>(), {
|
||||
buffer: undefined,
|
||||
sliderBgWhite: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -38,6 +34,8 @@ const modelValue = computed({
|
||||
<style lang="scss" module>
|
||||
.controlsSeekbar {
|
||||
position: relative;
|
||||
--sliderBg: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15));
|
||||
--thumbSize: 17px;
|
||||
}
|
||||
|
||||
.seek {
|
||||
@@ -49,7 +47,7 @@ const modelValue = computed({
|
||||
border-radius: 26px;
|
||||
color: var(--MI_THEME-accent);
|
||||
display: block;
|
||||
height: 19px;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
@@ -83,11 +81,11 @@ const modelValue = computed({
|
||||
border: 0;
|
||||
border-radius: 100%;
|
||||
box-shadow: 0 1px 1px rgba(35, 40, 47, .15),0 0 0 1px rgba(35, 40, 47, .2);
|
||||
height: 13px;
|
||||
margin-top: -4px;
|
||||
height: var(--thumbSize);
|
||||
margin-top: calc((5px - var(--thumbSize)) / 2);
|
||||
position: relative;
|
||||
transition: all .2s ease;
|
||||
width: 13px;
|
||||
width: var(--thumbSize);
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 1px 1px rgba(35, 40, 47, .15), 0 0 0 1px rgba(35, 40, 47, .15), 0 0 0 3px rgba(255, 255, 255, .5);
|
||||
@@ -99,10 +97,10 @@ const modelValue = computed({
|
||||
border: 0;
|
||||
border-radius: 100%;
|
||||
box-shadow: 0 1px 1px rgba(35, 40, 47, .15),0 0 0 1px rgba(35, 40, 47, .2);
|
||||
height: 13px;
|
||||
height: var(--thumbSize);
|
||||
position: relative;
|
||||
transition: all .2s ease;
|
||||
width: 13px;
|
||||
width: var(--thumbSize);
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 1px 1px rgba(35, 40, 47, .15), 0 0 0 1px rgba(35, 40, 47, .15), 0 0 0 3px rgba(255, 255, 255, .5);
|
||||
@@ -119,7 +117,7 @@ const modelValue = computed({
|
||||
.buffer {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
color: var(--sliderBg);
|
||||
color: color(from var(--MI_THEME-accent) srgb r g b / 0.25);
|
||||
border: 0;
|
||||
border-radius: 99rem;
|
||||
height: 5px;
|
||||
|
||||
@@ -6,17 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<div
|
||||
ref="playerEl"
|
||||
v-hotkey="keymap"
|
||||
tabindex="0"
|
||||
:class="[
|
||||
$style.videoContainer,
|
||||
controlsShowing && $style.active,
|
||||
$style.root,
|
||||
(video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
|
||||
]"
|
||||
@mouseover.passive="onMouseOver"
|
||||
@mousemove.passive="onMouseMove"
|
||||
@mouseleave.passive="onMouseLeave"
|
||||
@contextmenu.stop
|
||||
@contextmenu.stop="onContextmenu"
|
||||
@keydown.stop
|
||||
>
|
||||
<button v-if="hide" :class="$style.hidden" @click="reveal">
|
||||
@@ -27,154 +22,49 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.videoRoot">
|
||||
<video
|
||||
ref="videoEl"
|
||||
<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
|
||||
v-else
|
||||
: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 :class="$style.playIconWrapper">
|
||||
<div :class="$style.playIcon">
|
||||
<i class="ti ti-player-play"></i>
|
||||
</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>
|
||||
<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 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 * 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 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);
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
(event: 'mediaClick', ev: PointerEvent): void;
|
||||
}>();
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const hide = ref(shouldHideFileByDefault(props.video));
|
||||
@@ -187,365 +77,17 @@ async function reveal() {
|
||||
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;
|
||||
},
|
||||
});
|
||||
os.popupMenu(getFileMenu(props.video, (newHide) => { hide.value = newHide; }), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
function onContextmenu(ev: PointerEvent) {
|
||||
os.contextMenu(getFileMenu(props.video, (newHide) => { hide.value = newHide; }), ev);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
@@ -553,6 +95,12 @@ onDeactivated(() => {
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.playIcon {
|
||||
scale: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sensitive {
|
||||
@@ -571,42 +119,6 @@ onDeactivated(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.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%;
|
||||
@@ -616,7 +128,7 @@ onDeactivated(() => {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 60px 0;
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -640,154 +152,62 @@ onDeactivated(() => {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.playIconWrapper {
|
||||
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;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
padding: 35px 10px 10px 10px;
|
||||
background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, .75));
|
||||
.playIcon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 100%;
|
||||
font-size: 120%;
|
||||
background: var(--MI_THEME-accent);
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
scale: 1;
|
||||
transition: scale 100ms ease;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: block;
|
||||
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;
|
||||
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;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
font-size: 0.8em;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
text-align: center;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
310
packages/frontend/src/components/MkVideoControl.vue
Normal file
310
packages/frontend/src/components/MkVideoControl.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="[$style.seekbar]">
|
||||
<MkMediaRange
|
||||
v-model="rangePercent"
|
||||
:buffer="bufferedDataRatio"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="[$style.controlsChild, $style.controlsLeft]">
|
||||
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
|
||||
<i v-if="isPlaying" class="ti ti-player-pause"></i>
|
||||
<i v-else class="ti ti-player-play"></i>
|
||||
</button>
|
||||
|
||||
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }} / {{ hms(durationMs) }}</div>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsCenter]">
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
<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"
|
||||
:class="$style.volumeSeekbar"
|
||||
/>
|
||||
<button class="_button" :class="$style.controlButton" @click="showMenu">
|
||||
<i class="ti ti-settings"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, inject, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { hms } from '@/filters/hms.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import hasAudio from '@/utility/media-has-audio.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
|
||||
const videoEl = inject(DI.mkImageGalleryItemVideoEl, shallowRef<HTMLVideoElement | null>(null));
|
||||
|
||||
// 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,
|
||||
}] : []),
|
||||
];
|
||||
|
||||
menuShowing.value = true;
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
|
||||
align: 'right',
|
||||
onClosing: () => {
|
||||
menuShowing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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 == null) 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 == null || videoEl.value.duration === 0) return 0;
|
||||
return bufferedEnd.value / videoEl.value.duration;
|
||||
});
|
||||
|
||||
function togglePlayPause() {
|
||||
if (!isReady.value) return;
|
||||
|
||||
if (isPlaying.value) {
|
||||
videoEl.value?.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
videoEl.value?.play();
|
||||
isPlaying.value = true;
|
||||
oncePlayed.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePictureInPicture() {
|
||||
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 abortController: AbortController | null = null;
|
||||
let mediaTickFrameId: number | null = null;
|
||||
|
||||
function init() {
|
||||
if (videoEl.value == null) return;
|
||||
|
||||
isReady.value = true;
|
||||
abortController = new AbortController();
|
||||
|
||||
function updateMediaTick() {
|
||||
if (videoEl.value == null) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (videoEl.value.paused !== !isPlaying.value) {
|
||||
isPlaying.value = !videoEl.value.paused;
|
||||
}
|
||||
|
||||
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
|
||||
}
|
||||
|
||||
updateMediaTick();
|
||||
|
||||
videoEl.value.addEventListener('play', () => {
|
||||
isActuallyPlaying.value = true;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.addEventListener('pause', () => {
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.addEventListener('ended', () => {
|
||||
oncePlayed.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
durationMs.value = videoEl.value.duration * 1000;
|
||||
videoEl.value.addEventListener('durationchange', () => {
|
||||
durationMs.value = videoEl.value!.duration * 1000;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.volume = volume.value;
|
||||
hasAudio(videoEl.value).then(had => {
|
||||
if (!had) {
|
||||
videoEl.value!.loop = videoEl.value!.muted = true;
|
||||
videoEl.value!.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(volume, (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.volume = to;
|
||||
});
|
||||
|
||||
watch(speed, (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.playbackRate = to;
|
||||
});
|
||||
|
||||
watch(loop, (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.loop = to;
|
||||
});
|
||||
|
||||
watch(videoEl, () => {
|
||||
if (abortController != null) {
|
||||
abortController.abort();
|
||||
}
|
||||
init();
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (mediaTickFrameId != null) {
|
||||
window.cancelAnimationFrame(mediaTickFrameId);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
isActuallyPlaying,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"seekbar seekbar seekbar"
|
||||
"left center right";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controlsChild {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.controlsCenter {
|
||||
grid-area: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controlButton {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.controlsTime {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.seekbar {
|
||||
grid-area: seekbar;
|
||||
}
|
||||
</style>
|
||||
@@ -19,4 +19,5 @@ export const DI = {
|
||||
inModal: Symbol() as InjectionKey<boolean>,
|
||||
inAppSearchMarkerId: Symbol() as InjectionKey<Ref<string | null>>,
|
||||
inChannel: Symbol() as InjectionKey<ComputedRef<string | null> | null>, // 現在開いているチャンネルのID
|
||||
mkImageGalleryItemVideoEl: Symbol() as InjectionKey<Ref<HTMLVideoElement | null>>,
|
||||
};
|
||||
|
||||
56
packages/frontend/src/utility/double-tap.ts
Normal file
56
packages/frontend/src/utility/double-tap.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export function makeDoubleTapDetector(onDoubletap: (event: TouchEvent) => void) {
|
||||
const positionThreshold = 10; // px
|
||||
const durationThreshold = 300; // ms
|
||||
|
||||
let lastTapTime = 0;
|
||||
let lastTapPosition = { x: 0, y: 0 };
|
||||
|
||||
function onTouchstart(ev: TouchEvent) {
|
||||
if (ev.touches.length !== 1) return;
|
||||
|
||||
const currentTime = new Date().getTime();
|
||||
const tapLength = currentTime - lastTapTime;
|
||||
const positionDelta = Math.max(
|
||||
Math.abs(ev.touches[0].clientX - lastTapPosition.x),
|
||||
Math.abs(ev.touches[0].clientY - lastTapPosition.y),
|
||||
);
|
||||
|
||||
if (tapLength < durationThreshold && tapLength > 0 && positionDelta < positionThreshold) { // ダブルタップ
|
||||
onDoubletap(ev);
|
||||
lastTapTime = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
lastTapTime = currentTime;
|
||||
lastTapPosition = {
|
||||
x: ev.touches[0].clientX,
|
||||
y: ev.touches[0].clientY,
|
||||
};
|
||||
}
|
||||
|
||||
function onTouchmove(ev: TouchEvent) {
|
||||
const positionDelta = Math.max(
|
||||
Math.abs(ev.touches[0].clientX - lastTapPosition.x),
|
||||
Math.abs(ev.touches[0].clientY - lastTapPosition.y),
|
||||
);
|
||||
if (positionDelta > positionThreshold) {
|
||||
lastTapTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
lastTapTime = 0;
|
||||
lastTapPosition = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
onTouchstart,
|
||||
onTouchmove,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -97,6 +97,7 @@ async function deleteFile(file: Misskey.entities.DriveFile) {
|
||||
globalEvents.emit('driveFilesDeleted', [file]);
|
||||
}
|
||||
|
||||
/** 自分のドライブファイルを操作する際のメニュー */
|
||||
export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] {
|
||||
const _isImage = file.type.startsWith('image/');
|
||||
|
||||
|
||||
83
packages/frontend/src/utility/get-file-menu.ts
Normal file
83
packages/frontend/src/utility/get-file-menu.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
/** 添付ファイルなど、公開ファイル用のメニュー */
|
||||
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;
|
||||
}
|
||||
@@ -53,7 +53,6 @@ let latestHotkey: Pattern & { callback: CallbackFunction } | null = null;
|
||||
export const makeHotkey = (keymap: Keymap, ignoreElements = IGNORE_ELEMENTS) => {
|
||||
const actions = parseKeymap(keymap);
|
||||
return (ev: KeyboardEvent) => {
|
||||
if ('pswp' in window && window.pswp != null) return;
|
||||
if (window.document.activeElement != null) {
|
||||
if (ignoreElements.includes(window.document.activeElement.tagName.toLowerCase())) return;
|
||||
if (getHTMLElementOrNull(window.document.activeElement)?.isContentEditable) return;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -220,9 +220,6 @@ export function getConfig(): UserConfig {
|
||||
groups: [{
|
||||
name: 'vue',
|
||||
test: /node_modules[\\/]vue/,
|
||||
}, {
|
||||
name: 'photoswipe',
|
||||
test: /node_modules[\\/]photoswipe/,
|
||||
}, {
|
||||
// split i18n related module to distinct module
|
||||
name: 'i18n',
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -646,9 +646,6 @@ importers:
|
||||
misskey-reversi:
|
||||
specifier: workspace:*
|
||||
version: link:../misskey-reversi
|
||||
photoswipe:
|
||||
specifier: 5.4.4
|
||||
version: 5.4.4
|
||||
punycode.js:
|
||||
specifier: 2.3.1
|
||||
version: 2.3.1
|
||||
@@ -7237,10 +7234,6 @@ packages:
|
||||
pgpass@1.0.5:
|
||||
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||
|
||||
photoswipe@5.4.4:
|
||||
resolution: {integrity: sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==}
|
||||
engines: {node: '>= 0.12.0'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -15637,8 +15630,6 @@ snapshots:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
photoswipe@5.4.4: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.2: {}
|
||||
|
||||
308
scripts/dev.mjs
308
scripts/dev.mjs
@@ -10,104 +10,264 @@ import { execa } from 'execa';
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
await execa('pnpm', ['clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
/** @type {Set<import('execa').ResultPromise>} */
|
||||
const childProcesses = new Set();
|
||||
/** @type {Set<import('execa').ResultPromise>} */
|
||||
const persistentChildProcesses = new Set();
|
||||
let shuttingDown = false;
|
||||
let persistentChildProcessesStarted = false;
|
||||
let persistentChildProcessFailed = false;
|
||||
/** @type {Promise<void> | null} */
|
||||
let shutdownPromise = null;
|
||||
|
||||
/**
|
||||
* 開発用コマンドを起動し、終了時にまとめて停止できるよう追跡する。
|
||||
* Windows では Ctrl+C の配信先を分離し、出力を親コンソールへ中継する。
|
||||
*
|
||||
* @param {string} command - 実行するコマンド。
|
||||
* @param {string[]} args - コマンドへ渡す引数。
|
||||
* @param {import('execa').Options} options - execa の起動オプション。
|
||||
* @returns {import('execa').ResultPromise} 起動した子プロセス。
|
||||
*/
|
||||
function spawnChildProcess(command, args, options) {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const pnpmPath = _dirname + '/../node_modules/pnpm/bin/pnpm.mjs';
|
||||
const windowsCommand = [process.execPath, pnpmPath, ...args]
|
||||
.map(argument => `"${argument}"`)
|
||||
.join(' ');
|
||||
const executable = isWindows && command === 'pnpm' ? process.env.ComSpec ?? 'cmd.exe' : command;
|
||||
const executableArgs = isWindows && command === 'pnpm'
|
||||
? ['/d', '/s', '/c', `start "" /b /wait ${windowsCommand}`]
|
||||
: args;
|
||||
const childProcess = execa(executable, executableArgs, isWindows ? {
|
||||
...options,
|
||||
// `start /b` keeps the process in the current console without forwarding
|
||||
// Ctrl+C, allowing only this supervisor to coordinate the shutdown.
|
||||
windowsVerbatimArguments: true,
|
||||
windowsHide: false,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
buffer: false,
|
||||
} : options);
|
||||
|
||||
if (isWindows) {
|
||||
if (options.stdout != null) childProcess.stdout?.pipe(options.stdout, { end: false });
|
||||
if (options.stderr != null) childProcess.stderr?.pipe(options.stderr, { end: false });
|
||||
}
|
||||
|
||||
childProcesses.add(childProcess);
|
||||
return childProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* 子プロセスの終了を待機し、追跡対象から取り除く。
|
||||
*
|
||||
* @param {string} command - 実行するコマンド。
|
||||
* @param {string[]} args - コマンドへ渡す引数。
|
||||
* @param {import('execa').Options} options - execa の起動オプション。
|
||||
* @returns {Promise<import('execa').Result>} 子プロセスの実行結果。
|
||||
*/
|
||||
async function runChildProcess(command, args, options) {
|
||||
const childProcess = spawnChildProcess(command, args, options);
|
||||
|
||||
try {
|
||||
return await childProcess;
|
||||
} finally {
|
||||
childProcesses.delete(childProcess);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 常駐する子プロセスがすべて終了していれば、終了結果を引き継いで親も終了する。
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function shutdownIfAllPersistentChildProcessesStopped() {
|
||||
if (shuttingDown || !persistentChildProcessesStarted || persistentChildProcesses.size > 0) return;
|
||||
|
||||
void shutdown(persistentChildProcessFailed ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 常駐する子プロセスを起動し、終了時の追跡解除・エラー出力・親の終了判定を設定する。
|
||||
*
|
||||
* @param {string} command - 実行するコマンド。
|
||||
* @param {string[]} args - コマンドへ渡す引数。
|
||||
* @param {import('execa').Options} options - execa の起動オプション。
|
||||
* @returns {void}
|
||||
*/
|
||||
function startChildProcess(command, args, options) {
|
||||
const childProcess = spawnChildProcess(command, args, options);
|
||||
persistentChildProcesses.add(childProcess);
|
||||
|
||||
void childProcess.then(() => {
|
||||
childProcesses.delete(childProcess);
|
||||
persistentChildProcesses.delete(childProcess);
|
||||
shutdownIfAllPersistentChildProcessesStopped();
|
||||
}, error => {
|
||||
childProcesses.delete(childProcess);
|
||||
persistentChildProcesses.delete(childProcess);
|
||||
if (!shuttingDown) {
|
||||
persistentChildProcessFailed = true;
|
||||
console.error(error);
|
||||
shutdownIfAllPersistentChildProcessesStopped();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 子プロセスとその配下のプロセスを停止し、終了を待機する。
|
||||
*
|
||||
* @param {import('execa').ResultPromise} childProcess - 停止する子プロセス。
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function stopChildProcess(childProcess) {
|
||||
if (process.platform === 'win32' && childProcess.pid != null) {
|
||||
const result = await execa('taskkill', ['/pid', childProcess.pid.toString(), '/t', '/f'], {
|
||||
reject: false,
|
||||
});
|
||||
if (result.failed) childProcess.kill();
|
||||
} else {
|
||||
childProcess.kill();
|
||||
}
|
||||
|
||||
await childProcess.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 追跡中の子プロセスを一度だけ停止して、指定した終了コードで終了する。
|
||||
*
|
||||
* @param {number} exitCode - 親プロセスに返す終了コード。
|
||||
* @returns {Promise<void>} 実行中または完了した停止処理。
|
||||
*/
|
||||
function shutdown(exitCode) {
|
||||
if (shutdownPromise != null) return shutdownPromise;
|
||||
|
||||
shuttingDown = true;
|
||||
shutdownPromise = (async () => {
|
||||
await Promise.allSettled([...childProcesses].map(stopChildProcess));
|
||||
process.exit(exitCode);
|
||||
})();
|
||||
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown(0);
|
||||
});
|
||||
|
||||
// アセットのビルドで依存しているので一番最初に必要
|
||||
await execa('pnpm', ['--filter', 'i18n', 'build'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown(0);
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
execa('pnpm', ['build-pre'], {
|
||||
try {
|
||||
await runChildProcess('pnpm', ['clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
execa('pnpm', ['build-assets'], {
|
||||
});
|
||||
|
||||
// アセットのビルドで依存しているので一番最初に必要
|
||||
await runChildProcess('pnpm', ['--filter', 'i18n', 'build'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
execa('pnpm', ['--filter', 'backend...', '--filter=!backend', 'build'], {
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
runChildProcess('pnpm', ['build-pre'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
runChildProcess('pnpm', ['build-assets'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
runChildProcess('pnpm', ['--filter', 'backend...', '--filter=!backend', 'build'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
// icons-subsetterは開発段階では使用されないが、型エラーを抑制するためにはじめの一度だけビルドする
|
||||
runChildProcess('pnpm', ['--filter', 'icons-subsetter', 'build'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
runChildProcess('pnpm', ['--filter', 'misskey-js', 'build'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
]);
|
||||
|
||||
startChildProcess('pnpm', ['build-pre', '--watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
// icons-subsetterは開発段階では使用されないが、型エラーを抑制するためにはじめの一度だけビルドする
|
||||
execa('pnpm', ['--filter', 'icons-subsetter', 'build'], {
|
||||
});
|
||||
|
||||
startChildProcess('pnpm', ['build-assets', '--watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
execa('pnpm', ['--filter', 'misskey-js', 'build'], {
|
||||
});
|
||||
|
||||
startChildProcess('pnpm', ['--filter', 'backend', 'dev'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
execa('pnpm', ['build-pre', '--watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
startChildProcess('pnpm', ['--filter', 'frontend', 'watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
execa('pnpm', ['build-assets', '--watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
startChildProcess('pnpm', ['--filter', 'frontend-embed', 'watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
execa('pnpm', ['--filter', 'backend', 'dev'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
startChildProcess('pnpm', ['--filter', 'sw', 'watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
execa('pnpm', ['--filter', 'frontend', 'watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
startChildProcess('pnpm', ['--filter', 'misskey-js', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
execa('pnpm', ['--filter', 'frontend-embed', 'watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
startChildProcess('pnpm', ['--filter', 'i18n', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
execa('pnpm', ['--filter', 'sw', 'watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
startChildProcess('pnpm', ['--filter', 'misskey-reversi', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
execa('pnpm', ['--filter', 'misskey-js', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
startChildProcess('pnpm', ['--filter', 'misskey-bubble-game', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
execa('pnpm', ['--filter', 'i18n', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
execa('pnpm', ['--filter', 'misskey-reversi', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
execa('pnpm', ['--filter', 'misskey-bubble-game', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
persistentChildProcessesStarted = true;
|
||||
shutdownIfAllPersistentChildProcessesStopped();
|
||||
} catch (error) {
|
||||
if (!shuttingDown) {
|
||||
console.error(error);
|
||||
await shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user