1
0
mirror of https://github.com/MisskeyIO/misskey synced 2024-11-27 06:18:40 +09:00

audio player visualization wip

This commit is contained in:
あわわわとーにゅ 2024-11-09 03:52:12 +09:00
parent 6cebb2b9ed
commit f8faaed369
No known key found for this signature in database
GPG Key ID: 6AFBBF529601C1DB
9 changed files with 256 additions and 39 deletions

View File

@ -35,40 +35,43 @@ SPDX-License-Identifier: AGPL-3.0-only
</audio> </audio>
</div> </div>
<div v-else :class="$style.audioControls"> <div v-else>
<audio <MkUserRippleEffect v-if="user" ref="userRippleEffect" :user="user" />
ref="audioEl" <div :class="$style.audioControls">
preload="metadata" <audio
> ref="audioEl"
<source :src="audio.url"> preload="metadata"
</audio> >
<div :class="[$style.controlsChild, $style.controlsLeft]"> <source :src="audio.url">
<button class="_button" :class="$style.controlButton" @click.prevent.stop="togglePlayPause"> </audio>
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i> <div :class="[$style.controlsChild, $style.controlsLeft]">
<i v-else class="ti ti-player-play-filled"></i> <button class="_button" :class="$style.controlButton" @click.prevent.stop="togglePlayPause">
</button> <i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
</div> <i v-else class="ti ti-player-play-filled"></i>
<div :class="[$style.controlsChild, $style.controlsRight]"> </button>
<button class="_button" :class="$style.controlButton" @click.prevent.stop="showMenu"> </div>
<i class="ti ti-settings"></i> <div :class="[$style.controlsChild, $style.controlsRight]">
</button> <button class="_button" :class="$style.controlButton" @click.prevent.stop="showMenu">
</div> <i class="ti ti-settings"></i>
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div> </button>
<div :class="[$style.controlsChild, $style.controlsVolume]"> </div>
<button class="_button" :class="$style.controlButton" @click.prevent.stop="toggleMute"> <div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
<i v-if="volume === 0" class="ti ti-volume-3"></i> <div :class="[$style.controlsChild, $style.controlsVolume]">
<i v-else class="ti ti-volume"></i> <button class="_button" :class="$style.controlButton" @click.prevent.stop="toggleMute">
</button> <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 <MkMediaRange
v-model="volume" v-model="rangePercent"
:class="$style.volumeSeekbar" :class="$style.seekbarRoot"
:buffer="bufferedDataRatio"
/> />
</div> </div>
<MkMediaRange
v-model="rangePercent"
:class="$style.seekbarRoot"
:buffer="bufferedDataRatio"
/>
</div> </div>
</div> </div>
</template> </template>
@ -83,11 +86,13 @@ import * as os from '@/os.js';
import bytes from '@/filters/bytes.js'; import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js'; import { hms } from '@/filters/hms.js';
import MkMediaRange from '@/components/MkMediaRange.vue'; import MkMediaRange from '@/components/MkMediaRange.vue';
import MkUserRippleEffect from '@/components/MkUserRippleEffect.vue';
import { pleaseLogin } from '@/scripts/please-login.js'; import { pleaseLogin } from '@/scripts/please-login.js';
import { $i, iAmModerator } from '@/account.js'; import { $i, iAmModerator } from '@/account.js';
const props = defineProps<{ const props = defineProps<{
audio: Misskey.entities.DriveFile; audio: Misskey.entities.DriveFile;
user?: Misskey.entities.UserLite;
}>(); }>();
const keymap = { const keymap = {
@ -126,6 +131,7 @@ function hasFocus() {
const playerEl = shallowRef<HTMLDivElement>(); const playerEl = shallowRef<HTMLDivElement>();
const audioEl = shallowRef<HTMLAudioElement>(); const audioEl = shallowRef<HTMLAudioElement>();
const userRippleEffect = ref<InstanceType<typeof MkUserRippleEffect>>();
// eslint-disable-next-line vue/no-setup-props-destructure // eslint-disable-next-line vue/no-setup-props-destructure
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore')); const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'));
@ -264,9 +270,11 @@ function togglePlayPause() {
if (isPlaying.value) { if (isPlaying.value) {
audioEl.value.pause(); audioEl.value.pause();
userRippleEffect.value?.pauseAnimation();
isPlaying.value = false; isPlaying.value = false;
} else { } else {
audioEl.value.play(); audioEl.value.play();
userRippleEffect.value?.resumeAnimation();
isPlaying.value = true; isPlaying.value = true;
oncePlayed.value = true; oncePlayed.value = true;
} }

View File

@ -5,12 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div :class="$style.root"> <div :class="$style.root">
<MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/> <div v-if="media.isSensitive && hide" :class="$style.sensitive" @click="showHiddenContent">
<div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="showHiddenContent">
<span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span> <span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span>
<b>{{ i18n.ts.sensitive }}</b> <b>{{ i18n.ts.sensitive }}</b>
<span>{{ i18n.ts.clickToShow }}</span> <span>{{ i18n.ts.clickToShow }}</span>
</div> </div>
<MkMediaAudio v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media" :user="user"/>
<a <a
v-else :class="$style.download" v-else :class="$style.download"
:href="media.url" :href="media.url"
@ -33,6 +33,7 @@ import { $i } from '@/account.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
media: Misskey.entities.DriveFile; media: Misskey.entities.DriveFile;
user?: Misskey.entities.UserLite;
}>(), { }>(), {
}); });

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div> <div>
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/> <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media" :user="user"/>
<div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container"> <div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container">
<div <div
ref="gallery" ref="gallery"
@ -42,6 +42,7 @@ import { defaultStore } from '@/store.js';
const props = defineProps<{ const props = defineProps<{
mediaList: Misskey.entities.DriveFile[]; mediaList: Misskey.entities.DriveFile[];
user?: Misskey.entities.UserLite;
raw?: boolean; raw?: boolean;
}>(); }>();

View File

@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<div v-if="appearNote.files && appearNote.files.length > 0"> <div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files"/> <MkMediaList :mediaList="appearNote.files" :user="appearNote.user"/>
</div> </div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/> <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
<div v-if="isEnabledUrlPreview"> <div v-if="isEnabledUrlPreview">

View File

@ -92,7 +92,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<div v-if="appearNote.files && appearNote.files.length > 0"> <div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files"/> <MkMediaList :mediaList="appearNote.files" :user="appearNote.user"/>
</div> </div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/> <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
<div v-if="isEnabledUrlPreview"> <div v-if="isEnabledUrlPreview">

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<details v-if="note.files && note.files.length > 0"> <details v-if="note.files && note.files.length > 0">
<summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary> <summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary>
<MkMediaList :mediaList="note.files"/> <MkMediaList :mediaList="note.files" :user="note.user"/>
</details> </details>
<details v-if="note.poll"> <details v-if="note.poll">
<summary>{{ i18n.ts.poll }}</summary> <summary>{{ i18n.ts.poll }}</summary>

View File

@ -0,0 +1,207 @@
<template>
<div ref="container" :class="$style.root">
<canvas ref="canvas" :class="$style.canvas"/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, shallowRef, nextTick, watch, ref } from 'vue';
import { render } from 'buraha';
const props = defineProps<{
user: Misskey.entities.UserLite;
}>();
const container = shallowRef<HTMLDivElement>();
const canvas = shallowRef<HTMLCanvasElement>();
let width: number;
let height: number;
let maxRadius: number;
let rippleSpeed: number;
let backgroundColor: string;
let animationFrameId: number;
let ctx: CanvasRenderingContext2D | null;
let circles = [];
const isPaused = ref(false);
const image = new Image();
image.src = props.user.profileImage;
const initCanvas = () => {
const parent = container.value ?? { offsetWidth: 0 };
const cv = canvas.value ?? { width: 0, height: 0 };
width = cv.width = parent.offsetWidth;
height = cv.height = Math.floor(width * 9 / 16);
ctx = canvas.value?.getContext("2d") ?? null;
maxRadius = Math.sqrt(Math.pow(width / 2, 2) + Math.pow(height / 2, 2));
rippleSpeed = maxRadius / 1500;
backgroundColor = `hsl(${360 * Math.random()},${25 + 70 * Math.random()}%,${85 + 10 * Math.random()}%)`;
if (!ctx) return;
drawBackground();
drawProfileImage();
};
const drawBackground = () => {
if (!ctx) return;
if (props.user.avatarBlurhash) {
render(props.user.avatarBlurhash, canvas.value);
} else {
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, width, height);
}
};
const drawProfileImage = () => {
if (!ctx) return;
const imgSize = Math.min(width, height) / 4; // Profile image size
const x = width / 2 - imgSize / 2;
const y = height / 2 - imgSize / 2;
ctx.save();
ctx.beginPath();
ctx.arc(width / 2, height / 2, imgSize / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(image, x, y, imgSize, imgSize);
ctx.restore();
};
const easeOutCubic = (time, start, change, duration) => {
time /= duration;
time--;
return change * (time * time * time + 1) + start;
};
const dropBall = (radius) => {
if (!ctx) return;
if (isPaused.value) return;
drawBackground();
ctx.beginPath();
ctx.fillStyle = 'black';
ctx.arc(width / 2, height / 2, radius, 0, Math.PI * 2);
ctx.closePath();
ctx.fill();
drawProfileImage();
requestAnimationFrame(() => {
if (radius > 0) {
dropBall(radius - 1);
} else {
animateRipple();
setInterval(() => {
if (!isPaused.value) animateRipple();
}, 2500);
}
});
};
const animateRipple = () => {
if (!ctx) return;
circles.forEach((circle) => (circle.radius += rippleSpeed));
circles.forEach((circle) => {
if (!ctx) return;
const currentValue = easeOutCubic(circle.radius, 0, maxRadius, maxRadius);
ctx.fillStyle = '#ebebeb';
ctx.lineWidth = 20;
ctx.shadowBlur = 50 + ((100 / maxRadius) * currentValue);
ctx.shadowColor = `rgba(0, 0, 0, ${(1 - currentValue / maxRadius) * 0.2}`;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.beginPath();
ctx.arc(width / 2, height / 2, currentValue, 0, Math.PI * 2);
ctx.closePath();
ctx.fill();
});
drawProfileImage();
circles = circles.filter((circle) => circle.radius < maxRadius);
if (circles.length) {
requestAnimationFrame(tick);
}
};
const animate = () => {
if (isPaused.value || document.visibilityState === 'hidden') return;
animateRipple();
drawProfileImage();
setTimeout(() => {
animationFrameId = requestAnimationFrame(animate);
}, 500);
};
const pauseAnimation = () => {
isPaused.value = true;
cancelAnimationFrame(animationFrameId);
};
const resumeAnimation = () => {
isPaused.value = false;
animationFrameId = requestAnimationFrame(animate);
};
const onResize = () => {
initCanvas();
drawProfileImage();
};
onMounted(async () => {
nextTick().then(() => {
onResize();
window.addEventListener("resize", onResize);
});
});
onUnmounted(() => {
window.removeEventListener("resize", onResize);
});
watch(
() => props.profileImage,
() => {
image.src = props.profileImage;
image.onload = () => {
initCanvas();
drawProfileImage();
};
},
);
defineExpose({
pauseAnimation,
resumeAnimation,
});
</script>
<style lang="scss" module>
.root {
position: relative;
width: 100%;
height: 100%;
border-radius: 8px;
pointer-events: none;
z-index: 0;
}
.canvas {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA :to="notePage(media.note)"> <MkA :to="notePage(media.note)">
<XVideo v-if="media.file.type.startsWith('video')" :key="`video:${media.file.id}`" :class="$style.media" :video="media.file" :videoControls="false"/> <XVideo v-if="media.file.type.startsWith('video')" :key="`video:${media.file.id}`" :class="$style.media" :video="media.file" :videoControls="false"/>
<XImage v-else-if="media.file.type.startsWith('image')" :key="`image:${media.file.id}`" :class="$style.media" class="image" :data-id="media.file.id" :image="media.file" :disableImageLink="true"/> <XImage v-else-if="media.file.type.startsWith('image')" :key="`image:${media.file.id}`" :class="$style.media" class="image" :data-id="media.file.id" :image="media.file" :disableImageLink="true"/>
<XBanner v-else :media="media.file"/> <XBanner v-else :media="media.file" :user="user"/>
</MkA> </MkA>
</template> </template>
</div> </div>

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div> </div>
<div v-if="note.files.length > 0" :class="$style.richcontent"> <div v-if="note.files.length > 0" :class="$style.richcontent">
<MkMediaList :mediaList="note.files"/> <MkMediaList :mediaList="note.files" :user="note.user"/>
</div> </div>
<div v-if="note.poll"> <div v-if="note.poll">
<MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/> <MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/>