mirror of
https://git.boykissers.com/pawkey/pawkey-sk.git
synced 2025-12-20 04:04:16 +00:00
feat: add music to user profiles (#7)
This commit is contained in:
12
packages/backend/migration/1743916276027-ProfileMusic.js
Normal file
12
packages/backend/migration/1743916276027-ProfileMusic.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('typeorm').MigrationInterface} */
|
||||
export class AddProfileMusic1743916276027 {
|
||||
name = 'AddProfileMusic1743916276027'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ADD "musicUrl" character varying(2048)`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "musicUrl"`);
|
||||
}
|
||||
}
|
||||
@@ -612,6 +612,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
location: profile!.location,
|
||||
birthday: profile!.birthday,
|
||||
listenbrainz: profile!.listenbrainz,
|
||||
musicUrl: profile!.musicUrl,
|
||||
lang: profile!.lang,
|
||||
fields: profile!.fields,
|
||||
verifiedLinks: profile!.verifiedLinks,
|
||||
|
||||
@@ -43,6 +43,12 @@ export class MiUserProfile {
|
||||
})
|
||||
public listenbrainz: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 2048, nullable: true,
|
||||
comment: 'The music URL of the User.',
|
||||
})
|
||||
public musicUrl: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 2048, nullable: true,
|
||||
comment: 'The description (bio) of the User.',
|
||||
|
||||
@@ -201,6 +201,7 @@ export const paramDef = {
|
||||
autoAcceptFollowed: { type: 'boolean' },
|
||||
noCrawle: { type: 'boolean' },
|
||||
preventAiLearning: { type: 'boolean' },
|
||||
musicUrl: { type: 'string', nullable: true },
|
||||
noindex: { type: 'boolean' },
|
||||
requireSigninToViewContents: { type: 'boolean' },
|
||||
makeNotesFollowersOnlyBefore: { type: 'integer', nullable: true },
|
||||
@@ -335,6 +336,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
if (ps.location !== undefined) profileUpdates.location = ps.location;
|
||||
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
|
||||
if (ps.listenbrainz !== undefined) profileUpdates.listenbrainz = ps.listenbrainz;
|
||||
if (ps.musicUrl !== undefined) profileUpdates.musicUrl = ps.musicUrl;
|
||||
if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
|
||||
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
|
||||
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
|
||||
|
||||
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div :style="sliderBgWhite ? '--sliderBg: rgba(255,255,255,.25);' : '--sliderBg: var(--MI_THEME-scrollbarHandle);'">
|
||||
<div :class="$style.controlsSeekbar">
|
||||
<progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress>
|
||||
<input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/>
|
||||
<input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)" @input="emit('input', modelValue)"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,6 +26,7 @@ withDefaults(defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'dragEnded', value: number): void;
|
||||
(ev: 'input', value: number): void;
|
||||
}>();
|
||||
|
||||
const model = defineModel<string | number>({ required: true });
|
||||
|
||||
@@ -57,6 +57,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkInput>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['music', 'url']">
|
||||
<MkInput v-model="profile.musicUrl" manualSave>
|
||||
<template #label><SearchLabel>{{ i18n.ts._profile.musicUrl }}</SearchLabel></template>
|
||||
<template #prefix><i class="ti ti-music"></i></template>
|
||||
</MkInput>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['listenbrain', 'music']">
|
||||
<MkInput v-model="profile.listenbrainz" manualSave>
|
||||
<template #label><SearchLabel>{{ i18n.ts._profile.listenbrainz }}</SearchLabel></template>
|
||||
@@ -222,6 +229,7 @@ const profile = reactive({
|
||||
followedMessage: $i.followedMessage,
|
||||
location: $i.location,
|
||||
birthday: $i.birthday,
|
||||
musicUrl: $i.musicUrl,
|
||||
listenbrainz: $i.listenbrainz,
|
||||
lang: assertVaildLang($i.lang) ? $i.lang : null,
|
||||
isBot: $i.isBot ?? false,
|
||||
@@ -281,6 +289,7 @@ function save() {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
birthday: profile.birthday || null,
|
||||
listenbrainz: profile.listenbrainz || null,
|
||||
musicUrl: profile.musicUrl || null,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
lang: profile.lang || null,
|
||||
isBot: !!profile.isBot,
|
||||
|
||||
@@ -143,6 +143,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div class="contents _gaps">
|
||||
<MkInfo v-if="user.pinnedNotes.length === 0 && $i?.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
|
||||
<template v-if="narrow">
|
||||
<MkLazy v-if="user.musicUrl">
|
||||
<XProfileMusic :key="user.id" :user="user"/>
|
||||
</MkLazy>
|
||||
<MkLazy>
|
||||
<XFiles :key="user.id" :user="user" :collapsed="true" @unfold="emit('unfoldFiles')"/>
|
||||
</MkLazy>
|
||||
@@ -189,6 +192,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/>
|
||||
<XActivity :key="user.id" :user="user"/>
|
||||
<XListenBrainz v-if="user.listenbrainz && listenbrainzdata" :key="user.id" :user="user"/>
|
||||
<XProfileMusic v-if="user.musicUrl" :key="user.id" :user="user" :collapsed="true"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="background"></div>
|
||||
@@ -243,6 +247,7 @@ function calcAge(birthdate: string): number {
|
||||
const XFiles = defineAsyncComponent(() => import('./index.files.vue'));
|
||||
const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
|
||||
const XListenBrainz = defineAsyncComponent(() => import('./index.listenbrainz.vue'));
|
||||
const XProfileMusic = defineAsyncComponent(() => import('./index.profilemusic.vue'))
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
user: Misskey.entities.UserDetailed;
|
||||
|
||||
172
packages/frontend/src/pages/user/index.profilemusic.vue
Normal file
172
packages/frontend/src/pages/user/index.profilemusic.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: Leafus and other pawkey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkContainer :foldable="true" :expanded="true">
|
||||
<template #header>
|
||||
<i class="ph-music-note ph-bold ph-lg" style="margin-right: 0.5em"></i>Profile Music
|
||||
</template>
|
||||
|
||||
<div style="padding: 8px">
|
||||
<div v-if="user.musicUrl" class="music-player">
|
||||
<audio ref="audioEl" :src="user.musicUrl" class="audio-player" loop @timeupdate="onTimeUpdate" @ended="onEnded">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<div class="controls">
|
||||
<button class="control-button" @click="togglePlay">
|
||||
<i :class="isPlaying ? 'ph-pause ph-bold ph-lg' : 'ph-play ph-bold ph-lg'"></i>
|
||||
</button>
|
||||
<button class="control-button" @click="toggleMute">
|
||||
<i :class="isMuted ? 'ph-speaker-x ph-bold ph-lg' : 'ph-speaker-high ph-bold ph-lg'"></i>
|
||||
</button>
|
||||
<div class="volume-control">
|
||||
<MkMediaRange v-model="volume" @input="onVolumeChange"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-music">
|
||||
No music set
|
||||
</div>
|
||||
</div>
|
||||
</MkContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as misskey from 'misskey-js';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import MkContainer from '@/components/MkContainer.vue';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
|
||||
type UserDetailedWithMusic = misskey.entities.UserDetailed & {
|
||||
musicUrl?: string | null;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
user: UserDetailedWithMusic;
|
||||
collapsed?: boolean;
|
||||
}>(), {
|
||||
collapsed: false,
|
||||
},
|
||||
);
|
||||
|
||||
const audioEl = ref<HTMLAudioElement | null>(null);
|
||||
const isPlaying = ref(false);
|
||||
const isMuted = ref(false);
|
||||
const volume = ref(1);
|
||||
const progress = ref(0);
|
||||
|
||||
function togglePlay() {
|
||||
if (!audioEl.value) return;
|
||||
if (isPlaying.value) {
|
||||
audioEl.value.pause();
|
||||
} else {
|
||||
audioEl.value.play();
|
||||
}
|
||||
isPlaying.value = !isPlaying.value;
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (!audioEl.value) return;
|
||||
audioEl.value.muted = !audioEl.value.muted;
|
||||
isMuted.value = !isMuted.value;
|
||||
}
|
||||
|
||||
function onVolumeChange(value: number) {
|
||||
if (!audioEl.value) return;
|
||||
audioEl.value.volume = value;
|
||||
if (value === 0) {
|
||||
isMuted.value = true;
|
||||
} else if (isMuted.value) {
|
||||
isMuted.value = false;
|
||||
audioEl.value.muted = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
if (!audioEl.value) return;
|
||||
progress.value = audioEl.value.currentTime / audioEl.value.duration;
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
isPlaying.value = false;
|
||||
progress.value = 0;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (audioEl.value) {
|
||||
audioEl.value.volume = volume.value;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (audioEl.value) {
|
||||
audioEl.value.pause();
|
||||
audioEl.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.music-player {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--MI_THEME-divider);
|
||||
border-radius: var(--MI-radius-sm);
|
||||
}
|
||||
|
||||
.control-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--MI-radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--MI_THEME-fg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--MI_THEME-accent) 10%, transparent) !important;
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.no-music {
|
||||
text-align: center;
|
||||
color: var(--MI_THEME-fgTransparent);
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user