1
0
mirror of https://github.com/MisskeyIO/misskey synced 2024-11-23 14:46: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>
</div>
<div v-else :class="$style.audioControls">
<audio
ref="audioEl"
preload="metadata"
>
<source :src="audio.url">
</audio>
<div :class="[$style.controlsChild, $style.controlsLeft]">
<button class="_button" :class="$style.controlButton" @click.prevent.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" :class="$style.controlButton" @click.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" :class="$style.controlButton" @click.prevent.stop="toggleMute">
<i v-if="volume === 0" class="ti ti-volume-3"></i>
<i v-else class="ti ti-volume"></i>
</button>
<div v-else>
<MkUserRippleEffect v-if="user" ref="userRippleEffect" :user="user" />
<div :class="$style.audioControls">
<audio
ref="audioEl"
preload="metadata"
>
<source :src="audio.url">
</audio>
<div :class="[$style.controlsChild, $style.controlsLeft]">
<button class="_button" :class="$style.controlButton" @click.prevent.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" :class="$style.controlButton" @click.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" :class="$style.controlButton" @click.prevent.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="volume"
:class="$style.volumeSeekbar"
v-model="rangePercent"
:class="$style.seekbarRoot"
:buffer="bufferedDataRatio"
/>
</div>
<MkMediaRange
v-model="rangePercent"
:class="$style.seekbarRoot"
:buffer="bufferedDataRatio"
/>
</div>
</div>
</template>
@ -83,11 +86,13 @@ import * as os from '@/os.js';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import MkUserRippleEffect from '@/components/MkUserRippleEffect.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { $i, iAmModerator } from '@/account.js';
const props = defineProps<{
audio: Misskey.entities.DriveFile;
user?: Misskey.entities.UserLite;
}>();
const keymap = {
@ -126,6 +131,7 @@ function hasFocus() {
const playerEl = shallowRef<HTMLDivElement>();
const audioEl = shallowRef<HTMLAudioElement>();
const userRippleEffect = ref<InstanceType<typeof MkUserRippleEffect>>();
// 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'));
@ -264,9 +270,11 @@ function togglePlayPause() {
if (isPlaying.value) {
audioEl.value.pause();
userRippleEffect.value?.pauseAnimation();
isPlaying.value = false;
} else {
audioEl.value.play();
userRippleEffect.value?.resumeAnimation();
isPlaying.value = true;
oncePlayed.value = true;
}

View File

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

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<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
ref="gallery"
@ -42,6 +42,7 @@ import { defaultStore } from '@/store.js';
const props = defineProps<{
mediaList: Misskey.entities.DriveFile[];
user?: Misskey.entities.UserLite;
raw?: boolean;
}>();

View File

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

View File

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

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<details v-if="note.files && note.files.length > 0">
<summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary>
<MkMediaList :mediaList="note.files"/>
<MkMediaList :mediaList="note.files" :user="note.user"/>
</details>
<details v-if="note.poll">
<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)">
<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"/>
<XBanner v-else :media="media.file"/>
<XBanner v-else :media="media.file" :user="user"/>
</MkA>
</template>
</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>
</div>
<div v-if="note.files.length > 0" :class="$style.richcontent">
<MkMediaList :mediaList="note.files"/>
<MkMediaList :mediaList="note.files" :user="note.user"/>
</div>
<div v-if="note.poll">
<MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/>