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