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:
parent
6cebb2b9ed
commit
f8faaed369
@ -35,7 +35,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div v-else :class="$style.audioControls">
|
||||
<div v-else>
|
||||
<MkUserRippleEffect v-if="user" ref="userRippleEffect" :user="user" />
|
||||
<div :class="$style.audioControls">
|
||||
<audio
|
||||
ref="audioEl"
|
||||
preload="metadata"
|
||||
@ -71,6 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}>(), {
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}>();
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
207
packages/frontend/src/components/MkUserRippleEffect.vue
Normal file
207
packages/frontend/src/components/MkUserRippleEffect.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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"/>
|
||||
|
Loading…
Reference in New Issue
Block a user