This commit is contained in:
かっこかり 2025-09-24 15:41:34 +09:00 committed by GitHub
commit ca4428356d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 419 additions and 70 deletions

View File

@ -10,8 +10,12 @@ SPDX-License-Identifier: AGPL-3.0-only
tabindex="0"
:class="[
$style.audioContainer,
controlsShowing && $style.active,
(audio.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
]"
@mouseover.passive="onMouseOver"
@mousemove.passive="onMouseMove"
@mouseleave.passive="onMouseLeave"
@contextmenu.stop
@keydown.stop
>
@ -35,71 +39,83 @@ SPDX-License-Identifier: AGPL-3.0-only
</audio>
</div>
<div v-else :class="$style.audioControls">
<div v-else :class="$style.audioRoot">
<audio
ref="audioEl"
preload="metadata"
tabindex="-1"
@keydown.prevent="() => {}"
>
<source :src="audio.url">
</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>
<canvas
ref="canvasEl"
width="1600"
height="900"
:class="$style.audio"
@keydown.prevent
@click.self="togglePlayPause"
></canvas>
<button v-if="isReady && !isPlaying" class="_button" :class="$style.audioOverlayPlayButton" @click="togglePlayPause"><i class="ti ti-player-play-filled"></i></button>
<div v-else-if="!isActuallyPlaying" :class="$style.audioLoading">
<MkLoading/>
</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>
<i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i>
<div :class="$style.indicators">
<div v-if="audio.comment" :class="$style.indicator">ALT</div>
<div v-if="audio.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.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>
<div :class="$style.audioControls" @click.self="togglePlayPause">
<div :class="[$style.controlsChild, $style.controlsLeft]">
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
<i v-else class="ti ti-player-play-filled"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsRight]">
<button class="_button" :class="$style.controlButton" @click="showMenu">
<i class="ti ti-settings"></i>
</button>
</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="volume"
:class="$style.volumeSeekbar"
v-model="rangePercent"
:sliderBgWhite="true"
:class="$style.seekbarRoot"
:buffer="bufferedDataRatio"
/>
</div>
<MkMediaRange
v-model="rangePercent"
:class="$style.seekbarRoot"
:buffer="bufferedDataRatio"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { useTemplateRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue';
import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted, shallowRef, onBeforeUnmount } from 'vue';
import * as Misskey from 'misskey-js';
import tinycolor from 'tinycolor2';
import type { MenuItem } from '@/types/menu.js';
import type { Keymap } from '@/utility/hotkey.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import 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 { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
const props = defineProps<{
audio: Misskey.entities.DriveFile;
@ -151,9 +167,6 @@ function hasFocus() {
return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement);
}
const playerEl = useTemplateRef('playerEl');
const audioEl = useTemplateRef('audioEl');
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore'));
@ -273,6 +286,150 @@ async function toggleSensitive(file: Misskey.entities.DriveFile) {
});
}
// MediaControl: Video State
const audioEl = useTemplateRef('audioEl');
const audioSource = shallowRef<MediaElementAudioSourceNode | null>(null);
const audioCtx = new AudioContext();
const gainNode = audioCtx.createGain();
gainNode.gain.value = 0.25;
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;
});
let controlStateTimer: number | null = null;
// MediaControl: Audio Visualizer
const canvasEl = useTemplateRef('canvasEl');
const canvasCtx = computed(() => {
if (!canvasEl.value) return null;
return canvasEl.value.getContext('2d');
});
const channelMergerNode = audioCtx.createChannelMerger(2);
const analyser = audioCtx.createAnalyser();
analyser.smoothingTimeConstant = 0.85;
analyser.fftSize = 2048;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const prevDataArray = new Uint8Array(bufferLength);
const WAVE_THRESHOLD = 50; //
const EXPONENTIAL_FACTOR = 2; // 調
const WAVE_REFRESH_THRESHOLD = 2; //
let visualizerTickFrameId: number | null = null;
let visualizerTickCount = 0;
const bgColor = computed(() => {
return props.audio.user?.avatarBlurhash ? extractAvgColorFromBlurhash(props.audio.user?.avatarBlurhash) ?? '#aaa' : '#aaa';
});
const fgColor = computed(() => {
const tcInstance = tinycolor(bgColor.value);
if (tcInstance.isDark()) {
return tcInstance.lighten(20).toHexString();
} else {
return tcInstance.darken(20).toHexString();
}
});
const userAvatarImage = computed(() => {
const img = new Image();
img.src = props.audio.user?.avatarUrl ?? '/static-assets/avatar.png';
return img;
});
function drawVisualizer() {
if (!canvasEl.value || !canvasCtx.value || !audioEl.value || !audioSource.value) return;
if (window.document.visibilityState === 'hidden') {
if (isActuallyPlaying.value) {
visualizerTickFrameId = window.requestAnimationFrame(drawVisualizer);
} else {
visualizerTickFrameId = null;
}
return;
}
visualizerTickCount++;
const tickStep = visualizerTickCount % WAVE_REFRESH_THRESHOLD;
canvasCtx.value.clearRect(0, 0, canvasEl.value.width, canvasEl.value.height);
if (tickStep === 0) {
prevDataArray.set(dataArray);
analyser.getByteTimeDomainData(dataArray);
}
canvasCtx.value.fillStyle = bgColor.value;
canvasCtx.value.fillRect(0, 0, canvasEl.value.width, canvasEl.value.height);
const centerX = canvasEl.value.width / 2;
const centerY = canvasEl.value.height / 2;
const radius = Math.min(centerX, centerY) * 0.8;
canvasCtx.value.beginPath();
canvasCtx.value.fillStyle = fgColor.value;
//
const points: { x: number, y: number }[] = [];
for (let i = 0; i < bufferLength; i++) {
if (i % WAVE_THRESHOLD !== 0 && i !== bufferLength - 1) continue; // Skip values below threshold
const data = prevDataArray[i] + (dataArray[i] - prevDataArray[i]) * tickStep / WAVE_REFRESH_THRESHOLD;
const value = Math.pow(data / 255 * Math.sqrt(EXPONENTIAL_FACTOR), EXPONENTIAL_FACTOR); // Exponential scaling
const angle = (i / bufferLength) * Math.PI - (Math.PI / 2); //
const x = centerX + radius * value * Math.cos(angle);
const y = centerY + radius * value * Math.sin(angle);
points.push({ x, y });
}
//
const mirroredPoints = points.map(point => ({ x: centerX - (point.x - centerX), y: point.y })).reverse();
if (points.length > 0) {
canvasCtx.value.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length - 1; i++) {
const xc = (points[i].x + points[i + 1].x) / 2;
const yc = (points[i].y + points[i + 1].y) / 2;
canvasCtx.value.quadraticCurveTo(points[i].x, points[i].y, xc, yc);
}
canvasCtx.value.lineTo(mirroredPoints[0].x, mirroredPoints[0].y);
for (let i = 1; i < mirroredPoints.length - 1; i++) {
const xc = (mirroredPoints[i].x + mirroredPoints[i + 1].x) / 2;
const yc = (mirroredPoints[i].y + mirroredPoints[i + 1].y) / 2;
canvasCtx.value.quadraticCurveTo(mirroredPoints[i].x, mirroredPoints[i].y, xc, yc);
}
canvasCtx.value.lineTo(points[0].x, points[0].y);
}
canvasCtx.value.closePath();
canvasCtx.value.fill();
//
const avatarSize = radius;
const avatarHeight = Math.max(userAvatarImage.value.height * (avatarSize / userAvatarImage.value.width), avatarSize);
const avatarWidth = Math.max(userAvatarImage.value.width * (avatarSize / userAvatarImage.value.height), avatarSize);
const avatarDx = centerX - avatarWidth / 2;
const avatarDy = centerY - avatarHeight / 2;
canvasCtx.value.save();
canvasCtx.value.beginPath();
canvasCtx.value.arc(centerX, centerY, avatarSize / 2, 0, Math.PI * 2);
canvasCtx.value.clip();
canvasCtx.value.drawImage(userAvatarImage.value, avatarDx, avatarDy, avatarWidth, avatarHeight);
canvasCtx.value.restore();
if (isActuallyPlaying.value) {
visualizerTickFrameId = window.requestAnimationFrame(drawVisualizer);
} else {
visualizerTickFrameId = null;
}
}
// MediaControl: Common State
const oncePlayed = ref(false);
const isReady = ref(false);
@ -299,9 +456,45 @@ const bufferedDataRatio = computed(() => {
});
// 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 || !audioEl.value) return;
if (audioCtx.state === 'suspended') {
audioCtx.resume().catch(err => {
console.error('Failed to resume AudioContext:', err);
});
}
if (isPlaying.value) {
audioEl.value.pause();
isPlaying.value = false;
@ -328,8 +521,18 @@ function init() {
if (onceInit) return;
onceInit = true;
if (prefer.s.useNativeUiForVideoAudioPlayer) return;
stopAudioElWatch = watch(audioEl, () => {
if (audioEl.value) {
audioSource.value = audioCtx.createMediaElementSource(audioEl.value);
// Visualizer
audioSource.value.connect(channelMergerNode).connect(analyser);
// Player
audioSource.value.connect(gainNode).connect(audioCtx.destination);
isReady.value = true;
function updateMediaTick() {
@ -346,6 +549,7 @@ function init() {
loop.value = audioEl.value.loop;
}
}
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
}
@ -373,34 +577,15 @@ function init() {
}
});
audioEl.value.volume = volume.value;
// GainNode
audioEl.value.volume = 1;
}
}, {
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(() => {
function dispose() {
isReady.value = false;
isPlaying.value = false;
isActuallyPlaying.value = false;
@ -414,6 +599,65 @@ onDeactivated(() => {
window.cancelAnimationFrame(mediaTickFrameId);
mediaTickFrameId = null;
}
if (controlStateTimer) {
window.clearTimeout(controlStateTimer);
controlStateTimer = null;
}
if (visualizerTickFrameId) {
window.cancelAnimationFrame(visualizerTickFrameId);
visualizerTickFrameId = null;
}
if (audioSource.value) {
audioSource.value.disconnect();
audioSource.value = null;
}
if (audioCtx.state !== 'closed') {
audioCtx.close().catch(err => {
console.error('Failed to close AudioContext:', err);
});
}
}
watch(volume, (to) => {
gainNode.gain.value = to;
});
watch(speed, (to) => {
if (audioEl.value) audioEl.value.playbackRate = to;
});
watch(loop, (to) => {
if (audioEl.value) audioEl.value.loop = to;
});
watch(isActuallyPlaying, (to) => {
if (to) {
if (visualizerTickFrameId) {
window.cancelAnimationFrame(visualizerTickFrameId);
}
visualizerTickFrameId = window.requestAnimationFrame(drawVisualizer);
} else {
if (visualizerTickFrameId) {
window.cancelAnimationFrame(visualizerTickFrameId);
visualizerTickFrameId = null;
}
}
});
onMounted(() => {
init();
});
onActivated(() => {
init();
});
onDeactivated(() => {
dispose();
});
onBeforeUnmount(() => {
dispose();
});
</script>
@ -421,8 +665,6 @@ onDeactivated(() => {
.audioContainer {
container-type: inline-size;
position: relative;
border: .5px solid var(--MI_THEME-divider);
border-radius: var(--MI-radius);
overflow: clip;
&:focus-visible {
@ -446,15 +688,46 @@ onDeactivated(() => {
}
}
.indicators {
display: inline-flex;
position: absolute;
top: 10px;
left: 10px;
pointer-events: none;
opacity: .5;
gap: 6px;
}
.indicator {
font-size: 0.8em;
padding: 2px 5px;
}
.hide {
display: block;
position: absolute;
border-radius: 6px;
background-color: var(--MI_THEME-fg);
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
font-size: 12px;
opacity: .5;
padding: 5px 8px;
text-align: center;
cursor: pointer;
top: 12px;
right: 12px;
}
.hidden {
width: 100%;
height: 100%;
background: #000;
border: none;
outline: none;
font: inherit;
color: inherit;
cursor: pointer;
padding: 12px 0;
padding: 60px 0;
display: flex;
align-items: center;
justify-content: center;
@ -466,6 +739,54 @@ onDeactivated(() => {
color: #fff;
}
.audioRoot {
background: #000;
position: relative;
width: 100%;
height: 100%;
object-fit: contain;
border-radius: var(--MI-radius);
overflow: clip;
}
.audio {
display: block;
height: 100%;
width: 100%;
}
.audioOverlayPlayButton {
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;
}
}
.audioLoading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.audioControls {
display: grid;
grid-template-areas:
@ -474,22 +795,47 @@ onDeactivated(() => {
grid-template-columns: auto auto 1fr auto auto;
align-items: center;
gap: 4px 8px;
padding: 10px;
padding: 35px 10px 10px 10px;
background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, .75));
position: absolute;
left: 0;
right: 0;
bottom: 0;
transform: translateY(100%);
pointer-events: none;
opacity: 0;
transition: opacity .4s ease-in-out, transform .4s ease-in-out;
}
.active {
.audioControls {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.audioOverlayPlayButton {
opacity: 1;
}
}
.controlsChild {
display: flex;
align-items: center;
gap: 4px;
color: #fff;
.controlButton {
padding: 6px;
border-radius: calc(var(--MI-radius) / 2);
transition: background-color .2s ease-in-out;
font-size: 1.05rem;
&:hover {
color: var(--MI_THEME-accent);
background-color: var(--MI_THEME-accentedBg);
background-color: var(--MI_THEME-accent);
}
&:focus-visible {
@ -521,6 +867,9 @@ onDeactivated(() => {
.seekbarRoot {
grid-area: seekbar;
/* ▼シークバー操作をやりやすくするためにクリックイベントが伝播されないエリアを拡張する */
margin: -10px;
padding: 10px;
}
@container (min-width: 500px) {