From 302b5ac9530edaeac1abcd1c260c1f0d048e1db5 Mon Sep 17 00:00:00 2001
From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Tue, 20 Aug 2024 17:52:09 +0900
Subject: [PATCH] tweak embed media ui

---
 packages/frontend/src/components/MkNote.vue   |   4 +-
 .../src/embed/components/EmMediaAudio.vue     | 531 ------------------
 .../src/embed/components/EmMediaBanner.vue    |  76 +--
 .../src/embed/components/EmMediaImage.vue     |  14 +-
 .../src/embed/components/EmMediaList.vue      |  20 +-
 .../src/embed/components/EmMediaVideo.vue     |  65 +++
 6 files changed, 107 insertions(+), 603 deletions(-)
 delete mode 100644 packages/frontend/src/embed/components/EmMediaAudio.vue
 create mode 100644 packages/frontend/src/embed/components/EmMediaVideo.vue

diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 84d7a7a10d..67c006f83c 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -79,7 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 						</div>
 					</div>
 					<div v-if="appearNote.files && appearNote.files.length > 0">
-						<MkMediaList ref="galleryEl" :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/>
+						<EmMediaList v-if="inEmbedPage" ref="galleryEl" :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/>
+						<MkMediaList v-else ref="galleryEl" :mediaList="appearNote.files"/>
 					</div>
 					<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="inEmbedPage" :class="$style.poll"/>
 					<div v-if="isEnabledUrlPreview">
@@ -187,6 +188,7 @@ import MkNoteSimple from '@/components/MkNoteSimple.vue';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
 import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
 import MkMediaList from '@/components/MkMediaList.vue';
+import EmMediaList from '@/embed/components/EmMediaList.vue';
 import MkCwButton from '@/components/MkCwButton.vue';
 import MkPoll from '@/components/MkPoll.vue';
 import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
diff --git a/packages/frontend/src/embed/components/EmMediaAudio.vue b/packages/frontend/src/embed/components/EmMediaAudio.vue
deleted file mode 100644
index 4e25554aac..0000000000
--- a/packages/frontend/src/embed/components/EmMediaAudio.vue
+++ /dev/null
@@ -1,531 +0,0 @@
-<!--
-SPDX-FileCopyrightText: syuilo and misskey-project
-SPDX-License-Identifier: AGPL-3.0-only
--->
-
-<template>
-<div
-	ref="playerEl"
-	v-hotkey="keymap"
-	tabindex="0"
-	:class="[
-		$style.audioContainer,
-		(audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive,
-	]"
-	@contextmenu.stop
-	@keydown.stop
->
-	<button v-if="hide" :class="$style.hidden" @click="show">
-		<div :class="$style.hiddenTextWrapper">
-			<b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b>
-			<b v-else style="display: block;"><i class="ti ti-music"></i> {{ defaultStore.state.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b>
-			<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
-		</div>
-	</button>
-
-	<div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.nativeAudioContainer">
-		<audio
-			ref="audioEl"
-			preload="metadata"
-			controls
-			:class="$style.nativeAudio"
-			@keydown.prevent
-		>
-			<source :src="audio.url" :type="audio.type">
-		</audio>
-	</div>
-
-	<div v-else :class="$style.audioControls">
-		<audio
-			ref="audioEl"
-			preload="metadata"
-			tabindex="-1"
-			@keydown.prevent
-		>
-			<source :src="audio.url" :type="audio.type">
-		</audio>
-		<div :class="[$style.controlsChild, $style.controlsLeft]">
-			<button
-				:class="['_button', $style.controlButton]"
-				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>
-			</button>
-		</div>
-		<div :class="[$style.controlsChild, $style.controlsRight]">
-			<button
-				:class="['_button', $style.controlButton]"
-				tabindex="-1"
-				@click.stop="() => {}"
-				@mousedown.prevent.stop="showMenu"
-			>
-				<i class="ti ti-settings"></i>
-			</button>
-		</div>
-		<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
-		<div :class="[$style.controlsChild, $style.controlsVolume]">
-			<button
-				:class="['_button', $style.controlButton]"
-				tabindex="-1"
-				@click.stop="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"
-			/>
-		</div>
-		<MkMediaRange
-			v-model="rangePercent"
-			:class="$style.seekbarRoot"
-			:buffer="bufferedDataRatio"
-		/>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { shallowRef, watch, computed, ref, inject, onDeactivated, onActivated, onMounted } from 'vue';
-import * as Misskey from 'misskey-js';
-import type { MenuItem } from '@/types/menu.js';
-import { defaultStore } from '@/store.js';
-import { i18n } from '@/i18n.js';
-import * as os from '@/os.js';
-import { type Keymap } from '@/scripts/hotkey.js';
-import bytes from '@/filters/bytes.js';
-import { hms } from '@/filters/hms.js';
-import MkMediaRange from '@/components/MkMediaRange.vue';
-import { $i, iAmModerator } from '@/account.js';
-
-const props = defineProps<{
-	audio: Misskey.entities.DriveFile;
-}>();
-
-const inEmbedPage = inject<boolean>('EMBED_PAGE', false);
-
-const keymap = {
-	'up': {
-		allowRepeat: true,
-		callback: () => {
-    	if (inEmbedPage) return;
-			if (hasFocus() && audioEl.value) {
-				volume.value = Math.min(volume.value + 0.1, 1);
-			}
-		},
-	},
-	'down': {
-		allowRepeat: true,
-		callback: () => {
-    	if (inEmbedPage) return;
-			if (hasFocus() && audioEl.value) {
-				volume.value = Math.max(volume.value - 0.1, 0);
-			}
-		},
-	},
-	'left': {
-		allowRepeat: true,
-		callback: () => {
-    	if (inEmbedPage) return;
-			if (hasFocus() && audioEl.value) {
-				audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
-			}
-		},
-	},
-	'right': {
-		allowRepeat: true,
-		callback: () => {
-    	if (inEmbedPage) return;
-			if (hasFocus() && audioEl.value) {
-				audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
-			}
-		},
-	},
-	'space': () => {
-		if (inEmbedPage) return;
-		if (hasFocus()) {
-			togglePlayPause();
-		}
-	},
-} as const satisfies Keymap;
-
-// PlayerElもしくはその子要素にフォーカスがあるかどうか
-function hasFocus() {
-	if (!playerEl.value) return false;
-	return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
-}
-
-const playerEl = shallowRef<HTMLDivElement>();
-const audioEl = shallowRef<HTMLAudioElement>();
-
-// eslint-disable-next-line vue/no-setup-props-reactivity-loss
-const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'));
-
-async function show() {
-	if (props.audio.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
-		const { canceled } = await os.confirm({
-			type: 'question',
-			text: i18n.ts.sensitiveMediaRevealConfirm,
-		});
-		if (canceled) return;
-	}
-
-	hide.value = false;
-}
-
-// Menu
-const menuShowing = ref(false);
-
-function showMenu(ev: MouseEvent) {
-	let menu: MenuItem[] = [];
-
-	menu = [
-		// TODO: 再生キューに追加
-		{
-			type: 'switch',
-			text: i18n.ts._mediaControls.loop,
-			icon: 'ti ti-repeat',
-			ref: loop,
-		},
-		{
-			type: 'radio',
-			text: i18n.ts._mediaControls.playbackRate,
-			icon: 'ti ti-clock-play',
-			ref: speed,
-			options: {
-				'0.25x': 0.25,
-				'0.5x': 0.5,
-				'0.75x': 0.75,
-				'1.0x': 1,
-				'1.25x': 1.25,
-				'1.5x': 1.5,
-				'2.0x': 2,
-			},
-		},
-		{
-			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),
-		});
-	}
-
-	if ($i?.id === props.audio.userId) {
-		menu.push({
-			type: 'divider',
-		}, {
-			type: 'link' as const,
-			text: i18n.ts._fileViewer.title,
-			icon: 'ti ti-info-circle',
-			to: `/my/drive/file/${props.audio.id}`,
-		});
-	}
-
-	menuShowing.value = true;
-	os.popupMenu(menu, ev.currentTarget ?? ev.target, {
-		align: 'right',
-		onClosing: () => {
-			menuShowing.value = false;
-		},
-	});
-}
-
-function toggleSensitive(file: Misskey.entities.DriveFile) {
-	os.apiWithDialog('drive/files/update', {
-		fileId: file.id,
-		isSensitive: !file.isSensitive,
-	});
-}
-
-// 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 (!audioEl.value) return;
-		audioEl.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 (!audioEl.value) return 0;
-	return bufferedEnd.value / audioEl.value.duration;
-});
-
-// MediaControl Events
-function togglePlayPause() {
-	if (!isReady.value || !audioEl.value) return;
-
-	if (isPlaying.value) {
-		audioEl.value.pause();
-		isPlaying.value = false;
-	} else {
-		audioEl.value.play();
-		isPlaying.value = true;
-		oncePlayed.value = true;
-	}
-}
-
-function toggleMute() {
-	if (volume.value === 0) {
-		volume.value = .25;
-	} else {
-		volume.value = 0;
-	}
-}
-
-let onceInit = false;
-let mediaTickFrameId: number | null = null;
-let stopAudioElWatch: () => void;
-
-function init() {
-	if (onceInit) return;
-	onceInit = true;
-
-	stopAudioElWatch = watch(audioEl, () => {
-		if (audioEl.value) {
-			isReady.value = true;
-
-			function updateMediaTick() {
-				if (audioEl.value) {
-					try {
-						bufferedEnd.value = audioEl.value.buffered.end(0);
-					} catch (err) {
-						bufferedEnd.value = 0;
-					}
-
-					elapsedTimeMs.value = audioEl.value.currentTime * 1000;
-
-					if (audioEl.value.loop !== loop.value) {
-						loop.value = audioEl.value.loop;
-					}
-				}
-				mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
-			}
-
-			updateMediaTick();
-
-			audioEl.value.addEventListener('play', () => {
-				isActuallyPlaying.value = true;
-			});
-
-			audioEl.value.addEventListener('pause', () => {
-				isActuallyPlaying.value = false;
-				isPlaying.value = false;
-			});
-
-			audioEl.value.addEventListener('ended', () => {
-				oncePlayed.value = false;
-				isActuallyPlaying.value = false;
-				isPlaying.value = false;
-			});
-
-			durationMs.value = audioEl.value.duration * 1000;
-			audioEl.value.addEventListener('durationchange', () => {
-				if (audioEl.value) {
-					durationMs.value = audioEl.value.duration * 1000;
-				}
-			});
-
-			audioEl.value.volume = volume.value;
-		}
-	}, {
-		immediate: true,
-	});
-}
-
-watch(volume, (to) => {
-	if (audioEl.value) audioEl.value.volume = to;
-});
-
-watch(speed, (to) => {
-	if (audioEl.value) audioEl.value.playbackRate = to;
-});
-
-watch(loop, (to) => {
-	if (audioEl.value) audioEl.value.loop = to;
-});
-
-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 = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore');
-	stopAudioElWatch();
-	onceInit = false;
-	if (mediaTickFrameId) {
-		window.cancelAnimationFrame(mediaTickFrameId);
-		mediaTickFrameId = null;
-	}
-});
-</script>
-
-<style lang="scss" module>
-.audioContainer {
-	container-type: inline-size;
-	position: relative;
-	border: .5px solid var(--divider);
-	border-radius: var(--radius);
-	overflow: clip;
-
-	&:focus-visible {
-		outline: none;
-	}
-}
-
-.sensitive {
-	position: relative;
-
-	&::after {
-		content: "";
-		position: absolute;
-		top: 0;
-		left: 0;
-		width: 100%;
-		height: 100%;
-		pointer-events: none;
-		border-radius: inherit;
-		box-shadow: inset 0 0 0 4px var(--warn);
-	}
-}
-
-.hidden {
-	width: 100%;
-	background: #000;
-	border: none;
-	outline: none;
-	font: inherit;
-	color: inherit;
-	cursor: pointer;
-	padding: 12px 0;
-	display: flex;
-	align-items: center;
-	justify-content: center;
-}
-
-.hiddenTextWrapper {
-	text-align: center;
-	font-size: 0.8em;
-	color: #fff;
-}
-
-.audioControls {
-	display: grid;
-	grid-template-areas:
-		"left time . volume right"
-		"seekbar seekbar seekbar seekbar seekbar";
-	grid-template-columns: auto auto 1fr auto auto;
-	align-items: center;
-	gap: 4px 8px;
-	padding: 10px;
-}
-
-.controlsChild {
-	display: flex;
-	align-items: center;
-	gap: 4px;
-
-	.controlButton {
-		padding: 6px;
-		border-radius: calc(var(--radius) / 2);
-		font-size: 1.05rem;
-
-		&:hover {
-			color: var(--accent);
-			background-color: var(--accentedBg);
-		}
-
-		&:focus-visible {
-			outline: none;
-		}
-	}
-}
-
-.controlsLeft {
-	grid-area: left;
-}
-
-.controlsRight {
-	grid-area: right;
-}
-
-.controlsTime {
-	grid-area: time;
-	font-size: .9rem;
-}
-
-.controlsVolume {
-	grid-area: volume;
-
-	.volumeSeekbar {
-		display: none;
-	}
-}
-
-.seekbarRoot {
-	grid-area: seekbar;
-}
-
-@container (min-width: 500px) {
-	.audioControls {
-		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;
-		}
-	}
-}
-
-.nativeAudioContainer {
-	display: flex;
-	align-items: center;
-	padding: 6px;
-}
-
-.nativeAudio {
-	display: block;
-	width: 100%;
-}
-</style>
diff --git a/packages/frontend/src/embed/components/EmMediaBanner.vue b/packages/frontend/src/embed/components/EmMediaBanner.vue
index 0b87ffdcff..435da238a4 100644
--- a/packages/frontend/src/embed/components/EmMediaBanner.vue
+++ b/packages/frontend/src/embed/components/EmMediaBanner.vue
@@ -4,70 +4,52 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div :class="$style.root">
-	<EmMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/>
-	<div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="show">
-		<span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span>
-		<b>{{ i18n.ts.sensitive }}</b>
-		<span>{{ i18n.ts.clickToShow }}</span>
+<a :href="href" target="_blank" :class="$style.root">
+	<div :class="$style.label">
+		<template v-if="media.type.startsWith('audio')"><i class="ti ti-music"></i> {{ i18n.ts.audio }}</template>
+		<template v-else><i class="ti ti-file"></i> {{ i18n.ts.file }}</template>
 	</div>
-	<a
-		v-else :class="$style.download"
-		:href="media.url"
-		:title="media.name"
-		:download="media.name"
-	>
-		<span style="font-size: 1.6em;"><i class="ti ti-download"></i></span>
-		<b>{{ media.name }}</b>
-	</a>
-</div>
+	<div :class="$style.go">
+		<i class="ti ti-chevron-right"></i>
+	</div>
+</a>
 </template>
 
-<script lang="ts" setup>
-import { ref } from 'vue';
+<script setup lang="ts">
 import * as Misskey from 'misskey-js';
-import EmMediaAudio from './EmMediaAudio.vue';
 import { i18n } from '@/i18n.js';
 
-const props = defineProps<{
+defineProps<{
 	media: Misskey.entities.DriveFile;
+	href: string;
 }>();
-
-const hide = ref(true);
-
-async function show() {
-	hide.value = false;
-}
 </script>
 
 <style lang="scss" module>
 .root {
-	width: 100%;
-	border-radius: 4px;
-	margin-top: 4px;
-	overflow: clip;
-}
-
-.download,
-.sensitive {
+	box-sizing: border-box;
 	display: flex;
 	align-items: center;
-	font-size: 12px;
-	padding: 8px 12px;
-	white-space: nowrap;
+	width: 100%;
+	padding: var(--margin);
+	margin-top: 4px;
+	border: 1px solid var(--inputBorder);
+	border-radius: var(--radius);
+	background-color: var(--panel);
+	transition: background-color .1s, border-color .1s;
+
+	&:hover {
+		text-decoration: none;
+		border-color: var(--inputBorderHover);
+		background-color: var(--buttonHoverBg);
+	}
 }
 
-.download {
-	background: var(--noteAttachedFile);
+.label {
+	font-size: .9em;
 }
 
-.sensitive {
-	background: #111;
-	color: #fff;
-}
-
-.audio {
-	border-radius: 8px;
-	overflow: clip;
+.go {
+	margin-left: auto;
 }
 </style>
diff --git a/packages/frontend/src/embed/components/EmMediaImage.vue b/packages/frontend/src/embed/components/EmMediaImage.vue
index 9e88513998..5d79d4484a 100644
--- a/packages/frontend/src/embed/components/EmMediaImage.vue
+++ b/packages/frontend/src/embed/components/EmMediaImage.vue
@@ -8,14 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<a
 		:title="image.name"
 		:class="$style.imageContainer"
-		:href="image.url"
+		:href="href ?? image.url"
 		target="_blank"
 		rel="noopener"
-		style="cursor: zoom-in;"
 	>
 		<ImgWithBlurhash
 			:hash="image.blurhash"
-			:src="(defaultStore.state.dataSaver.media && hide) ? null : url"
+			:src="hide ? null : url"
 			:forceBlurhash="hide"
 			:cover="hide || cover"
 			:alt="image.comment || image.name"
@@ -26,10 +25,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 		/>
 	</a>
 	<template v-if="hide">
-		<div :class="$style.hiddenText">
+		<div :class="$style.hiddenText" @click="hide = !hide">
 			<div :class="$style.hiddenTextWrapper">
-				<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
-				<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && image.size ? bytes(image.size) : i18n.ts.image }}</b>
+				<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</b>
+				<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ i18n.ts.image }}</b>
 				<span v-if="controls" style="display: block;">{{ i18n.ts.clickToShow }}</span>
 			</div>
 		</div>
@@ -39,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div v-if="image.comment" :class="$style.indicator">ALT</div>
 		<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
 	</div>
-	<i class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i>
+	<i v-if="!hide" class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i>
 </div>
 </template>
 
@@ -52,6 +51,7 @@ import { i18n } from '@/i18n.js';
 
 const props = withDefaults(defineProps<{
 	image: Misskey.entities.DriveFile;
+	href?: string;
 	raw?: boolean;
 	cover?: boolean;
 }>(), {
diff --git a/packages/frontend/src/embed/components/EmMediaList.vue b/packages/frontend/src/embed/components/EmMediaList.vue
index 9ea55aee8d..ef20ecd23f 100644
--- a/packages/frontend/src/embed/components/EmMediaList.vue
+++ b/packages/frontend/src/embed/components/EmMediaList.vue
@@ -6,8 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <div>
 	<div v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :class="$style.banner">
-		<XBanner :media="media" :inert="true"/>
-		<a v-if="originalEntityUrl" :href="originalEntityUrl" target="_blank" rel="noopener" :class="$style.mediaLinkForEmbed"></a>
+		<XBanner :media="media" :href="originalEntityUrl"/>
 	</div>
 	<div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container">
 		<div
@@ -17,9 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 			]"
 		>
 			<div v-for="media in mediaList.filter(media => previewable(media))" :class="$style.media">
-				<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :video="media" :class="$style.mediaInner" :inert="true"/>
-				<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.mediaInner" class="image" :inert="true" :data-id="media.id" :image="media" :raw="raw"/>
-				<a v-if="originalEntityUrl" :href="originalEntityUrl" target="_blank" rel="noopener" :class="$style.mediaLinkForEmbed"></a>
+				<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.mediaInner" :video="media" :href="originalEntityUrl"/>
+				<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.mediaInner" class="image" :image="media" :raw="raw" :href="originalEntityUrl"/>
 			</div>
 		</div>
 	</div>
@@ -43,8 +41,6 @@ const props = defineProps<{
 	originalEntityUrl?: string;
 }>();
 
-const pswpZIndex = os.claimZIndex('middle');
-document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
 const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
 
 let activeEl: HTMLElement | null = null;
@@ -150,14 +146,4 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
 .banner {
 	position: relative;
 }
-
-.mediaLinkForEmbed::after {
-	position: absolute;
-	top: 0;
-	left: 0;
-	right: 0;
-	bottom: 0;
-	z-index: 1;
-	content: '';
-}
 </style>
diff --git a/packages/frontend/src/embed/components/EmMediaVideo.vue b/packages/frontend/src/embed/components/EmMediaVideo.vue
new file mode 100644
index 0000000000..ad0cd08391
--- /dev/null
+++ b/packages/frontend/src/embed/components/EmMediaVideo.vue
@@ -0,0 +1,65 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<a :href="href" target="_blank" :class="$style.root">
+	<img v-if="!video.isSensitive && video.thumbnailUrl" :class="$style.thumbnail" :src="video.thumbnailUrl">
+	<div :class="$style.videoOverlayPlayButton"><i class="ti ti-player-play-filled"></i></div>
+</a>
+</template>
+
+<script setup lang="ts">
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+
+defineProps<{
+	video: Misskey.entities.DriveFile;
+	href: string;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+	position: relative;
+	box-sizing: border-box;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: 100%;
+	height: auto;
+	aspect-ratio: 16 / 9;
+	padding: var(--margin);
+	border: 1px solid var(--divider);
+	border-radius: var(--radius);
+	background-color: #000;
+
+	&:hover {
+		text-decoration: none;
+	}
+}
+
+.thumbnail {
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: 100%;
+	height: 100%;
+	object-fit: cover;
+}
+
+.videoOverlayPlayButton {
+	background: var(--accent);
+	color: #fff;
+	padding: 1rem;
+	border-radius: 99rem;
+
+	font-size: 1rem;
+	line-height: 1rem;
+
+	&:focus-visible {
+		outline: none;
+	}
+}
+</style>