Add SUNO support (#82)

Co-authored-by: Germey <germey@acedata.cloud>
This commit is contained in:
HCode
2024-07-27 18:34:43 +00:00
committed by GitHub
co-authored by Germey
parent ffdf6cbbdf
commit a46fc4ef3c
237 changed files with 9933 additions and 4641 deletions
+3 -1
View File
@@ -72,5 +72,7 @@
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"cSpell.words": ["Chatdoc"]
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
+6
View File
@@ -65,3 +65,9 @@ Artistic QR Code:
![](https://cdn.acedata.cloud/q3ivan.png)
<img src="https://cdn.acedata.cloud/l11ebv.png" style="width: 300px">
Suno Music:
![](https://cdn.acedata.cloud/mewx2.png)
<img src="https://cdn.acedata.cloud/4mvwe.png" style="width: 300px">
@@ -0,0 +1,7 @@
{
"type": "major",
"comment": "init suno",
"packageName": "@acedatacloud/nexior",
"email": "office@acedata.cloud",
"dependentChangeType": "patch"
}
+1
View File
@@ -35,6 +35,7 @@
"@fortawesome/free-regular-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
"@fortawesome/vue-fontawesome": "^3.0.2",
"@icon-park/vue-next": "^1.3.6",
"axios": "^1.6.0",
"codemirror": "^6.0.1",
"copy-to-clipboard": "^3.3.3",
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+3
View File
@@ -0,0 +1,3 @@
import OpticalDisk from './disk.png';
export { OpticalDisk };
+4 -1
View File
@@ -13,7 +13,10 @@
<el-menu :collapse="false" class="menu">
<el-popover :width="350" trigger="hover">
<template #reference>
<el-menu-item v-if="site?.features?.support?.wechat?.enabled" index="1">
<el-menu-item
v-if="site?.features?.support?.wechat?.enabled && site?.features?.support?.wechat?.qr"
index="1"
>
<font-awesome-icon icon="fa-brands fa-weixin" class="mr-2" />
<template #title>{{ $t('common.message.addWeChat') }}</template>
</el-menu-item>
+27
View File
@@ -0,0 +1,27 @@
<template>
<Component
:is="icon"
:theme="theme"
:size="size"
:spin="spin"
:fill="fill"
:stroke-linecap="strokeLinecap"
:stroke-linejoin="strokeLinejoin"
:stroke-width="strokeWidth"
/>
</template>
<script setup lang="ts">
import type { Icon } from '@icon-park/vue-next/lib/runtime';
defineProps<{
icon: Icon;
theme?: 'outline' | 'filled' | 'two-tone' | 'multi-color';
size?: number | string;
spin?: boolean;
fill?: string | string[];
strokeLinecap?: 'butt' | 'round' | 'square';
strokeLinejoin?: 'miter' | 'round' | 'bevel';
strokeWidth?: number;
}>();
</script>
+15
View File
@@ -108,7 +108,9 @@ import {
ROUTE_INDEX,
ROUTE_MIDJOURNEY_INDEX,
ROUTE_QRART_INDEX,
ROUTE_SUNO_INDEX,
ROUTE_QRART_HISTORY,
ROUTE_SUNO_HISTORY,
ROUTE_SITE_INDEX
} from '@/router/constants';
import LogoTiny from './LogoTiny.vue';
@@ -164,6 +166,7 @@ export default defineComponent({
});
}
/*
if (this.$store?.state?.site?.features?.chatdoc?.enabled) {
result.push({
route: {
@@ -174,6 +177,7 @@ export default defineComponent({
routes: [ROUTE_CHATDOC_INDEX, ROUTE_CHATDOC_CONVERSATION, ROUTE_CHATDOC_MANAGE, ROUTE_CHATDOC_SETTING]
});
}
*/
if (this.$store?.state?.site?.features?.qrart?.enabled) {
result.push({
@@ -185,6 +189,17 @@ export default defineComponent({
routes: [ROUTE_QRART_INDEX, ROUTE_QRART_HISTORY]
});
}
// Add suno's leftmost icon
if (this.$store?.state?.site?.features?.suno?.enabled) {
result.push({
route: {
name: ROUTE_SUNO_INDEX
},
displayName: this.$t('common.nav.suno'),
icon: 'fa-solid fa-music',
routes: [ROUTE_SUNO_INDEX, ROUTE_SUNO_HISTORY]
});
}
if (this.direction === 'row') {
result.push({
+1
View File
@@ -12,6 +12,7 @@
index="/midjourney"
></el-menu-item>
<el-menu-item v-if="site?.features?.qrart?.enabled" v-t="'index.title.qrart'" index="/qrart"></el-menu-item>
<el-menu-item v-if="site?.features?.suno?.enabled" v-t="'index.title.suno'" index="/suno"></el-menu-item>
</el-menu>
</el-col>
<el-col :md="4" :xs="11">
+85
View File
@@ -0,0 +1,85 @@
<template>
<div class="panel">
<div class="config">
<type-selector class="mb-4" />
<instrument-switch class="mb-4 instrument" />
<prompt-input v-if="!config?.custom" class="mb-4" />
<lyric-input v-if="config?.custom && !config.instrumental" class="mb-4" />
<style-input v-if="config?.custom" class="mb-4" />
<title-input v-if="config?.custom" class="mb-4" />
<div class="actions">
<el-button type="primary" class="btn w-full" round @click="onGenerate">
<font-awesome-icon icon="fa-solid fa-magic" class="mr-2" />
{{ $t('suno.button.generate') }}
</el-button>
</div>
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue';
import { ElButton } from 'element-plus';
import TypeSelector from './config/TypeSelector.vue';
import PromptInput from './config/PromptInput.vue';
import LyricInput from './config/LyricInput.vue';
import StyleInput from './config/StyleInput.vue';
import TitleInput from './config/TitleInput.vue';
import InstrumentSwitch from './config/InstrumentSwitch.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
export default defineComponent({
name: 'PresetPanel',
components: {
TypeSelector,
PromptInput,
LyricInput,
InstrumentSwitch,
StyleInput,
TitleInput,
FontAwesomeIcon,
ElButton
},
emits: ['generate'],
computed: {
config() {
return this.$store.state.suno?.config;
}
},
methods: {
onGenerate() {
this.$emit('generate');
}
}
});
</script>
<style lang="scss" scoped>
.panel {
height: 100%;
padding: 15px;
display: flex;
flex-direction: column;
.config {
width: 100%;
height: calc(100% - 50px);
flex: 1;
position: relative;
.instrument {
position: absolute;
top: 50px;
right: -10px;
z-index: 1000;
}
}
.actions {
height: 50px;
display: flex;
justify-content: center;
align-items: center;
.btn {
width: 100%;
}
}
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<template>
<el-scrollbar class="right-panel">
<div v-if="audio?.object" class="card">
<div class="image-container">
<el-image :src="audio.image_url" fit="cover">
<template #error>
<div class="image-slot">
<el-icon><icon-picture /></el-icon>
</div>
</template>
</el-image>
<h2 class="title">{{ audio?.title }}</h2>
</div>
<div class="content">
<div class="artist">
<el-avatar :size="30" :src="audio?.image_url"></el-avatar>
<span>{{ audio?.prompt }}</span>
</div>
<p class="style">{{ audio?.style }}</p>
<p class="time">{{ $dayjs.format(audio?.created_at) }}</p>
<div class="lyrics">
<p>{{ audio?.lyric }}</p>
</div>
</div>
</div>
<div v-else class="gradient-background"></div>
</el-scrollbar>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { ElImage, ElScrollbar, ElAvatar, ElIcon } from 'element-plus';
import { Picture as IconPicture } from '@element-plus/icons-vue';
export default defineComponent({
name: 'TaskPreview',
components: {
ElScrollbar,
IconPicture,
ElImage,
ElAvatar,
ElIcon
},
data() {
return {
showCloseIcon: true
};
},
computed: {
audio() {
return this.$store.state.suno?.audio;
}
},
methods: {
closeCard() {
this.showCloseIcon = !this.showCloseIcon;
}
}
});
</script>
<style scoped>
.right-panel {
padding: 2px;
background-color: (--el-bg-color);
}
.cover-image {
width: 100%;
height: auto;
margin-bottom: 10px;
}
.card-scrollbar {
height: 600px;
width: 300px;
}
.card {
width: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.image-container {
position: relative;
height: 300px;
}
.image-container .el-image {
width: 100%;
height: 100%;
}
.close-icon {
position: absolute;
top: 10px;
right: 10px;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
padding: 5px;
color: white;
z-index: 2;
}
.content {
padding: 15px;
}
.title {
position: absolute;
bottom: 0;
left: 0;
right: 0;
margin: 0;
padding: 15px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0));
color: white;
z-index: 1;
}
.artist {
display: flex;
align-items: center;
margin: 10px 0;
font-weight: bold;
}
.artist span {
margin-right: 10px;
}
.actions {
display: flex;
justify-content: space-around;
margin: 10px 0;
}
.action-icon {
font-size: 24px;
cursor: pointer;
}
.time {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.lyrics {
margin: 15px 0;
font-size: 14px;
line-height: 25px;
white-space: pre-wrap;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
<template>
<div class="panel recent">
<div v-if="tasks?.items === undefined" class="tasks">
<div v-for="_ in 3" :key="_" class="task placeholder">
<div class="left">
<el-skeleton animated>
<template #template>
<el-skeleton-item variant="image" class="avatar" />
</template>
</el-skeleton>
</div>
<div class="main">
<el-skeleton animated>
<template #template>
<el-skeleton-item variant="p" class="title" />
<el-skeleton-item variant="text" style="margin-right: 16px" />
<el-skeleton-item variant="text" style="width: 80%" />
</template>
</el-skeleton>
</div>
</div>
</div>
<div v-else-if="tasks?.items?.length && tasks?.items?.length > 0" class="tasks">
<task-preview v-for="(task, taskId) in tasks?.items" :key="taskId" :model-value="task" class="preview" />
</div>
<p v-if="tasks?.items?.length === 0" class="description">
{{ $t('suno.message.noTasks') }}
</p>
</div>
<div v-show="!!$store?.state?.suno?.audio?.object" class="flex-1 flex flex-col">
<div class="h-20">
<player />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import TaskPreview from './task/Preview.vue';
import Player from '@/components/suno/player/Player.vue';
import { ElSkeleton, ElSkeletonItem } from 'element-plus';
// import { ISunoAudio, ISunoAudioLyric, ISunoTask } from '@/models';
export default defineComponent({
name: 'RecentPanel',
components: {
TaskPreview,
ElSkeleton,
ElSkeletonItem,
Player
},
data() {
return {
job: 0
};
},
computed: {
tasks() {
// reverse the order of the tasks.items
return {
...this.$store.state.suno?.tasks,
items: this.$store.state.suno?.tasks?.items?.slice().reverse()
};
}
}
});
</script>
<style lang="scss" scoped>
.panel {
display: flex;
flex-direction: column;
align-items: center;
// justify-content: center;
// height: 100%;
&.recent {
width: 100%;
height: 100%;
display: flex;
overflow-y: auto;
flex-direction: column;
.preview {
margin-right: 15px;
}
.description {
text-align: left;
font-size: 14px;
color: var(--el-text-color-secondary);
}
.tasks {
width: 100%;
.task {
width: 100%;
height: fit-content;
text-align: left;
&.placeholder {
display: flex;
flex-direction: row;
.left {
width: 70px;
padding: 10px;
.avatar {
width: 50px;
height: 50px;
// border-radius: 50%;
}
}
.main {
width: calc(100% - 70px);
flex: 1;
padding: 10px;
margin-bottom: 10px;
.icon {
display: flex;
height: 200px;
width: 300px;
}
.title {
display: block;
width: 200px;
height: 20px;
margin-bottom: 15px;
}
}
}
.operations {
height: fit-content !important;
}
}
}
}
}
</style>
@@ -0,0 +1,61 @@
<template>
<div class="field">
<el-switch v-model="instrumental" class="value mr-2" />
<h2 class="title inline-block">{{ $t('suno.name.instrumental') }}</h2>
</div>
</template>
<script>
import { defineComponent } from 'vue';
import { ElSwitch } from 'element-plus';
export const DEFAULT_INSTRUMENTAL = false;
export default defineComponent({
name: 'InstrumentSwitch',
components: {
ElSwitch
},
data() {
return {};
},
computed: {
instrumental: {
get() {
return this.$store.state.suno?.config?.instrumental;
},
set(val) {
console.debug('set instrumental', val);
this.$store.commit('suno/setConfig', {
...this.$store.state.suno?.config,
instrumental: val
});
}
}
},
mounted() {
if (this.instrumental === undefined) {
this.instrumental = DEFAULT_INSTRUMENTAL;
}
}
});
</script>
<style lang="scss" scoped>
.field {
display: flex;
flex-direction: row;
align-items: center;
width: 120px;
.title {
font-size: 14px;
margin: 0;
flex: 1;
}
.value {
width: 50px;
}
}
</style>
+73
View File
@@ -0,0 +1,73 @@
<template>
<div class="field">
<div class="box">
<h2 class="title">{{ $t('suno.name.lyrics') }}</h2>
<info-icon :content="$t('suno.description.lyrics')" class="info" />
</div>
<el-input
v-model="lyric"
:rows="3"
type="textarea"
class="lyrics"
:placeholder="$t('suno.placeholder.lyrics')"
:maxlength="3000"
show-word-limit
/>
</div>
</template>
<script>
import { defineComponent } from 'vue';
import { ElInput } from 'element-plus';
import InfoIcon from '@/components/common/InfoIcon.vue';
export const DEFAULT_LYRIC = '';
export default defineComponent({
name: 'LyricInput',
components: {
ElInput,
InfoIcon
},
data() {
return {};
},
computed: {
lyric: {
get() {
return this.$store.state.suno?.config?.lyric;
},
set(val) {
console.debug('set lyric', val);
this.$store.commit('suno/setConfig', {
...this.$store.state.suno?.config,
lyric: val
});
}
},
config() {
return this.$store.state.suno?.config;
}
},
mounted() {
if (!this.value) {
this.value = DEFAULT_LYRIC;
}
}
});
</script>
<style lang="scss" scoped>
.field {
.box {
display: flex;
flex-direction: row;
align-items: center;
position: relative;
.title {
font-size: 14px;
margin-bottom: 10px;
}
}
}
</style>
@@ -0,0 +1,70 @@
<template>
<div class="field">
<div class="box">
<h2 class="title">{{ $t('suno.name.prompt') }}</h2>
<info-icon :content="$t('suno.description.prompt')" class="info" />
</div>
<el-input
v-model="prompt"
:rows="3"
type="textarea"
class="prompt"
:placeholder="$t('suno.placeholder.prompt')"
:maxlength="300"
show-word-limit
/>
</div>
</template>
<script>
import { defineComponent } from 'vue';
import { ElInput } from 'element-plus';
import InfoIcon from '@/components/common/InfoIcon.vue';
export const DEFAULT_PROMPT = '';
export default defineComponent({
name: 'PromptInput',
components: {
ElInput,
InfoIcon
},
data() {
return {};
},
computed: {
prompt: {
get() {
return this.$store.state.suno?.config?.prompt;
},
set(val) {
console.debug('set prompt', val);
this.$store.commit('suno/setConfig', {
...this.$store.state.suno?.config,
prompt: val
});
}
}
},
mounted() {
if (!this.value) {
this.value = DEFAULT_PROMPT;
}
}
});
</script>
<style lang="scss" scoped>
.field {
.box {
display: flex;
flex-direction: row;
align-items: center;
position: relative;
.title {
font-size: 14px;
margin-bottom: 10px;
}
}
}
</style>
+68
View File
@@ -0,0 +1,68 @@
<template>
<div class="field">
<div class="title-container">
<h2 class="title">{{ $t('suno.name.style') }}</h2>
<info-icon :content="$t('suno.description.style')" class="info" />
</div>
<el-input
v-model="style"
:rows="3"
type="textarea"
class="style"
:placeholder="$t('suno.placeholder.style')"
:maxlength="300"
show-word-limit
/>
</div>
</template>
<script>
import { defineComponent } from 'vue';
import { ElInput } from 'element-plus';
import InfoIcon from '@/components/common/InfoIcon.vue';
export default defineComponent({
name: 'StyleInput',
components: {
ElInput,
InfoIcon
},
data() {
return {};
},
computed: {
style: {
get() {
return this.$store.state.suno?.config?.style;
},
set(val) {
console.debug('set style', val);
this.$store.commit('suno/setConfig', {
...this.$store.state.suno?.config,
style: val
});
}
},
config() {
return this.$store.state.suno?.config;
}
}
});
</script>
<style lang="scss" scoped>
.field {
.title-container {
display: flex;
flex-direction: row;
align-items: center;
.title {
font-size: 14px;
margin-bottom: 10px;
}
.right-aligned-switch {
float: left;
}
}
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<template>
<div class="field">
<div class="title-container">
<h2 class="title">{{ $t('suno.name.title') }}</h2>
<info-icon :content="$t('suno.description.title')" class="info" />
</div>
<el-input
v-model="title"
:rows="3"
type="textarea"
class="title"
:placeholder="$t('suno.placeholder.title')"
:maxlength="300"
show-word-limit
/>
</div>
</template>
<script>
import { defineComponent } from 'vue';
import { ElInput } from 'element-plus';
import InfoIcon from '@/components/common/InfoIcon.vue';
export const DEFAULT_PROMPT = '';
export default defineComponent({
name: 'TitleInput',
components: {
ElInput,
InfoIcon
},
data() {
return {};
},
computed: {
title: {
get() {
return this.$store.state.suno?.config?.title;
},
set(val) {
console.debug('set title', val);
this.$store.commit('suno/setConfig', {
...this.$store.state.suno?.config,
title: val
});
}
},
instrumental: {
get() {
return this.$store.state.suno?.config?.instrumental;
},
set(val) {
console.debug('set instrumental', val);
this.$store.commit('suno/setConfig', {
...this.$store.state.suno?.config,
instrumental: val
});
}
}
},
mounted() {
if (!this.value) {
this.value = DEFAULT_PROMPT;
}
}
});
</script>
<style lang="scss" scoped>
.field {
.title-container {
display: flex;
flex-direction: row;
align-items: center;
.title {
font-size: 14px;
margin-bottom: 10px;
}
.right-aligned-switch {
float: left;
}
}
}
</style>
@@ -0,0 +1,99 @@
<template>
<div class="field">
<h2 class="title">{{ $t('suno.name.type') }}</h2>
<el-switch v-model="custom" class="value" />
<el-select v-model="model" class="value" :placeholder="$t('suno.placeholder.select')">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
</template>
<script>
import { defineComponent } from 'vue';
import { ElSelect, ElOption, ElSwitch } from 'element-plus';
import { SUNO_DEFAULT_MODEL } from '@/constants';
export default defineComponent({
name: 'VersionSelector',
components: {
ElSelect,
ElOption,
ElSwitch
},
props: {
modelValue: {
type: String,
default: undefined
}
},
emits: ['update:modelValue'],
data() {
return {
options: [
{
label: this.$t('suno.model.model1'),
value: 'chirp-v2-xxl-alpha'
},
{
label: this.$t('suno.model.model2'),
value: 'chirp-v3-0'
},
{
label: this.$t('suno.model.model3'),
value: 'chirp-v3-5'
}
]
};
},
computed: {
custom: {
get() {
return this.$store.state.suno?.config?.custom;
},
set(val) {
console.debug('set custom', val);
this.$store.commit('suno/setConfig', {
...this.$store.state.suno?.config,
custom: val
});
}
},
model: {
get() {
return this.$store.state.suno?.config?.model;
},
set(val) {
console.debug('set model', val);
this.$store.commit('suno/setConfig', {
...this.$store.state.suno?.config,
model: val
});
}
}
},
mounted() {
if (!this.model) {
console.debug('set default type', SUNO_DEFAULT_MODEL);
this.model = SUNO_DEFAULT_MODEL;
}
}
});
</script>
<style lang="scss" scoped>
.field {
display: flex;
flex-direction: row;
align-items: center;
.title {
font-size: 14px;
margin: 0;
width: 30%;
}
.value {
flex: 1;
}
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<template>
<div class="flex flex-col items-stretch h-20">
<player-slider />
<div class="flex grow px-5 items-center">
<div class="flex-1">
<player-song />
</div>
<div class="flex-1">
<player-controller />
</div>
<div class="flex-1">
<player-action />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import PlayerSlider from './PlayerSlider.vue';
import PlayerSong from './PlayerSong.vue';
import PlayerAction from './PlayerAction.vue';
import PlayerController from './PlayerController.vue';
</script>
@@ -0,0 +1,33 @@
<template>
<div class="flex justify-end items-center gap-x-2.5">
<span class="text-xs"> {{ useFormatDuring(progress) }} / {{ useFormatDuring(duration) }} </span>
</div>
</template>
<script setup lang="ts">
import { useFormatDuring } from '@/utils/number';
import { computed } from 'vue';
import { useStore } from 'vuex';
const store = useStore();
const progress = computed({
get: () => store.state.suno?.audio?.progress,
set: (value) =>
store.commit('suno/setAudio', {
...store.state.suno.audio,
progress: value
})
});
const duration = computed({
get: () => store.state.suno?.audio?.duration,
set: (value) =>
store.commit('suno/setAudio', {
...store.state.suno.audio,
duration: value
})
});
</script>
<style lang="scss"></style>
@@ -0,0 +1,40 @@
<template>
<div class="flex items-center justify-center gap-x-3">
<icon-park
:icon="audio?.state === 'playing' ? PauseOne : Play"
size="45"
theme="filled"
class="control"
@click="togglePlay"
/>
<el-popover placement="top" width="50px">
<template #reference>
<icon-park :icon="VolumeSmall" size="20" :stroke-width="3" class="hover-text" />
</template>
<player-volume-slider />
</el-popover>
</div>
</template>
<script setup lang="ts">
import { Play, PauseOne, VolumeSmall } from '@icon-park/vue-next';
import IconPark from '@/components/common/IconPark.vue';
import PlayerVolumeSlider from './PlayerVolumeSlider.vue';
import { ElPopover } from 'element-plus';
import { computed } from 'vue';
import { useStore } from 'vuex';
const store = useStore();
const togglePlay = () =>
store.dispatch('suno/setAudio', {
...store.state.suno.audio,
state: store.state.suno.audio.state === 'playing' ? 'paused' : 'playing'
});
const audio = computed(() => store.state.suno.audio);
</script>
<style lang="scss">
.control {
color: var(--el-color-primary);
}
</style>
@@ -0,0 +1,79 @@
<template>
<div class="player-slider">
<el-slider
v-model="progress"
:show-tooltip="false"
:min="0"
:max="duration"
@change="onSliderChange"
@input="onSliderInput"
/>
</div>
</template>
<script setup lang="ts">
import { ElSlider } from 'element-plus';
import { computed } from 'vue';
import { useStore } from 'vuex';
const store = useStore();
const progress = computed({
get: () => store.state.suno?.audio?.progress,
set: (value) =>
store.commit('suno/setAudio', {
...store.state.suno.audio,
progress: value
})
});
const duration = computed({
get: () => store.state.suno?.audio?.duration,
set: (value) => store.commit('suno/setAudio', { ...store.state.suno.audio, duration: value })
});
const onSliderInput = () => {};
const onSliderChange = (val: number) =>
store.dispatch('suno/setAudio', {
...store.state.suno.audio,
progress: val
});
</script>
<style lang="scss">
.player-slider {
.el-slider {
height: 10px;
.el-slider__runway,
.el-slider__bar {
height: 2px;
border-radius: 0;
}
.el-slider__runway {
//@apply bg-slate-50 dark:bg-stone-700 transition-all;
//@apply hover:bg-stone-200 dark:hover:bg-stone-600;
}
.el-slider__button-wrapper {
@apply opacity-0 transition-opacity;
width: 10px;
height: 10px;
top: -10.5px;
}
&:hover {
.el-slider__button-wrapper {
@apply opacity-100;
}
}
.el-slider__button {
width: 8px;
height: 8px;
@apply bg-emerald-400;
}
}
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<template>
<div class="flex player-song">
<img alt="" class="w-11 h-11 rounded" :src="audio?.image_url || OpticalDisk" />
<div class="ml-2 text-xs flex flex-col justify-between">
<div class="w-52 2xl:w-96 cursor-pointer truncate">
<div class="flex">
<span>{{ audio?.title || 'Music' }}</span>
<span class="ml-2 text-dc">- {{ audio?.style || `SmallRuralDog` }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { OpticalDisk } from '@/assets/img';
import { computed, watch } from 'vue';
import { useStore } from 'vuex';
const store = useStore();
const audio = computed({
get: () => store.state.suno.audio,
set: (value) => store.commit('suno/setAudio', value)
});
// watch audio change and play
watch(audio, (value, oldValue) => {
// url changed
if (value?.audio_url !== oldValue?.audio_url) {
console.log('audio changed', value);
if (value.object) {
console.log('111', value.object);
// delete old object
value.object.pause();
delete value.object;
}
const object = new Audio(value.audio_url);
object.currentTime = 0;
if (value.state === 'playing') {
object.play();
} else {
object.pause();
}
// listen to the time change of audio
object.addEventListener('timeupdate', () => {
store.commit('suno/setAudio', {
...store.state.suno.audio,
progress: object.currentTime
});
});
store.commit('suno/setAudio', {
...store.state.suno.audio,
object: object
});
} else if (value?.progress !== oldValue?.progress && Math.abs(value.progress - value.object.currentTime) > 2) {
console.log('progress changed', value.progress);
const audio = store.state.suno.audio;
if (audio.object) {
audio.object.currentTime = audio.progress;
}
} else if (value?.state !== oldValue?.state) {
console.log('state changed', value.state);
if (value.object) {
if (value.state === 'playing') {
value.object.play();
} else {
value.object.pause();
}
}
}
if (value?.volume !== oldValue?.volume) {
console.log('volume changed', value.volume);
if (value.object) {
value.object.volume = value.volume / 100;
}
}
});
</script>
@@ -0,0 +1,55 @@
<template>
<div class="player-volume flex flex-col items-center pt-2">
<div>
<el-slider
v-model="volume"
vertical
height="100px"
:show-tooltip="false"
:max="100"
:min="0"
size="small"
:disabled="muted"
@input="setVolume"
/>
</div>
<div class="text-sm mt-3">{{ volume }}</div>
<div class="mt-2">
<icon-park
:icon="muted ? VolumeMute : VolumeSmall"
size="16"
theme="filled"
class="hover-text"
@click="toggleMuted"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { VolumeMute, VolumeSmall } from '@icon-park/vue-next';
import IconPark from '@/components/common/IconPark.vue';
import { ElSlider } from 'element-plus';
import { computed } from 'vue';
import { useStore } from 'vuex';
const store = useStore();
const volume = computed({
get: () => store.state.suno.audio?.volume,
set: (value) => store.commit('suno/setAudio', { ...store.state.suno.audio, volume: value })
});
const muted = computed({
get: () => store.state.suno.audio?.muted,
set: (value) => store.commit('suno/setAudio', { ...store.state.suno.audio, muted: value })
});
const toggleMuted = () => store.dispatch('suno/toggleMuted');
const setVolume = (value: number) => store.dispatch('suno/setVolume', value);
</script>
<style lang="scss">
.el-popover.el-popper {
min-width: auto;
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<template>
<div class="task">
<div v-for="(audio, audioIndex) in audios" :key="audioIndex" class="audio" @click="onClick(audio)">
<div v-loading="!audio?.audio_url" class="left">
<el-image :src="audio?.image_url" class="cover" fit="cover" />
<div
v-if="
audio?.audio_url &&
$store.state?.suno?.audio?.id === audio.id &&
$store.state?.suno?.audio?.state === 'playing'
"
class="overlay"
@click="onPause(audio)"
>
<el-icon><video-pause /></el-icon>
</div>
<div
v-if="
audio?.audio_url &&
($store.state?.suno?.audio?.id !== audio.id ||
($store.state?.suno?.audio?.id === audio.id && $store.state?.suno?.audio?.state === 'paused'))
"
class="overlay"
@click="onPlay(audio)"
>
<el-icon><video-play /></el-icon>
</div>
<div v-if="audio?.duration" class="duration">
{{ useFormatDuring(audio?.duration) }}
</div>
</div>
<div class="info">
<h2 class="title">{{ audio?.title }}</h2>
<p class="style">{{ audio?.style }}</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useFormatDuring } from '@/utils/number';
import { ISunoAudio, ISunoTask } from '@/models';
import { ElButton, ElImage, ElIcon } from 'element-plus';
import { VideoPlay, VideoPause } from '@element-plus/icons-vue';
export default defineComponent({
name: 'TaskPreview',
components: {
ElImage,
ElIcon,
VideoPlay,
VideoPause
},
props: {
modelValue: {
type: Object as () => ISunoTask,
required: true
}
},
data() {
return {};
},
computed: {
task() {
return this.$store.state.suno?.tasks;
},
audios(): ISunoAudio[] {
let result: ISunoAudio[] = [];
if (Array.isArray(this.modelValue?.response?.data)) {
this.modelValue?.response?.data?.forEach((item: any) => {
let audio = item as ISunoAudio;
result.push(audio);
});
}
console.log('audios', result);
return result;
},
application() {
return this.$store.state.suno?.application;
},
active() {
return this.$store.state.suno?.tasks?.active;
}
},
methods: {
useFormatDuring,
onPlay(audio: ISunoAudio) {
this.$store.dispatch('suno/setAudio', {
...this.$store.state.suno.audio,
...audio,
state: 'playing'
});
console.log('on play');
},
onPause(audio: ISunoAudio) {
this.$store.dispatch('suno/setAudio', {
...this.$store.state.suno.audio,
...audio,
state: 'paused'
});
console.log('on pause');
},
onClick(audio: ISunoAudio) {
if (this.$store.state?.suno?.audio?.id !== audio.id) {
this.onPlay({
...audio,
progress: 0
});
}
}
}
});
</script>
<style lang="scss">
.task {
display: flex;
flex-direction: column;
.audio {
display: flex;
margin-bottom: 10px;
&:hover {
background-color: var(--el-bg-color-page);
}
.left {
position: relative;
width: 60px;
height: 60px;
margin-right: 16px;
flex-shrink: 0;
&:hover .overlay {
display: block;
}
.cover {
width: 100%;
height: 100%;
border-radius: 4px;
}
.duration {
position: absolute;
right: 0px;
bottom: 0px;
background-color: rgba(0, 0, 0, 0.7);
padding: 2px 4px;
color: white;
border-radius: 2px;
font-size: 10px;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
display: none;
transition: opacity 0.3s;
border-radius: 4px;
text-align: center;
line-height: 70px;
cursor: pointer;
.el-icon {
font-size: 20px;
color: white;
}
}
}
.info {
flex: 1;
.title {
font-size: 14px;
font-weight: bold;
margin-top: 5px;
}
.style {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
}
</style>
+1
View File
@@ -5,4 +5,5 @@ export * from './chat';
export * from './chatdoc';
export * from './midjourney';
export * from './qrart';
export * from './suno';
export * from './mapping';
+17
View File
@@ -0,0 +1,17 @@
export const SUNO_SERVICE_ID = 'f2b646d8-3cfd-46ef-969a-1ea9eebde329';
export const SUNO_DEFAULT_QRW = 2;
export const SUNO_DEFAULT_STEPS = 20;
export const SUNO_DEFAULT_PRESET = '';
export const SUNO_DEFAULT_MODEL = 'chirp-v3-5';
export const SUNO_DEFAULT_ASPECT_RATIO = '1:1';
export const SUNO_DEFAULT_ADVANCED = false;
export const SUNO_DEFAULT_RAWURL = true;
export const SUNO_DEFAULT_PADDING_LEVEL = 5;
export const SUNO_DEFAULT_POSITION = 'center';
export const SUNO_DEFAULT_PIXEL_STYLE = 'square';
export const SUNO_DEFAULT_MARKER_SHAPE = 'square';
export const SUNO_DEFAULT_SUB_MARKER = 'square';
export const SUNO_DEFAULT_ROTATE = 0;
export const SUNO_DEFAULT_ECL = 'H';
export const SUNO_DEFAULT_PADDING_NOISE = 0;

Some files were not shown because too many files have changed in this diff Show More