1
0
mirror of https://git.boykissers.com/pawkey/pawkey-sk.git synced 2025-12-20 12:14:18 +00:00

feat: add music to user profiles (#7)

This commit is contained in:
Leafus
2025-05-19 16:34:25 +00:00
committed by Bluey Heeler
parent 9599b802a4
commit d2fb093a35
8 changed files with 209 additions and 1 deletions

View 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"`);
}
}

View File

@@ -612,6 +612,7 @@ export class UserEntityService implements OnModuleInit {
location: profile!.location, location: profile!.location,
birthday: profile!.birthday, birthday: profile!.birthday,
listenbrainz: profile!.listenbrainz, listenbrainz: profile!.listenbrainz,
musicUrl: profile!.musicUrl,
lang: profile!.lang, lang: profile!.lang,
fields: profile!.fields, fields: profile!.fields,
verifiedLinks: profile!.verifiedLinks, verifiedLinks: profile!.verifiedLinks,

View File

@@ -43,6 +43,12 @@ export class MiUserProfile {
}) })
public listenbrainz: string | null; public listenbrainz: string | null;
@Column('varchar', {
length: 2048, nullable: true,
comment: 'The music URL of the User.',
})
public musicUrl: string | null;
@Column('varchar', { @Column('varchar', {
length: 2048, nullable: true, length: 2048, nullable: true,
comment: 'The description (bio) of the User.', comment: 'The description (bio) of the User.',

View File

@@ -201,6 +201,7 @@ export const paramDef = {
autoAcceptFollowed: { type: 'boolean' }, autoAcceptFollowed: { type: 'boolean' },
noCrawle: { type: 'boolean' }, noCrawle: { type: 'boolean' },
preventAiLearning: { type: 'boolean' }, preventAiLearning: { type: 'boolean' },
musicUrl: { type: 'string', nullable: true },
noindex: { type: 'boolean' }, noindex: { type: 'boolean' },
requireSigninToViewContents: { type: 'boolean' }, requireSigninToViewContents: { type: 'boolean' },
makeNotesFollowersOnlyBefore: { type: 'integer', nullable: true }, 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.location !== undefined) profileUpdates.location = ps.location;
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.listenbrainz !== undefined) profileUpdates.listenbrainz = ps.listenbrainz; 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.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility; if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope; if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;

View File

@@ -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 :style="sliderBgWhite ? '--sliderBg: rgba(255,255,255,.25);' : '--sliderBg: var(--MI_THEME-scrollbarHandle);'">
<div :class="$style.controlsSeekbar"> <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> <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>
</div> </div>
</template> </template>
@@ -26,6 +26,7 @@ withDefaults(defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'dragEnded', value: number): void; (ev: 'dragEnded', value: number): void;
(ev: 'input', value: number): void;
}>(); }>();
const model = defineModel<string | number>({ required: true }); const model = defineModel<string | number>({ required: true });

View File

@@ -57,6 +57,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput> </MkInput>
</SearchMarker> </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']"> <SearchMarker :keywords="['listenbrain', 'music']">
<MkInput v-model="profile.listenbrainz" manualSave> <MkInput v-model="profile.listenbrainz" manualSave>
<template #label><SearchLabel>{{ i18n.ts._profile.listenbrainz }}</SearchLabel></template> <template #label><SearchLabel>{{ i18n.ts._profile.listenbrainz }}</SearchLabel></template>
@@ -222,6 +229,7 @@ const profile = reactive({
followedMessage: $i.followedMessage, followedMessage: $i.followedMessage,
location: $i.location, location: $i.location,
birthday: $i.birthday, birthday: $i.birthday,
musicUrl: $i.musicUrl,
listenbrainz: $i.listenbrainz, listenbrainz: $i.listenbrainz,
lang: assertVaildLang($i.lang) ? $i.lang : null, lang: assertVaildLang($i.lang) ? $i.lang : null,
isBot: $i.isBot ?? false, isBot: $i.isBot ?? false,
@@ -281,6 +289,7 @@ function save() {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
birthday: profile.birthday || null, birthday: profile.birthday || null,
listenbrainz: profile.listenbrainz || null, listenbrainz: profile.listenbrainz || null,
musicUrl: profile.musicUrl || null,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
lang: profile.lang || null, lang: profile.lang || null,
isBot: !!profile.isBot, isBot: !!profile.isBot,

View File

@@ -143,6 +143,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="contents _gaps"> <div class="contents _gaps">
<MkInfo v-if="user.pinnedNotes.length === 0 && $i?.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> <MkInfo v-if="user.pinnedNotes.length === 0 && $i?.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
<template v-if="narrow"> <template v-if="narrow">
<MkLazy v-if="user.musicUrl">
<XProfileMusic :key="user.id" :user="user"/>
</MkLazy>
<MkLazy> <MkLazy>
<XFiles :key="user.id" :user="user" :collapsed="true" @unfold="emit('unfoldFiles')"/> <XFiles :key="user.id" :user="user" :collapsed="true" @unfold="emit('unfoldFiles')"/>
</MkLazy> </MkLazy>
@@ -189,6 +192,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/> <XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/>
<XActivity :key="user.id" :user="user"/> <XActivity :key="user.id" :user="user"/>
<XListenBrainz v-if="user.listenbrainz && listenbrainzdata" :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> </div>
<div class="background"></div> <div class="background"></div>
@@ -243,6 +247,7 @@ function calcAge(birthdate: string): number {
const XFiles = defineAsyncComponent(() => import('./index.files.vue')); const XFiles = defineAsyncComponent(() => import('./index.files.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
const XListenBrainz = defineAsyncComponent(() => import('./index.listenbrainz.vue')); const XListenBrainz = defineAsyncComponent(() => import('./index.listenbrainz.vue'));
const XProfileMusic = defineAsyncComponent(() => import('./index.profilemusic.vue'))
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
user: Misskey.entities.UserDetailed; user: Misskey.entities.UserDetailed;

View 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>