1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-11 20:14:42 +02:00

Compare commits

...

71 Commits

Author SHA1 Message Date
kakkokari-gtyih
aabd9e9cba fix 2026-07-12 00:50:24 +09:00
kakkokari-gtyih
7a31bfdbc2 fix 2026-07-12 00:43:07 +09:00
kakkokari-gtyih
3ba648b3d5 indent 2026-07-12 00:21:40 +09:00
kakkokari-gtyih
8f5949e1a2 Update Changelog 2026-07-12 00:20:47 +09:00
かっこかり
37213d1c43 fix(frontend/imagegallery): ボタンの当たり判定がまだ小さいのを修正 (#17697)
* wip

* fix

* fix

* 🎨

* fix

* fix: Video要素をpropsで渡すのをやめる

* 🎨

* fix

* wip

* fix

* fix: 画面外にドラッグできるのを修正

* fix lint

* fix

* fix

* fix

* refactor

* add comment

* fix: 画像を新しいタブで開くが機能しなくなっていた問題を修正

* fix

* clean up

* attempt to fix

* fix

* enhance: ブラウザの戻るボタンで閉じる

* Revert "fix: 画面外にドラッグできるのを修正"

This reverts commit ea2ddc2620.

* Reapply "fix: 画面外にドラッグできるのを修正"

This reverts commit 87171980a7.

* fix

* fix: 存在しない要素がsourceElementのときに閉じると画像が飛んでいく問題を修正

* fix: clampに余白を追加

* fix duplicated condition

* fix

* fix typo

* fix
2026-07-12 00:11:01 +09:00
かっこかり
520ede4d63 Improve image viewer (#17696)
* wip

* fix

* fix

* 🎨

* fix

* fix: Video要素をpropsで渡すのをやめる

* 🎨

* fix

* wip

* fix

* fix: 画面外にドラッグできるのを修正

* fix lint

* fix

* fix

* fix

* refactor

* add comment

* fix: 画像を新しいタブで開くが機能しなくなっていた問題を修正

* fix

* clean up

* attempt to fix

* fix

* enhance: ブラウザの戻るボタンで閉じる

* Revert "fix: 画面外にドラッグできるのを修正"

This reverts commit ea2ddc2620.

* Reapply "fix: 画面外にドラッグできるのを修正"

This reverts commit 87171980a7.

* fix

* fix: 存在しない要素がsourceElementのときに閉じると画像が飛んでいく問題を修正

* fix: clampに余白を追加

* fix duplicated condition

* fix

* fix typo
2026-07-12 00:05:40 +09:00
syuilo
ef034dc2ab Update MkVideoContol.vue 2026-07-11 18:30:57 +09:00
syuilo
491eba6772 Update MkVideoContol.vue 2026-07-11 18:26:50 +09:00
syuilo
9db840c17b Update MkImageGallery.vue 2026-07-11 18:10:10 +09:00
syuilo
5d92214ccc wip 2026-07-11 17:31:52 +09:00
syuilo
d55112ec7b Update MkMediaVideo.vue 2026-07-11 17:23:41 +09:00
syuilo
9f37206f14 Update MkMediaVideo.vue 2026-07-11 17:16:35 +09:00
syuilo
292005b27b wip 2026-07-11 17:11:49 +09:00
syuilo
43caa68594 Update MkVideoContol.vue 2026-07-11 17:11:01 +09:00
syuilo
1538df1c74 Update MkImageGallery.item.vue 2026-07-11 16:42:38 +09:00
syuilo
ea7f97c07a Update MkImageGallery.item.vue 2026-07-11 15:25:38 +09:00
syuilo
159d4e00dc Update MkImageGallery.item.vue 2026-07-11 15:21:55 +09:00
syuilo
955418a2e1 Update MkImageGallery.item.vue 2026-07-11 15:08:36 +09:00
syuilo
bebd318730 Merge branch 'develop' into improve-image-viewer 2026-07-11 13:16:26 +09:00
syuilo
bf7986f2a4 wip 2026-07-11 13:03:26 +09:00
syuilo
10df70b3f5 wip 2026-07-11 11:46:05 +09:00
syuilo
a21a93190e Update MkImageGallery.item.vue 2026-07-11 11:10:49 +09:00
syuilo
afb72ac3b0 wip 2026-07-11 10:56:27 +09:00
syuilo
0ad2606ee5 Update MkImageGallery.item.vue 2026-07-11 09:51:36 +09:00
syuilo
734a19c06f fix 2026-07-11 09:42:27 +09:00
syuilo
7f3cbace05 Update MkImageGallery.item.vue 2026-07-11 09:35:43 +09:00
syuilo
4a941252f8 wip 2026-07-11 08:50:03 +09:00
おさむのひと
2335eb7b7f chore: pnpm dev時のプロセス管理をより綿密に行い、Windows環境でのプロセス残留を抑止 (#17690) 2026-07-10 21:51:01 +09:00
syuilo
4e0aa269eb Update MkImageGallery.item.vue 2026-07-10 21:41:59 +09:00
syuilo
abae0b5eb1 refactor 2026-07-10 21:33:04 +09:00
syuilo
4f6830fdd6 Update MkMediaList.vue 2026-07-10 21:15:08 +09:00
syuilo
be09eb9d47 lint 2026-07-10 21:05:32 +09:00
syuilo
1398686cb7 add note 2026-07-10 20:57:44 +09:00
syuilo
bb315a8cbc Update MkImageGallery.item.vue 2026-07-10 20:22:34 +09:00
syuilo
b7be82bd41 fix 2026-07-10 20:13:59 +09:00
syuilo
965a7bcc41 Update MkImageGallery.item.vue 2026-07-10 19:51:31 +09:00
syuilo
fc59574f58 Update MkImageGallery.vue 2026-07-10 18:49:50 +09:00
syuilo
c8229fc5f2 Update MkImageGallery.vue 2026-07-10 18:37:48 +09:00
syuilo
7dd9574d32 wip 2026-07-10 18:32:41 +09:00
syuilo
c5dd17e7e2 wip 2026-07-10 17:08:47 +09:00
syuilo
a129b453b0 Update MkImageGallery.item.vue 2026-07-10 16:43:15 +09:00
syuilo
392fb2c40d Update MkImageGallery.item.vue 2026-07-10 16:41:26 +09:00
syuilo
3ecb2b4d13 wip 2026-07-10 15:48:39 +09:00
syuilo
f3dcca2d31 wip 2026-07-10 15:12:00 +09:00
syuilo
327c447b9a Delete animation.ts 2026-07-10 15:03:00 +09:00
syuilo
62afbc32fb Update MkImageGallery.vue 2026-07-10 15:02:19 +09:00
syuilo
5677646bd5 Update MkImageGallery.item.vue 2026-07-10 14:41:54 +09:00
syuilo
53b4d184ac refactor 2026-07-10 14:16:38 +09:00
syuilo
1cabf88445 Update MkImageGallery.item.vue 2026-07-10 13:53:54 +09:00
syuilo
b47303f6d6 wip 2026-07-10 13:46:45 +09:00
syuilo
b48321154f wip 2026-07-10 13:18:54 +09:00
syuilo
f70048bd6c Update MkImageGallery.item.vue 2026-07-10 12:01:13 +09:00
syuilo
4b592f9c94 wip 2026-07-10 09:56:03 +09:00
syuilo
f5f5eab09e fix 2026-07-10 09:34:08 +09:00
syuilo
324aea004d Update MkImageGallery.item.vue 2026-07-10 09:26:14 +09:00
syuilo
dc07f0c6de Update MkMediaList.vue 2026-07-10 09:24:16 +09:00
syuilo
2e54afd3a7 wip 2026-07-10 09:21:42 +09:00
syuilo
e41aedeb06 wip 2026-07-10 09:03:03 +09:00
syuilo
c3f8c0a871 Update MkImageGallery.vue 2026-07-10 08:45:16 +09:00
syuilo
95901e5220 Update MkImageGallery.vue 2026-07-10 08:36:42 +09:00
syuilo
d5609abf25 wip 2026-07-09 22:02:37 +09:00
syuilo
cf3b206ef4 wip 2026-07-09 21:24:33 +09:00
syuilo
951e22242d wip 2026-07-09 21:05:14 +09:00
syuilo
cc356cf542 wip 2026-07-09 20:32:20 +09:00
syuilo
f0b3a7241d wip 2026-07-09 18:48:28 +09:00
syuilo
e7bc3821d1 Update MkImageGallery.item.vue 2026-07-09 18:25:08 +09:00
syuilo
d2647e066e Update MkImageGallery.item.vue 2026-07-09 17:53:09 +09:00
syuilo
b7ca70bd1e wip 2026-07-09 17:42:34 +09:00
syuilo
564ed21b74 Update MkImageGallery.item.vue 2026-07-09 17:31:09 +09:00
syuilo
5c7f547342 wip 2026-07-09 17:03:20 +09:00
syuilo
75e97da41e wip 2026-07-09 16:13:16 +09:00
24 changed files with 2494 additions and 1233 deletions

View File

@@ -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: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正

View File

@@ -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();
},
};
}

View File

@@ -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({

View File

@@ -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",

View 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>

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View 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>

View File

@@ -14,18 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only
:enterToClass="prefer.s.animation && props.transition?.enterToClass || undefined"
:leaveFromClass="prefer.s.animation && props.transition?.leaveFromClass || undefined"
>
<canvas
v-show="hide"
<MkBlurhash
key="canvas"
ref="canvas"
:class="$style.canvas"
:width="canvasWidth"
:height="canvasHeight"
:title="title ?? undefined"
draggable="false"
tabindex="-1"
style="-webkit-user-drag: none;"
></canvas>
:blurhash="hash ?? null"
:height="imgHeight ?? undefined"
:width="imgWidth ?? undefined"
:onlyAvgColor="props.onlyAvgColor"
:show="hide"
/>
<img
v-show="!hide"
key="img"
@@ -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>

View File

@@ -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);

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal" @contextmenu.stop="onContextmenu">
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onClick" @contextmenu.stop="onContextmenu">
<component
:is="disableImageLink ? 'div' : 'a'"
v-bind="disableImageLink ? {
@@ -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>

View File

@@ -20,8 +20,23 @@ SPDX-License-Identifier: AGPL-3.0-only
]"
>
<template v-for="media in mediaList.filter(media => previewable(media))">
<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.media" :video="media"/>
<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>

View File

@@ -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;

View File

@@ -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>

View 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>

View File

@@ -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>>,
};

View 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,
};
}

View File

@@ -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/');

View 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;
}

View File

@@ -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;

View File

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

View File

@@ -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
View File

@@ -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: {}

View File

@@ -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);
}
}