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,
|
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,
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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