mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-18 06:54:15 +00:00
Improvee music studio
This commit is contained in:
parent
22c6279798
commit
8b9afae7eb
@ -1,101 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import * as antd from "antd"
|
|
||||||
|
|
||||||
import LyricsTextView from "@components/MusicStudio/LyricsTextView"
|
|
||||||
import UploadButton from "@components/UploadButton"
|
|
||||||
import { Icons } from "@components/Icons"
|
|
||||||
|
|
||||||
import Languages from "@config/languages"
|
|
||||||
|
|
||||||
import "./index.less"
|
|
||||||
|
|
||||||
const LanguagesMap = Object.entries(Languages).map(([key, value]) => {
|
|
||||||
return {
|
|
||||||
label: value,
|
|
||||||
value: key,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const LyricsEditor = (props) => {
|
|
||||||
const { langs = {} } = props
|
|
||||||
const [selectedLang, setSelectedLang] = React.useState("original")
|
|
||||||
|
|
||||||
function handleChange(key, value) {
|
|
||||||
if (typeof props.onChange !== "function") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
props.onChange(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCurrentLang(url) {
|
|
||||||
handleChange("langs", {
|
|
||||||
...langs,
|
|
||||||
[selectedLang]: url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lyrics-editor">
|
|
||||||
<div className="flex-row align-center justify-space-between gap-10">
|
|
||||||
<h1>
|
|
||||||
<Icons.MdOutlineMusicNote />
|
|
||||||
Lyrics
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="flex-row aling-center gap-5">
|
|
||||||
<span>Language:</span>
|
|
||||||
|
|
||||||
<antd.Select
|
|
||||||
showSearch
|
|
||||||
style={{ width: "220px" }}
|
|
||||||
placeholder="Select a language"
|
|
||||||
value={selectedLang}
|
|
||||||
options={[
|
|
||||||
...LanguagesMap,
|
|
||||||
{
|
|
||||||
label: "Original",
|
|
||||||
value: "original",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
optionFilterProp="children"
|
|
||||||
filterOption={(input, option) =>
|
|
||||||
(option?.label.toLowerCase() ?? "").includes(
|
|
||||||
input.toLowerCase(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
filterSort={(optionA, optionB) =>
|
|
||||||
(optionA?.label.toLowerCase() ?? "")
|
|
||||||
.toLowerCase()
|
|
||||||
.localeCompare(
|
|
||||||
(
|
|
||||||
optionB?.label.toLowerCase() ?? ""
|
|
||||||
).toLowerCase(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onChange={setSelectedLang}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedLang && (
|
|
||||||
<UploadButton
|
|
||||||
onSuccess={(file_uid, data) => {
|
|
||||||
updateCurrentLang(data.url)
|
|
||||||
}}
|
|
||||||
accept={["text/*"]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!langs[selectedLang] && (
|
|
||||||
<span>No lyrics uploaded for this language</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{langs[selectedLang] && (
|
|
||||||
<LyricsTextView lrcURL={langs[selectedLang]} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LyricsEditor
|
|
@ -1,11 +0,0 @@
|
|||||||
.lyrics-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 20px;
|
|
||||||
padding: 15px;
|
|
||||||
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
background-color: var(--background-color-accent);
|
|
||||||
}
|
|
@ -1,107 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import * as antd from "antd"
|
|
||||||
import dayjs from "dayjs"
|
|
||||||
import customParseFormat from "dayjs/plugin/customParseFormat"
|
|
||||||
|
|
||||||
import UploadButton from "@components/UploadButton"
|
|
||||||
import { Icons } from "@components/Icons"
|
|
||||||
import VideoPlayer from "@components/VideoPlayer"
|
|
||||||
|
|
||||||
import "./index.less"
|
|
||||||
|
|
||||||
dayjs.extend(customParseFormat)
|
|
||||||
|
|
||||||
const VideoEditor = (props) => {
|
|
||||||
function handleChange(key, value) {
|
|
||||||
if (typeof props.onChange !== "function") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
props.onChange(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="video-editor">
|
|
||||||
<h1>
|
|
||||||
<Icons.MdVideocam />
|
|
||||||
Video
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{!props.videoSourceURL && (
|
|
||||||
<antd.Empty
|
|
||||||
image={<Icons.MdVideocam />}
|
|
||||||
description="No video"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{props.videoSourceURL && (
|
|
||||||
<div className="video-editor-preview">
|
|
||||||
<VideoPlayer
|
|
||||||
controls={[
|
|
||||||
"play",
|
|
||||||
"current-time",
|
|
||||||
"seek-time",
|
|
||||||
"duration",
|
|
||||||
"progress",
|
|
||||||
"settings",
|
|
||||||
]}
|
|
||||||
src={props.videoSourceURL}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-column align-start gap10">
|
|
||||||
<div className="flex-row align-center gap10">
|
|
||||||
<span>
|
|
||||||
<Icons.MdAccessTime />
|
|
||||||
Start video sync at
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<code>{props.startSyncAt ?? "not set"}</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-row align-center gap10">
|
|
||||||
<span>Set to:</span>
|
|
||||||
|
|
||||||
<antd.TimePicker
|
|
||||||
showNow={false}
|
|
||||||
defaultValue={
|
|
||||||
props.startSyncAt &&
|
|
||||||
dayjs(props.startSyncAt, "mm:ss:SSS")
|
|
||||||
}
|
|
||||||
format={"mm:ss:SSS"}
|
|
||||||
onChange={(time, str) => {
|
|
||||||
handleChange("startSyncAt", str)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="video-editor-actions">
|
|
||||||
<UploadButton
|
|
||||||
onSuccess={(id, response) => {
|
|
||||||
handleChange("videoSourceURL", response.url)
|
|
||||||
}}
|
|
||||||
accept={["video/*"]}
|
|
||||||
headers={{
|
|
||||||
transformations: "mq-hls",
|
|
||||||
}}
|
|
||||||
disabled={props.loading}
|
|
||||||
>
|
|
||||||
Upload video
|
|
||||||
</UploadButton>
|
|
||||||
or
|
|
||||||
<antd.Input
|
|
||||||
placeholder="Set a video HLS URL"
|
|
||||||
onChange={(e) => {
|
|
||||||
handleChange("videoSourceURL", e.target.value)
|
|
||||||
}}
|
|
||||||
value={props.videoSourceURL}
|
|
||||||
disabled={props.loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VideoEditor
|
|
@ -1,25 +0,0 @@
|
|||||||
.video-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 20px;
|
|
||||||
padding: 15px;
|
|
||||||
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
background-color: var(--background-color-accent);
|
|
||||||
|
|
||||||
.video-editor-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-editor-preview {
|
|
||||||
width: 100%;
|
|
||||||
height: 350px;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,122 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import { Skeleton } from "antd"
|
|
||||||
|
|
||||||
import VideoEditor from "./components/VideoEditor"
|
|
||||||
import LyricsEditor from "./components/LyricsEditor"
|
|
||||||
|
|
||||||
import MusicModel from "@models/music"
|
|
||||||
|
|
||||||
import ReleaseEditorStateContext from "@contexts/MusicReleaseEditor"
|
|
||||||
|
|
||||||
import "./index.less"
|
|
||||||
|
|
||||||
class EnhancedLyricsEditor extends React.Component {
|
|
||||||
static contextType = ReleaseEditorStateContext
|
|
||||||
|
|
||||||
state = {
|
|
||||||
data: {},
|
|
||||||
loading: true,
|
|
||||||
submitting: false,
|
|
||||||
videoOptions: {},
|
|
||||||
lyricsOptions: {}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount = async () => {
|
|
||||||
this.setState({
|
|
||||||
loading: true
|
|
||||||
})
|
|
||||||
|
|
||||||
this.context.setCustomPageActions([
|
|
||||||
{
|
|
||||||
label: "Save",
|
|
||||||
icon: "FiSave",
|
|
||||||
onClick: this.submitChanges,
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const data = await MusicModel.getTrackLyrics(this.props.track._id).catch((err) => {
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
this.setState({
|
|
||||||
videoOptions: {
|
|
||||||
videoSourceURL: data.video_source,
|
|
||||||
startSyncAt: data.sync_audio_at
|
|
||||||
},
|
|
||||||
lyricsOptions: {
|
|
||||||
langs: data.lrc
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
loading: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
submitChanges = async () => {
|
|
||||||
this.setState({
|
|
||||||
submitting: true
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`Submitting changes with values >`, {
|
|
||||||
...this.state.videoOptions,
|
|
||||||
...this.state.lyricsOptions
|
|
||||||
})
|
|
||||||
|
|
||||||
await MusicModel.putTrackLyrics(this.props.track._id, {
|
|
||||||
video_source: this.state.videoOptions.videoSourceURL,
|
|
||||||
sync_audio_at: this.state.videoOptions.startSyncAt,
|
|
||||||
lrc: this.state.lyricsOptions.langs
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error(err)
|
|
||||||
app.message.error("Failed to update enhanced lyrics")
|
|
||||||
})
|
|
||||||
|
|
||||||
app.message.success("Lyrics updated")
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
submitting: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.loading) {
|
|
||||||
return <Skeleton active />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="enhanced_lyrics_editor-wrapper">
|
|
||||||
<h1>{this.props.track.title}</h1>
|
|
||||||
|
|
||||||
<VideoEditor
|
|
||||||
loading={this.state.submitting}
|
|
||||||
videoSourceURL={this.state.videoOptions.videoSourceURL}
|
|
||||||
startSyncAt={this.state.videoOptions.startSyncAt}
|
|
||||||
onChange={(key, value) => {
|
|
||||||
this.setState({
|
|
||||||
videoOptions: {
|
|
||||||
...this.state.videoOptions,
|
|
||||||
[key]: value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LyricsEditor
|
|
||||||
loading={this.state.submitting}
|
|
||||||
langs={this.state.lyricsOptions.langs}
|
|
||||||
onChange={(key, value) => {
|
|
||||||
this.setState({
|
|
||||||
lyricsOptions: {
|
|
||||||
...this.state.lyricsOptions,
|
|
||||||
[key]: value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EnhancedLyricsEditor
|
|
@ -1,6 +0,0 @@
|
|||||||
.enhanced_lyrics_editor-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
@ -1,75 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import * as antd from "antd"
|
|
||||||
import axios from "axios"
|
|
||||||
|
|
||||||
import "./index.less"
|
|
||||||
|
|
||||||
const LyricsTextView = (props) => {
|
|
||||||
const { lrcURL } = props
|
|
||||||
|
|
||||||
const [loading, setLoading] = React.useState(false)
|
|
||||||
const [error, setError] = React.useState(null)
|
|
||||||
const [lyrics, setLyrics] = React.useState(null)
|
|
||||||
|
|
||||||
async function getLyrics(resource_url) {
|
|
||||||
setError(null)
|
|
||||||
setLoading(true)
|
|
||||||
setLyrics(null)
|
|
||||||
|
|
||||||
const data = await axios({
|
|
||||||
method: "get",
|
|
||||||
url: resource_url,
|
|
||||||
responseType: "text"
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error(err)
|
|
||||||
setError(err)
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
setLyrics(data.data.split("\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
getLyrics(lrcURL)
|
|
||||||
}, [lrcURL])
|
|
||||||
|
|
||||||
if (!lrcURL) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <antd.Result
|
|
||||||
status="warning"
|
|
||||||
title="Failed"
|
|
||||||
subTitle={error.message}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <antd.Skeleton active />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lyrics) {
|
|
||||||
return <p>No lyrics provided</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="lyrics-text-view">
|
|
||||||
{
|
|
||||||
lyrics?.map((line, index) => {
|
|
||||||
return <div
|
|
||||||
key={index}
|
|
||||||
className="lyrics-text-view-line"
|
|
||||||
>
|
|
||||||
{line}
|
|
||||||
</div>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LyricsTextView
|
|
@ -1,15 +0,0 @@
|
|||||||
.lyrics-text-view {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
.lyrics-text-view-line {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,55 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import * as antd from "antd"
|
|
||||||
|
|
||||||
import ReleaseItem from "@components/MusicStudio/ReleaseItem"
|
|
||||||
|
|
||||||
import MusicModel from "@models/music"
|
|
||||||
|
|
||||||
import "./index.less"
|
|
||||||
|
|
||||||
const MyReleasesList = () => {
|
|
||||||
const [L_MyReleases, R_MyReleases, E_MyReleases, M_MyReleases] = app.cores.api.useRequest(MusicModel.getMyReleases, {
|
|
||||||
offset: 0,
|
|
||||||
limit: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
async function onClickReleaseItem(release) {
|
|
||||||
app.location.push(`/studio/music/${release._id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="music-studio-page-content">
|
|
||||||
<div className="music-studio-page-header">
|
|
||||||
<h1>Your Releases</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
|
||||||
L_MyReleases && !E_MyReleases && <antd.Skeleton active />
|
|
||||||
}
|
|
||||||
{
|
|
||||||
E_MyReleases && <antd.Result
|
|
||||||
status="warning"
|
|
||||||
title="Failed to retrieve releases"
|
|
||||||
subTitle={E_MyReleases.message}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
!L_MyReleases && !E_MyReleases && R_MyReleases && R_MyReleases.items.length === 0 && <antd.Empty />
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
!L_MyReleases && !E_MyReleases && R_MyReleases && R_MyReleases.items.length > 0 && <div className="music-studio-page-releases-list">
|
|
||||||
{
|
|
||||||
R_MyReleases.items.map((item) => {
|
|
||||||
return <ReleaseItem
|
|
||||||
key={item._id}
|
|
||||||
release={item}
|
|
||||||
onClick={onClickReleaseItem}
|
|
||||||
/>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MyReleasesList
|
|
@ -1,332 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import * as antd from "antd"
|
|
||||||
import { Icons, createIconRender } from "@components/Icons"
|
|
||||||
|
|
||||||
import MusicModel from "@models/music"
|
|
||||||
import compareObjectsByProperties from "@utils/compareObjectsByProperties"
|
|
||||||
import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey"
|
|
||||||
|
|
||||||
import TrackManifest from "@cores/player/classes/TrackManifest"
|
|
||||||
|
|
||||||
import {
|
|
||||||
DefaultReleaseEditorState,
|
|
||||||
ReleaseEditorStateContext,
|
|
||||||
} from "@contexts/MusicReleaseEditor"
|
|
||||||
|
|
||||||
import Tabs from "./tabs"
|
|
||||||
|
|
||||||
import "./index.less"
|
|
||||||
|
|
||||||
const ReleaseEditor = (props) => {
|
|
||||||
const { release_id } = props
|
|
||||||
|
|
||||||
const basicInfoRef = React.useRef()
|
|
||||||
|
|
||||||
const [submitting, setSubmitting] = React.useState(false)
|
|
||||||
const [loading, setLoading] = React.useState(true)
|
|
||||||
const [submitError, setSubmitError] = React.useState(null)
|
|
||||||
|
|
||||||
const [loadError, setLoadError] = React.useState(null)
|
|
||||||
const [globalState, setGlobalState] = React.useState(
|
|
||||||
DefaultReleaseEditorState,
|
|
||||||
)
|
|
||||||
const [initialValues, setInitialValues] = React.useState({})
|
|
||||||
|
|
||||||
const [customPage, setCustomPage] = React.useState(null)
|
|
||||||
const [customPageActions, setCustomPageActions] = React.useState([])
|
|
||||||
|
|
||||||
const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
|
|
||||||
defaultKey: "info",
|
|
||||||
queryKey: "tab",
|
|
||||||
})
|
|
||||||
|
|
||||||
async function initialize() {
|
|
||||||
setLoading(true)
|
|
||||||
setLoadError(null)
|
|
||||||
|
|
||||||
if (release_id !== "new") {
|
|
||||||
try {
|
|
||||||
let releaseData = await MusicModel.getReleaseData(release_id)
|
|
||||||
|
|
||||||
if (Array.isArray(releaseData.items)) {
|
|
||||||
releaseData.items = releaseData.items.map((item) => {
|
|
||||||
return new TrackManifest(item)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setGlobalState({
|
|
||||||
...globalState,
|
|
||||||
...releaseData,
|
|
||||||
})
|
|
||||||
|
|
||||||
setInitialValues(releaseData)
|
|
||||||
} catch (error) {
|
|
||||||
setLoadError(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasChanges() {
|
|
||||||
const stagedChanges = {
|
|
||||||
title: globalState.title,
|
|
||||||
type: globalState.type,
|
|
||||||
public: globalState.public,
|
|
||||||
cover: globalState.cover,
|
|
||||||
items: globalState.items,
|
|
||||||
}
|
|
||||||
|
|
||||||
return !compareObjectsByProperties(
|
|
||||||
stagedChanges,
|
|
||||||
initialValues,
|
|
||||||
Object.keys(stagedChanges),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderCustomPage(page, actions) {
|
|
||||||
setCustomPage(page ?? null)
|
|
||||||
setCustomPageActions(actions ?? [])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
setSubmitting(true)
|
|
||||||
setSubmitError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("Submitting Tracks")
|
|
||||||
|
|
||||||
// first sumbit tracks
|
|
||||||
const tracks = await MusicModel.putTrack({
|
|
||||||
items: globalState.items,
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log("Submitting release")
|
|
||||||
|
|
||||||
// then submit release
|
|
||||||
const result = await MusicModel.putRelease({
|
|
||||||
_id: globalState._id,
|
|
||||||
title: globalState.title,
|
|
||||||
description: globalState.description,
|
|
||||||
public: globalState.public,
|
|
||||||
cover: globalState.cover,
|
|
||||||
explicit: globalState.explicit,
|
|
||||||
type: globalState.type,
|
|
||||||
items: tracks.items.map((item) => item._id),
|
|
||||||
})
|
|
||||||
|
|
||||||
app.location.push(`/studio/music/${result._id}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
app.message.error(error.message)
|
|
||||||
|
|
||||||
setSubmitError(error)
|
|
||||||
setSubmitting(false)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(false)
|
|
||||||
app.message.success("Release saved")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
app.layout.modal.confirm({
|
|
||||||
headerText: "Are you sure you want to delete this release?",
|
|
||||||
descriptionText: "This action cannot be undone.",
|
|
||||||
onConfirm: async () => {
|
|
||||||
await MusicModel.deleteRelease(globalState._id)
|
|
||||||
app.location.push(
|
|
||||||
window.location.pathname.split("/").slice(0, -1).join("/"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function canFinish() {
|
|
||||||
return hasChanges()
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
initialize()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (loadError) {
|
|
||||||
return (
|
|
||||||
<antd.Result
|
|
||||||
status="warning"
|
|
||||||
title="Error"
|
|
||||||
subTitle={loadError.message}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <antd.Skeleton active />
|
|
||||||
}
|
|
||||||
|
|
||||||
const Tab = Tabs.find(({ key }) => key === selectedTab)
|
|
||||||
|
|
||||||
const CustomPageProps = {
|
|
||||||
close: () => {
|
|
||||||
renderCustomPage(null, null)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReleaseEditorStateContext.Provider
|
|
||||||
value={{
|
|
||||||
...globalState,
|
|
||||||
setGlobalState,
|
|
||||||
renderCustomPage,
|
|
||||||
setCustomPageActions,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="music-studio-release-editor">
|
|
||||||
{customPage && (
|
|
||||||
<div className="music-studio-release-editor-custom-page">
|
|
||||||
{customPage.header && (
|
|
||||||
<div className="music-studio-release-editor-custom-page-header">
|
|
||||||
<div className="music-studio-release-editor-custom-page-header-title">
|
|
||||||
<antd.Button
|
|
||||||
icon={<Icons.IoIosArrowBack />}
|
|
||||||
onClick={() =>
|
|
||||||
renderCustomPage(null, null)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<h2>{customPage.header}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Array.isArray(customPageActions) &&
|
|
||||||
customPageActions.map((action, index) => {
|
|
||||||
return (
|
|
||||||
<antd.Button
|
|
||||||
key={index}
|
|
||||||
type={action.type}
|
|
||||||
icon={createIconRender(
|
|
||||||
action.icon,
|
|
||||||
)}
|
|
||||||
onClick={async () => {
|
|
||||||
if (
|
|
||||||
typeof action.onClick ===
|
|
||||||
"function"
|
|
||||||
) {
|
|
||||||
await action.onClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.fireEvent) {
|
|
||||||
app.eventBus.emit(
|
|
||||||
action.fireEvent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={action.disabled}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</antd.Button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{customPage.content &&
|
|
||||||
(React.isValidElement(customPage.content)
|
|
||||||
? React.cloneElement(customPage.content, {
|
|
||||||
...CustomPageProps,
|
|
||||||
...customPage.props,
|
|
||||||
})
|
|
||||||
: React.createElement(customPage.content, {
|
|
||||||
...CustomPageProps,
|
|
||||||
...customPage.props,
|
|
||||||
}))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!customPage && (
|
|
||||||
<>
|
|
||||||
<div className="music-studio-release-editor-menu">
|
|
||||||
<antd.Menu
|
|
||||||
onClick={(e) => setSelectedTab(e.key)}
|
|
||||||
selectedKeys={[selectedTab]}
|
|
||||||
items={Tabs}
|
|
||||||
mode="vertical"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="music-studio-release-editor-menu-actions">
|
|
||||||
<antd.Button
|
|
||||||
type="primary"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
icon={
|
|
||||||
release_id !== "new" ? (
|
|
||||||
<Icons.FiSave />
|
|
||||||
) : (
|
|
||||||
<Icons.MdSend />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
submitting || loading || !canFinish()
|
|
||||||
}
|
|
||||||
loading={submitting}
|
|
||||||
>
|
|
||||||
{release_id !== "new" ? "Save" : "Release"}
|
|
||||||
</antd.Button>
|
|
||||||
|
|
||||||
{release_id !== "new" ? (
|
|
||||||
<antd.Button
|
|
||||||
icon={<Icons.IoMdTrash />}
|
|
||||||
disabled={loading}
|
|
||||||
onClick={handleDelete}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</antd.Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{release_id !== "new" ? (
|
|
||||||
<antd.Button
|
|
||||||
icon={<Icons.MdLink />}
|
|
||||||
onClick={() =>
|
|
||||||
app.location.push(
|
|
||||||
`/music/list/${globalState._id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Go to release
|
|
||||||
</antd.Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="music-studio-release-editor-content">
|
|
||||||
{submitError && (
|
|
||||||
<antd.Alert
|
|
||||||
message={submitError.message}
|
|
||||||
type="error"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!Tab && (
|
|
||||||
<antd.Result
|
|
||||||
status="error"
|
|
||||||
title="Error"
|
|
||||||
subTitle="Tab not found"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{Tab &&
|
|
||||||
React.createElement(Tab.render, {
|
|
||||||
release: globalState,
|
|
||||||
|
|
||||||
state: globalState,
|
|
||||||
setState: setGlobalState,
|
|
||||||
|
|
||||||
references: {
|
|
||||||
basic: basicInfoRef,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ReleaseEditorStateContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReleaseEditor
|
|
@ -1,136 +0,0 @@
|
|||||||
.music-studio-release-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
//padding: 20px;
|
|
||||||
|
|
||||||
gap: 20px;
|
|
||||||
|
|
||||||
.music-studio-release-editor-custom-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
gap: 20px;
|
|
||||||
|
|
||||||
.music-studio-release-editor-custom-page-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
background-color: var(--background-color-accent);
|
|
||||||
|
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6,
|
|
||||||
p,
|
|
||||||
span {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-studio-release-editor-custom-page-header-title {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-studio-release-editor-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 20px;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 1.7rem;
|
|
||||||
font-family: "Space Grotesk", sans-serif;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-studio-release-editor-menu {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 20px;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.ant-btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-menu {
|
|
||||||
background-color: var(--background-color-accent) !important;
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
padding: 8px;
|
|
||||||
|
|
||||||
gap: 5px;
|
|
||||||
|
|
||||||
.ant-menu-item {
|
|
||||||
padding: 5px 10px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-menu-item-selected {
|
|
||||||
background-color: var(--background-color-primary-2) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-studio-release-editor-menu-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-studio-release-editor-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.music-studio-release-editor-tab {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-form-item {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
height: fit-content;
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,115 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import * as antd from "antd"
|
|
||||||
|
|
||||||
import { Icons } from "@components/Icons"
|
|
||||||
|
|
||||||
import CoverEditor from "@components/CoverEditor"
|
|
||||||
|
|
||||||
const ReleasesTypes = [
|
|
||||||
{
|
|
||||||
value: "single",
|
|
||||||
label: "Single",
|
|
||||||
icon: <Icons.MdMusicNote />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "ep",
|
|
||||||
label: "Episode",
|
|
||||||
icon: <Icons.MdAlbum />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "album",
|
|
||||||
label: "Album",
|
|
||||||
icon: <Icons.MdAlbum />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "compilation",
|
|
||||||
label: "Compilation",
|
|
||||||
icon: <Icons.MdAlbum />,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const BasicInformation = (props) => {
|
|
||||||
const { release, onFinish, setState, state } = props
|
|
||||||
|
|
||||||
async function onFormChange(change) {
|
|
||||||
setState((globalState) => {
|
|
||||||
return {
|
|
||||||
...globalState,
|
|
||||||
...change
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="music-studio-release-editor-tab">
|
|
||||||
<h1>Release Information</h1>
|
|
||||||
|
|
||||||
<antd.Form
|
|
||||||
name="basic"
|
|
||||||
layout="vertical"
|
|
||||||
ref={props.references.basic}
|
|
||||||
onFinish={onFinish}
|
|
||||||
requiredMark={false}
|
|
||||||
onValuesChange={onFormChange}
|
|
||||||
>
|
|
||||||
<antd.Form.Item
|
|
||||||
label=""
|
|
||||||
name="cover"
|
|
||||||
rules={[{ required: true, message: "Input a cover for the release" }]}
|
|
||||||
initialValue={state?.cover}
|
|
||||||
>
|
|
||||||
<CoverEditor
|
|
||||||
defaultUrl="https://storage.ragestudio.net/comty-static-assets/default_song.png"
|
|
||||||
/>
|
|
||||||
</antd.Form.Item>
|
|
||||||
|
|
||||||
{
|
|
||||||
release._id && <antd.Form.Item
|
|
||||||
label={<><Icons.MdTag /> <span>ID</span></>}
|
|
||||||
name="_id"
|
|
||||||
initialValue={release._id}
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<antd.Input
|
|
||||||
placeholder="Release ID"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</antd.Form.Item>
|
|
||||||
}
|
|
||||||
|
|
||||||
<antd.Form.Item
|
|
||||||
label={<><Icons.MdMusicNote /> <span>Title</span></>}
|
|
||||||
name="title"
|
|
||||||
rules={[{ required: true, message: "Input a title for the release" }]}
|
|
||||||
initialValue={state?.title}
|
|
||||||
>
|
|
||||||
<antd.Input
|
|
||||||
placeholder="Release title"
|
|
||||||
maxLength={128}
|
|
||||||
showCount
|
|
||||||
/>
|
|
||||||
</antd.Form.Item>
|
|
||||||
|
|
||||||
<antd.Form.Item
|
|
||||||
label={<><Icons.MdAlbum /> <span>Type</span></>}
|
|
||||||
name="type"
|
|
||||||
rules={[{ required: true, message: "Select a type for the release" }]}
|
|
||||||
initialValue={state?.type}
|
|
||||||
>
|
|
||||||
<antd.Select
|
|
||||||
placeholder="Release type"
|
|
||||||
options={ReleasesTypes}
|
|
||||||
/>
|
|
||||||
</antd.Form.Item>
|
|
||||||
|
|
||||||
<antd.Form.Item
|
|
||||||
label={<><Icons.MdPublic /> <span>Public</span></>}
|
|
||||||
name="public"
|
|
||||||
initialValue={state?.public}
|
|
||||||
>
|
|
||||||
<antd.Switch />
|
|
||||||
</antd.Form.Item>
|
|
||||||
</antd.Form>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BasicInformation
|
|
@ -1,117 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import * as antd from "antd"
|
|
||||||
import classnames from "classnames"
|
|
||||||
import { Draggable } from "react-beautiful-dnd"
|
|
||||||
|
|
||||||
import Image from "@components/Image"
|
|
||||||
import { Icons } from "@components/Icons"
|
|
||||||
import TrackEditor from "@components/MusicStudio/TrackEditor"
|
|
||||||
|
|
||||||
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
|
||||||
|
|
||||||
import "./index.less"
|
|
||||||
|
|
||||||
const stateToString = {
|
|
||||||
uploading: "Uploading",
|
|
||||||
transmuxing: "Processing...",
|
|
||||||
uploading_s3: "Archiving...",
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTitleString = ({ track, progress }) => {
|
|
||||||
if (progress) {
|
|
||||||
return stateToString[progress.state] || progress.state
|
|
||||||
}
|
|
||||||
|
|
||||||
return track.title
|
|
||||||
}
|
|
||||||
|
|
||||||
const TrackListItem = (props) => {
|
|
||||||
const context = React.useContext(ReleaseEditorStateContext)
|
|
||||||
|
|
||||||
const [loading, setLoading] = React.useState(false)
|
|
||||||
const [error, setError] = React.useState(null)
|
|
||||||
|
|
||||||
const { track, progress } = props
|
|
||||||
|
|
||||||
async function onClickEditTrack() {
|
|
||||||
context.renderCustomPage({
|
|
||||||
header: "Track Editor",
|
|
||||||
content: <TrackEditor />,
|
|
||||||
props: {
|
|
||||||
track: track,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onClickRemoveTrack() {
|
|
||||||
props.onDelete(track.uid)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classnames(
|
|
||||||
"music-studio-release-editor-tracks-list-item",
|
|
||||||
{
|
|
||||||
["loading"]: loading,
|
|
||||||
["failed"]: !!error,
|
|
||||||
["disabled"]: props.disabled,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
data-swapy-item={track.id ?? track._id}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="music-studio-release-editor-tracks-list-item-progress"
|
|
||||||
style={{
|
|
||||||
"--upload-progress": `${props.progress?.percent ?? 0}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="music-studio-release-editor-tracks-list-item-index">
|
|
||||||
<span>{props.index + 1}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{progress !== null && <Icons.LoadingOutlined />}
|
|
||||||
|
|
||||||
<Image
|
|
||||||
src={track.cover}
|
|
||||||
height={25}
|
|
||||||
width={25}
|
|
||||||
style={{
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span>{getTitleString({ track, progress })}</span>
|
|
||||||
|
|
||||||
<div className="music-studio-release-editor-tracks-list-item-actions">
|
|
||||||
<antd.Popconfirm
|
|
||||||
title="Are you sure you want to delete this track?"
|
|
||||||
onConfirm={onClickRemoveTrack}
|
|
||||||
okText="Yes"
|
|
||||||
disabled={props.disabled}
|
|
||||||
>
|
|
||||||
<antd.Button
|
|
||||||
type="ghost"
|
|
||||||
icon={<Icons.FiTrash2 />}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>
|
|
||||||
</antd.Popconfirm>
|
|
||||||
<antd.Button
|
|
||||||
type="ghost"
|
|
||||||
icon={<Icons.FiEdit2 />}
|
|
||||||
onClick={onClickEditTrack}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-swapy-handle
|
|
||||||
className="music-studio-release-editor-tracks-list-item-dragger"
|
|
||||||
>
|
|
||||||
<Icons.MdDragIndicator />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TrackListItem
|
|
@ -1,62 +0,0 @@
|
|||||||
.music-studio-release-editor-tracks-list-item {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
background-color: var(--background-color-accent);
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.music-studio-release-editor-tracks-list-item-progress {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
|
|
||||||
width: var(--upload-progress);
|
|
||||||
height: 2px;
|
|
||||||
|
|
||||||
background-color: var(--colorPrimary);
|
|
||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-studio-release-editor-tracks-list-item-actions {
|
|
||||||
position: absolute;
|
|
||||||
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
padding: 0 5px;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-studio-release-editor-tracks-list-item-dragger {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,352 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import * as antd from "antd"
|
|
||||||
import { createSwapy } from "swapy"
|
|
||||||
|
|
||||||
import queuedUploadFile from "@utils/queuedUploadFile"
|
|
||||||
import FilesModel from "@models/files"
|
|
||||||
|
|
||||||
import TrackManifest from "@cores/player/classes/TrackManifest"
|
|
||||||
|
|
||||||
import { Icons } from "@components/Icons"
|
|
||||||
|
|
||||||
import TrackListItem from "./components/TrackListItem"
|
|
||||||
import UploadHint from "./components/UploadHint"
|
|
||||||
|
|
||||||
import "./index.less"
|
|
||||||
|
|
||||||
class TracksManager extends React.Component {
|
|
||||||
swapyRef = React.createRef()
|
|
||||||
|
|
||||||
state = {
|
|
||||||
items: Array.isArray(this.props.items) ? this.props.items : [],
|
|
||||||
pendingUploads: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate = (prevProps, prevState) => {
|
|
||||||
if (prevState.items !== this.state.items) {
|
|
||||||
if (typeof this.props.onChangeState === "function") {
|
|
||||||
this.props.onChangeState(this.state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.swapyRef.current = createSwapy(
|
|
||||||
document.getElementById("editor-tracks-list"),
|
|
||||||
{
|
|
||||||
animation: "dynamic",
|
|
||||||
dragAxis: "y",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
this.swapyRef.current.onSwapEnd((event) => {
|
|
||||||
console.log("end", event)
|
|
||||||
this.orderTrackList(
|
|
||||||
event.slotItemMap.asArray.map((item) => item.item),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.swapyRef.current.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
findTrackByUid = (uid) => {
|
|
||||||
if (!uid) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.state.items.find((item) => item.uid === uid)
|
|
||||||
}
|
|
||||||
|
|
||||||
addTrackToList = (track) => {
|
|
||||||
if (!track) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
items: [...this.state.items, track],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTrackByUid = (uid) => {
|
|
||||||
if (!uid) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
this.removeTrackUIDFromPendingUploads(uid)
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
items: this.state.items.filter((item) => item.uid !== uid),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
modifyTrackByUid = (uid, track) => {
|
|
||||||
if (!uid || !track) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
items: this.state.items.map((item) => {
|
|
||||||
if (item.uid === uid) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
...track,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return item
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
addTrackUIDToPendingUploads = (uid) => {
|
|
||||||
if (!uid) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingUpload = this.state.pendingUploads.find(
|
|
||||||
(item) => item.uid === uid,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!pendingUpload) {
|
|
||||||
this.setState({
|
|
||||||
pendingUploads: [
|
|
||||||
...this.state.pendingUploads,
|
|
||||||
{
|
|
||||||
uid: uid,
|
|
||||||
progress: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTrackUIDFromPendingUploads = (uid) => {
|
|
||||||
if (!uid) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
pendingUploads: this.state.pendingUploads.filter(
|
|
||||||
(item) => item.uid !== uid,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getUploadProgress = (uid) => {
|
|
||||||
const uploadProgressIndex = this.state.pendingUploads.findIndex(
|
|
||||||
(item) => item.uid === uid,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (uploadProgressIndex === -1) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.state.pendingUploads[uploadProgressIndex].progress
|
|
||||||
}
|
|
||||||
|
|
||||||
updateUploadProgress = (uid, progress) => {
|
|
||||||
const uploadProgressIndex = this.state.pendingUploads.findIndex(
|
|
||||||
(item) => item.uid === uid,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (uploadProgressIndex === -1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const newData = [...this.state.pendingUploads]
|
|
||||||
|
|
||||||
newData[uploadProgressIndex].progress = progress
|
|
||||||
|
|
||||||
console.log(`Updating progress for [${uid}] to >`, progress)
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
pendingUploads: newData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
handleUploaderStateChange = async (change) => {
|
|
||||||
const uid = change.file.uid
|
|
||||||
|
|
||||||
console.log("handleUploaderStateChange", change)
|
|
||||||
|
|
||||||
switch (change.file.status) {
|
|
||||||
case "uploading": {
|
|
||||||
this.addTrackUIDToPendingUploads(uid)
|
|
||||||
|
|
||||||
const trackManifest = new TrackManifest({
|
|
||||||
uid: uid,
|
|
||||||
file: change.file.originFileObj,
|
|
||||||
})
|
|
||||||
|
|
||||||
this.addTrackToList(trackManifest)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "done": {
|
|
||||||
// remove pending file
|
|
||||||
this.removeTrackUIDFromPendingUploads(uid)
|
|
||||||
|
|
||||||
let trackManifest = this.state.items.find(
|
|
||||||
(item) => item.uid === uid,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!trackManifest) {
|
|
||||||
console.error(`Track with uid [${uid}] not found!`)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// // update track list
|
|
||||||
// await this.modifyTrackByUid(uid, {
|
|
||||||
// source: change.file.response.url
|
|
||||||
// })
|
|
||||||
|
|
||||||
trackManifest.source = change.file.response.url
|
|
||||||
trackManifest = await trackManifest.initialize()
|
|
||||||
|
|
||||||
// if has a cover, Upload
|
|
||||||
if (trackManifest._coverBlob) {
|
|
||||||
console.log(
|
|
||||||
`[${trackManifest.uid}] Founded cover, uploading...`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const coverFile = new File(
|
|
||||||
[trackManifest._coverBlob],
|
|
||||||
"cover.jpg",
|
|
||||||
{ type: trackManifest._coverBlob.type },
|
|
||||||
)
|
|
||||||
|
|
||||||
const coverUpload = await FilesModel.upload(coverFile)
|
|
||||||
|
|
||||||
trackManifest.cover = coverUpload.url
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.modifyTrackByUid(uid, trackManifest)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "error": {
|
|
||||||
// remove pending file
|
|
||||||
this.removeTrackUIDFromPendingUploads(uid)
|
|
||||||
|
|
||||||
// remove from tracklist
|
|
||||||
await this.removeTrackByUid(uid)
|
|
||||||
}
|
|
||||||
case "removed": {
|
|
||||||
// stop upload & delete from pending list and tracklist
|
|
||||||
await this.removeTrackByUid(uid)
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadToStorage = async (req) => {
|
|
||||||
await queuedUploadFile(req.file, {
|
|
||||||
onFinish: (file, response) => {
|
|
||||||
req.onSuccess(response)
|
|
||||||
},
|
|
||||||
onError: req.onError,
|
|
||||||
onProgress: this.handleTrackFileUploadProgress,
|
|
||||||
headers: {
|
|
||||||
transformations: "a-dash",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTrackFileUploadProgress = async (file, progress) => {
|
|
||||||
this.updateUploadProgress(file.uid, progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
orderTrackList = (orderedIdsArray) => {
|
|
||||||
this.setState((prev) => {
|
|
||||||
// move all list items by id
|
|
||||||
const orderedIds = orderedIdsArray.map((id) =>
|
|
||||||
this.state.items.find((item) => item._id === id),
|
|
||||||
)
|
|
||||||
console.log("orderedIds", orderedIds)
|
|
||||||
return {
|
|
||||||
items: orderedIds,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
console.log(`Tracks List >`, this.state.items)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="music-studio-release-editor-tracks">
|
|
||||||
<antd.Upload
|
|
||||||
className="music-studio-tracks-uploader"
|
|
||||||
onChange={this.handleUploaderStateChange}
|
|
||||||
customRequest={this.uploadToStorage}
|
|
||||||
showUploadList={false}
|
|
||||||
accept="audio/*"
|
|
||||||
multiple
|
|
||||||
>
|
|
||||||
{this.state.items.length === 0 ? (
|
|
||||||
<UploadHint />
|
|
||||||
) : (
|
|
||||||
<antd.Button
|
|
||||||
className="uploadMoreButton"
|
|
||||||
icon={<Icons.FiPlus />}
|
|
||||||
>
|
|
||||||
Add another
|
|
||||||
</antd.Button>
|
|
||||||
)}
|
|
||||||
</antd.Upload>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="editor-tracks-list"
|
|
||||||
className="music-studio-release-editor-tracks-list"
|
|
||||||
>
|
|
||||||
{this.state.items.length === 0 && (
|
|
||||||
<antd.Result status="info" title="No tracks" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{this.state.items.map((track, index) => {
|
|
||||||
const progress = this.getUploadProgress(track.uid)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-swapy-slot={track._id ?? track.uid}>
|
|
||||||
<TrackListItem
|
|
||||||
index={index}
|
|
||||||
track={track}
|
|
||||||
onEdit={this.modifyTrackByUid}
|
|
||||||
onDelete={this.removeTrackByUid}
|
|
||||||
progress={progress}
|
|
||||||
disabled={progress > 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ReleaseTracks = (props) => {
|
|
||||||
const { state, setState } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="music-studio-release-editor-tab">
|
|
||||||
<h1>Tracks</h1>
|
|
||||||
|
|
||||||
<TracksManager
|
|
||||||
_id={state._id}
|
|
||||||
items={state.items}
|
|
||||||
onChangeState={(managerState) => {
|
|
||||||
setState({
|
|
||||||
...state,
|
|
||||||
...managerState,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReleaseTracks
|
|
@ -1,33 +0,0 @@
|
|||||||
.music-studio-release-editor-tracks {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
.music-studio-tracks-uploader {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.ant-upload {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-studio-release-editor-tracks-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
@ -1,93 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from "@dnd-kit/core"
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
useSortable,
|
|
||||||
} from "@dnd-kit/sortable"
|
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
|
||||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
|
||||||
|
|
||||||
export default function SortableItem({ id, children }) {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
setActivatorNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id })
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
cursor: "grab",
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={setNodeRef} style={style}>
|
|
||||||
{children({
|
|
||||||
...attributes,
|
|
||||||
...listeners,
|
|
||||||
ref: setActivatorNodeRef,
|
|
||||||
style: { cursor: "grab", touchAction: "none" },
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SortableList({ items, renderItem, onOrder }) {
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 5,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(KeyboardSensor),
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDragEnd = (event) => {
|
|
||||||
const { active, over } = event
|
|
||||||
if (over && active.id !== over.id) {
|
|
||||||
const oldIndex = items.findIndex((i) => i.id === active.id)
|
|
||||||
const newIndex = items.findIndex((i) => i.id === over.id)
|
|
||||||
const newItems = arrayMove(items, oldIndex, newIndex)
|
|
||||||
onOrder(newItems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
modifiers={[restrictToVerticalAxis]}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={items}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<SortableItem key={item.id} id={item.id}>
|
|
||||||
{(handleProps) => (
|
|
||||||
<div>
|
|
||||||
{renderItem(item, index)}
|
|
||||||
<div id="drag-handle" {...handleProps} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SortableItem>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,51 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
|
|
||||||
import { Icons } from "@components/Icons"
|
|
||||||
import Image from "@components/Image"
|
|
||||||
|
|
||||||
import "./index.less"
|
|
||||||
|
|
||||||
const ReleaseItem = (props) => {
|
|
||||||
const { release, onClick } = props
|
|
||||||
|
|
||||||
async function handleOnClick() {
|
|
||||||
if (typeof onClick === "function") {
|
|
||||||
return onClick(release)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div
|
|
||||||
id={release._id}
|
|
||||||
className="music-studio-page-release"
|
|
||||||
onClick={handleOnClick}
|
|
||||||
>
|
|
||||||
<div className="music-studio-page-release-title">
|
|
||||||
<Image
|
|
||||||
src={release.cover}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{release.title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="music-studio-page-release-info"
|
|
||||||
>
|
|
||||||
<div className="music-studio-page-release-info-field">
|
|
||||||
<Icons.IoMdMusicalNote />
|
|
||||||
{release.type}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="music-studio-page-release-info-field">
|
|
||||||
<Icons.MdTag />
|
|
||||||
{release._id}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* <div className="music-studio-page-release-info-field">
|
|
||||||
<Icons.IoMdEye />
|
|
||||||
{release.analytics?.listen_count ?? 0}
|
|
||||||
</div> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReleaseItem
|
|
@ -1,184 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import * as antd from "antd"
|
|
||||||
|
|
||||||
import CoverEditor from "@components/CoverEditor"
|
|
||||||
import { Icons } from "@components/Icons"
|
|
||||||
import EnhancedLyricsEditor from "@components/MusicStudio/EnhancedLyricsEditor"
|
|
||||||
|
|
||||||
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
|
||||||
|
|
||||||
import "./index.less"
|
|
||||||
|
|
||||||
const TrackEditor = (props) => {
|
|
||||||
const context = React.useContext(ReleaseEditorStateContext)
|
|
||||||
const [track, setTrack] = React.useState(props.track ?? {})
|
|
||||||
|
|
||||||
async function handleChange(key, value) {
|
|
||||||
setTrack((prev) => {
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[key]: value,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEnhancedLyricsEditor() {
|
|
||||||
context.renderCustomPage({
|
|
||||||
header: "Enhanced Lyrics",
|
|
||||||
content: EnhancedLyricsEditor,
|
|
||||||
props: {
|
|
||||||
track: track,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOnSave() {
|
|
||||||
setTrack((prev) => {
|
|
||||||
const listData = [...context.items]
|
|
||||||
|
|
||||||
const trackIndex = listData.findIndex(
|
|
||||||
(item) => item.uid === prev.uid,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (trackIndex === -1) {
|
|
||||||
return prev
|
|
||||||
}
|
|
||||||
|
|
||||||
listData[trackIndex] = prev
|
|
||||||
|
|
||||||
context.setGlobalState({
|
|
||||||
...context,
|
|
||||||
items: listData,
|
|
||||||
})
|
|
||||||
|
|
||||||
props.close()
|
|
||||||
|
|
||||||
return prev
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function setParentCover() {
|
|
||||||
handleChange("cover", context.cover)
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
context.setCustomPageActions([
|
|
||||||
{
|
|
||||||
label: "Save",
|
|
||||||
icon: "FiSave",
|
|
||||||
type: "primary",
|
|
||||||
onClick: handleOnSave,
|
|
||||||
disabled: props.track === track,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}, [track])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="track-editor">
|
|
||||||
<div className="track-editor-field">
|
|
||||||
<div className="track-editor-field-header">
|
|
||||||
<Icons.MdImage />
|
|
||||||
<span>Cover</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CoverEditor
|
|
||||||
value={track.cover}
|
|
||||||
onChange={(url) => handleChange("cover", url)}
|
|
||||||
extraActions={[
|
|
||||||
<antd.Button onClick={setParentCover}>
|
|
||||||
Use Parent
|
|
||||||
</antd.Button>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="track-editor-field">
|
|
||||||
<div className="track-editor-field-header">
|
|
||||||
<Icons.MdOutlineMusicNote />
|
|
||||||
<span>Title</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<antd.Input
|
|
||||||
value={track.title}
|
|
||||||
placeholder="Track title"
|
|
||||||
onChange={(e) => handleChange("title", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="track-editor-field">
|
|
||||||
<div className="track-editor-field-header">
|
|
||||||
<Icons.FiUser />
|
|
||||||
<span>Artist</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<antd.Input
|
|
||||||
value={track.artist}
|
|
||||||
placeholder="Artist"
|
|
||||||
onChange={(e) => handleChange("artist", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="track-editor-field">
|
|
||||||
<div className="track-editor-field-header">
|
|
||||||
<Icons.MdAlbum />
|
|
||||||
<span>Album</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<antd.Input
|
|
||||||
value={track.album}
|
|
||||||
placeholder="Album"
|
|
||||||
onChange={(e) => handleChange("album", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="track-editor-field">
|
|
||||||
<div className="track-editor-field-header">
|
|
||||||
<Icons.MdExplicit />
|
|
||||||
<span>Explicit</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<antd.Switch
|
|
||||||
checked={track.explicit}
|
|
||||||
onChange={(value) => handleChange("explicit", value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="track-editor-field">
|
|
||||||
<div className="track-editor-field-header">
|
|
||||||
<Icons.FiEye />
|
|
||||||
<span>Public</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<antd.Switch
|
|
||||||
checked={track.public}
|
|
||||||
onChange={(value) => handleChange("public", value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="track-editor-field">
|
|
||||||
<div className="track-editor-field-header">
|
|
||||||
<Icons.MdLyrics />
|
|
||||||
<span>Enhanced Lyrics</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="track-editor-field-actions">
|
|
||||||
<antd.Button
|
|
||||||
disabled={!track.params._id}
|
|
||||||
onClick={openEnhancedLyricsEditor}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</antd.Button>
|
|
||||||
|
|
||||||
{!track.params._id && (
|
|
||||||
<span>
|
|
||||||
You cannot edit Video and Lyrics without release
|
|
||||||
first
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TrackEditor
|
|
@ -1,59 +0,0 @@
|
|||||||
.track-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 20px;
|
|
||||||
|
|
||||||
.track-editor-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
align-self: center;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-editor-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.track-editor-field-header {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 7px;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-editor-field-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
|
|
||||||
import ReleaseEditor from "@components/MusicStudio/ReleaseEditor"
|
|
||||||
|
|
||||||
const ReleaseEditorPage = (props) => {
|
|
||||||
const { release_id } = props.params
|
|
||||||
|
|
||||||
return <ReleaseEditor
|
|
||||||
release_id={release_id}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReleaseEditorPage
|
|
@ -0,0 +1,62 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
|
||||||
|
import ReleaseItem from "../ReleaseItem"
|
||||||
|
|
||||||
|
import MusicModel from "@models/music"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const MyReleasesList = () => {
|
||||||
|
const [loading, response, error] = app.cores.api.useRequest(MusicModel.getMyReleases, {
|
||||||
|
offset: 0,
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleReleaseClick = React.useCallback((release) => {
|
||||||
|
app.location.push(`/studio/music/release/${release._id}`)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (loading) {
|
||||||
|
return <antd.Skeleton active />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<antd.Result
|
||||||
|
status="warning"
|
||||||
|
title="Failed to retrieve releases"
|
||||||
|
subTitle={error.message}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response?.items?.length) {
|
||||||
|
return <antd.Empty description="No releases found" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="music-studio-page-releases-list">
|
||||||
|
{response.items.map((release) => (
|
||||||
|
<ReleaseItem
|
||||||
|
key={release._id}
|
||||||
|
release={release}
|
||||||
|
onClick={handleReleaseClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="music-studio-page-content">
|
||||||
|
<div className="music-studio-page-header">
|
||||||
|
<h1>Your Releases</h1>
|
||||||
|
</div>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyReleasesList
|
@ -0,0 +1,57 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
import { Icons } from "@components/Icons"
|
||||||
|
import Image from "@components/Image"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const ReleaseItem = ({ release, onClick }) => {
|
||||||
|
const handleClick = React.useCallback(() => {
|
||||||
|
onClick?.(release)
|
||||||
|
}, [onClick, release])
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback((e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleClick()
|
||||||
|
}
|
||||||
|
}, [handleClick])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={release._id}
|
||||||
|
className="music-studio-page-release"
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Open release ${release.title}`}
|
||||||
|
>
|
||||||
|
<div className="music-studio-page-release-title">
|
||||||
|
<Image
|
||||||
|
src={release.cover}
|
||||||
|
/>
|
||||||
|
{release.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="music-studio-page-release-info">
|
||||||
|
<div className="music-studio-page-release-info-field">
|
||||||
|
<Icons.IoMdMusicalNote />
|
||||||
|
{release.type}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="music-studio-page-release-info-field">
|
||||||
|
<Icons.MdTag />
|
||||||
|
{release._id}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div className="music-studio-page-release-info-field">
|
||||||
|
<Icons.IoMdEye />
|
||||||
|
{release.analytics?.listen_count ?? 0}
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReleaseItem
|
188
packages/app/src/pages/studio/music/hooks/useReleaseEditor.js
Normal file
188
packages/app/src/pages/studio/music/hooks/useReleaseEditor.js
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import React from "react"
|
||||||
|
import MusicModel from "@models/music"
|
||||||
|
import TrackManifest from "@cores/player/classes/TrackManifest"
|
||||||
|
|
||||||
|
const DEFAULT_RELEASE_STATE = {
|
||||||
|
title: "Untitled",
|
||||||
|
type: "single",
|
||||||
|
public: false,
|
||||||
|
cover: "",
|
||||||
|
items: [],
|
||||||
|
description: "",
|
||||||
|
explicit: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const useReleaseEditor = (releaseId) => {
|
||||||
|
const [loading, setLoading] = React.useState(true)
|
||||||
|
const [submitting, setSubmitting] = React.useState(false)
|
||||||
|
const [loadError, setLoadError] = React.useState(null)
|
||||||
|
const [submitError, setSubmitError] = React.useState(null)
|
||||||
|
|
||||||
|
const [data, setData] = React.useState(DEFAULT_RELEASE_STATE)
|
||||||
|
const [initialValues, setInitialValues] = React.useState(
|
||||||
|
DEFAULT_RELEASE_STATE,
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchData = React.useCallback(async () => {
|
||||||
|
if (releaseId === "new") {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setLoadError(null)
|
||||||
|
|
||||||
|
const data = await MusicModel.getReleaseData(releaseId)
|
||||||
|
|
||||||
|
if (Array.isArray(data.items)) {
|
||||||
|
data.items = data.items.map((item) => new TrackManifest(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(data)
|
||||||
|
setInitialValues(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load release data:", error)
|
||||||
|
setLoadError(error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [releaseId])
|
||||||
|
|
||||||
|
const changeData = React.useCallback((updates) => {
|
||||||
|
setData((prev) => {
|
||||||
|
let newData
|
||||||
|
|
||||||
|
if (typeof updates === "function") {
|
||||||
|
newData = updates(prev)
|
||||||
|
} else {
|
||||||
|
newData = { ...prev, ...updates }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent unnecessary updates
|
||||||
|
if (JSON.stringify(newData) === JSON.stringify(prev)) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
return newData
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hasChanges = React.useMemo(() => {
|
||||||
|
return JSON.stringify(data) !== JSON.stringify(initialValues)
|
||||||
|
}, [data, initialValues])
|
||||||
|
|
||||||
|
const releaseDataRef = React.useRef(data)
|
||||||
|
const hasChangesRef = React.useRef(hasChanges)
|
||||||
|
|
||||||
|
releaseDataRef.current = data
|
||||||
|
hasChangesRef.current = hasChanges
|
||||||
|
|
||||||
|
const submitRelease = React.useCallback(async () => {
|
||||||
|
if (!hasChangesRef.current) {
|
||||||
|
app.message.warning("No changes to save")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true)
|
||||||
|
setSubmitError(null)
|
||||||
|
|
||||||
|
const currentReleaseData = releaseDataRef.current
|
||||||
|
|
||||||
|
// Submit tracks first if there are any
|
||||||
|
let trackIds = []
|
||||||
|
if (
|
||||||
|
currentReleaseData.items &&
|
||||||
|
currentReleaseData.items.length > 0
|
||||||
|
) {
|
||||||
|
const tracks = await MusicModel.putTrack({
|
||||||
|
items: currentReleaseData.items,
|
||||||
|
})
|
||||||
|
trackIds = tracks.items.map((item) => item._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then submit release
|
||||||
|
const releasePayload = {
|
||||||
|
_id: currentReleaseData._id,
|
||||||
|
title: currentReleaseData.title,
|
||||||
|
description: currentReleaseData.description,
|
||||||
|
public: currentReleaseData.public,
|
||||||
|
cover: currentReleaseData.cover,
|
||||||
|
explicit: currentReleaseData.explicit,
|
||||||
|
type: currentReleaseData.type,
|
||||||
|
items: trackIds,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await MusicModel.putRelease(releasePayload)
|
||||||
|
|
||||||
|
// Update initial values to prevent showing "unsaved changes"
|
||||||
|
setInitialValues(currentReleaseData)
|
||||||
|
|
||||||
|
app.message.success("Release saved successfully")
|
||||||
|
|
||||||
|
if (releaseId === "new") {
|
||||||
|
app.location.push(result._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// update items
|
||||||
|
fetchData()
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to submit release:", error)
|
||||||
|
app.message.error(error.message || "Failed to save release")
|
||||||
|
setSubmitError(error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const deleteRelease = React.useCallback(async () => {
|
||||||
|
const currentReleaseData = releaseDataRef.current
|
||||||
|
|
||||||
|
if (!currentReleaseData._id) {
|
||||||
|
console.warn("Cannot delete release without ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MusicModel.deleteRelease(currentReleaseData._id)
|
||||||
|
app.message.success("Release deleted successfully")
|
||||||
|
app.location.push("/studio/music")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete release:", error)
|
||||||
|
app.message.error(error.message || "Failed to delete release")
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const isNewRelease = releaseId === "new"
|
||||||
|
const canSubmit = hasChanges && !submitting && !loading
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
loading,
|
||||||
|
submitting,
|
||||||
|
loadError,
|
||||||
|
submitError,
|
||||||
|
|
||||||
|
hasChanges,
|
||||||
|
isNewRelease,
|
||||||
|
canSubmit,
|
||||||
|
|
||||||
|
data: data,
|
||||||
|
changeData: changeData,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
submitRelease: submitRelease,
|
||||||
|
deleteRelease: deleteRelease,
|
||||||
|
reload: fetchData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useReleaseEditor
|
222
packages/app/src/pages/studio/music/hooks/useTracksManager.js
Normal file
222
packages/app/src/pages/studio/music/hooks/useTracksManager.js
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import React from "react"
|
||||||
|
import queuedUploadFile from "@utils/queuedUploadFile"
|
||||||
|
import FilesModel from "@models/files"
|
||||||
|
import TrackManifest from "@cores/player/classes/TrackManifest"
|
||||||
|
|
||||||
|
const useTracksManager = (initialTracks = [], updater) => {
|
||||||
|
const [tracks, setTracks] = React.useState(initialTracks)
|
||||||
|
const [pendingUploads, setPendingUploads] = React.useState([])
|
||||||
|
|
||||||
|
const findTrackByUid = React.useCallback(
|
||||||
|
(uid) => {
|
||||||
|
return tracks.find((track) => track.uid === uid)
|
||||||
|
},
|
||||||
|
[tracks],
|
||||||
|
)
|
||||||
|
|
||||||
|
const addTrack = React.useCallback((track) => {
|
||||||
|
if (!track) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setTracks((prev) => [...prev, track])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeTrack = React.useCallback((uid) => {
|
||||||
|
if (!uid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setTracks((prev) => {
|
||||||
|
const filtered = prev.filter((track) => track.uid !== uid)
|
||||||
|
return filtered.length !== prev.length ? filtered : prev
|
||||||
|
})
|
||||||
|
setPendingUploads((prev) => prev.filter((upload) => upload.uid !== uid))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateTrack = React.useCallback((uid, updates) => {
|
||||||
|
if (!uid || !updates) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setTracks((prev) => {
|
||||||
|
const updated = prev.map((track) =>
|
||||||
|
track.uid === uid ? { ...track, ...updates } : track,
|
||||||
|
)
|
||||||
|
return JSON.stringify(updated) !== JSON.stringify(prev)
|
||||||
|
? updated
|
||||||
|
: prev
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reorderTracks = React.useCallback((newTracksArray) => {
|
||||||
|
if (!Array.isArray(newTracksArray)) {
|
||||||
|
console.warn("reorderTracks: Invalid tracks array provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTracks((prev) => {
|
||||||
|
if (JSON.stringify(prev) === JSON.stringify(newTracksArray)) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return newTracksArray
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addPendingUpload = React.useCallback((uid) => {
|
||||||
|
if (!uid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingUploads((prev) => {
|
||||||
|
if (prev.find((upload) => upload.uid === uid)) return prev
|
||||||
|
return [...prev, { uid, progress: 0 }]
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removePendingUpload = React.useCallback((uid) => {
|
||||||
|
if (!uid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingUploads((prev) => prev.filter((upload) => upload.uid !== uid))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateUploadProgress = React.useCallback((uid, progress) => {
|
||||||
|
setPendingUploads((prev) =>
|
||||||
|
prev.map((upload) =>
|
||||||
|
upload.uid === uid ? { ...upload, progress } : upload,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getUploadProgress = React.useCallback(
|
||||||
|
(uid) => {
|
||||||
|
const upload = pendingUploads.find((upload) => upload.uid === uid)
|
||||||
|
return upload?.progress || null
|
||||||
|
},
|
||||||
|
[pendingUploads],
|
||||||
|
)
|
||||||
|
|
||||||
|
const uploadToStorage = React.useCallback(
|
||||||
|
async (req) => {
|
||||||
|
await queuedUploadFile(req.file, {
|
||||||
|
onFinish: (file, response) => {
|
||||||
|
req.onSuccess(response)
|
||||||
|
},
|
||||||
|
onError: req.onError,
|
||||||
|
onProgress: (file, progress) => {
|
||||||
|
updateUploadProgress(file.uid, progress)
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
transformations: "a-dash",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[updateUploadProgress],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleUploadStateChange = async (change) => {
|
||||||
|
const uid = change.file.uid
|
||||||
|
|
||||||
|
switch (change.file.status) {
|
||||||
|
case "uploading": {
|
||||||
|
addPendingUpload(uid)
|
||||||
|
|
||||||
|
const trackManifest = new TrackManifest({
|
||||||
|
uid,
|
||||||
|
file: change.file.originFileObj,
|
||||||
|
})
|
||||||
|
|
||||||
|
addTrack(trackManifest)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "done": {
|
||||||
|
let trackManifest = findTrackByUid(uid)
|
||||||
|
|
||||||
|
if (!trackManifest) {
|
||||||
|
console.error(`Track with uid [${uid}] not found!`)
|
||||||
|
app.message.error(`Track with uid [${uid}] not found!`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
trackManifest.source = change.file.response.url
|
||||||
|
trackManifest = await trackManifest.initialize()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (trackManifest._coverBlob) {
|
||||||
|
const coverFile = new File(
|
||||||
|
[trackManifest._coverBlob],
|
||||||
|
"cover",
|
||||||
|
{ type: trackManifest._coverBlob.type },
|
||||||
|
)
|
||||||
|
|
||||||
|
const coverUpload = await FilesModel.upload(coverFile, {
|
||||||
|
headers: {
|
||||||
|
"prefer-no-job": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
trackManifest.cover = coverUpload.url
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTrack(uid, trackManifest)
|
||||||
|
removePendingUpload(uid)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
case "removed": {
|
||||||
|
removePendingUpload(uid)
|
||||||
|
removeTrack(uid)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync with initial tracks from props (only when length changes or first mount)
|
||||||
|
const prevInitialTracksLength = React.useRef(initialTracks.length)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (
|
||||||
|
initialTracks.length !== prevInitialTracksLength.current ||
|
||||||
|
tracks.length === 0
|
||||||
|
) {
|
||||||
|
setTracks(initialTracks)
|
||||||
|
prevInitialTracksLength.current = initialTracks.length
|
||||||
|
}
|
||||||
|
}, [initialTracks.length])
|
||||||
|
|
||||||
|
// Notify parent when tracks change (but not on initial mount)
|
||||||
|
const isInitialMount = React.useRef(true)
|
||||||
|
const onTracksChangeRef = React.useRef(updater)
|
||||||
|
|
||||||
|
onTracksChangeRef.current = updater
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onTracksChangeRef.current?.(tracks)
|
||||||
|
}, [tracks])
|
||||||
|
|
||||||
|
return {
|
||||||
|
tracks,
|
||||||
|
pendingUploads,
|
||||||
|
addTrack,
|
||||||
|
removeTrack,
|
||||||
|
updateTrack,
|
||||||
|
reorderTracks,
|
||||||
|
getUploadProgress,
|
||||||
|
uploadToStorage,
|
||||||
|
handleUploadStateChange,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useTracksManager
|
@ -3,38 +3,30 @@ import * as antd from "antd"
|
|||||||
|
|
||||||
import { Icons } from "@components/Icons"
|
import { Icons } from "@components/Icons"
|
||||||
|
|
||||||
import MyReleasesList from "@components/MusicStudio/MyReleasesList"
|
import MyReleasesList from "./components/MyReleasesList"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const ReleasesAnalytics = () => {
|
const MusicStudioPage = () => {
|
||||||
return <div>
|
return (
|
||||||
<h1>Analytics</h1>
|
<div className="music-studio-page">
|
||||||
</div>
|
<div className="music-studio-page-header">
|
||||||
|
<h1>Music Studio</h1>
|
||||||
|
|
||||||
|
<antd.Button
|
||||||
|
type="primary"
|
||||||
|
icon={<Icons.FiPlusCircle />}
|
||||||
|
onClick={() => {
|
||||||
|
app.location.push("/studio/music/release/new")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New Release
|
||||||
|
</antd.Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MyReleasesList />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MusicStudioPage = (props) => {
|
export default MusicStudioPage
|
||||||
return <div
|
|
||||||
className="music-studio-page"
|
|
||||||
>
|
|
||||||
<div className="music-studio-page-header">
|
|
||||||
<h1>Music Studio</h1>
|
|
||||||
|
|
||||||
<antd.Button
|
|
||||||
type="primary"
|
|
||||||
icon={<Icons.FiPlusCircle />}
|
|
||||||
onClick={() => {
|
|
||||||
app.location.push("/studio/music/new")
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
New Release
|
|
||||||
</antd.Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReleasesAnalytics />
|
|
||||||
|
|
||||||
<MyReleasesList />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MusicStudioPage
|
|
||||||
|
@ -0,0 +1,143 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { Icons, createIconRender } from "@components/Icons"
|
||||||
|
|
||||||
|
import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey"
|
||||||
|
import useReleaseEditor from "../../hooks/useReleaseEditor"
|
||||||
|
|
||||||
|
import Tabs from "./tabs"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const ReleaseEditor = (props) => {
|
||||||
|
const { release_id } = props.params
|
||||||
|
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
loadError,
|
||||||
|
|
||||||
|
submitting,
|
||||||
|
submitError,
|
||||||
|
|
||||||
|
data,
|
||||||
|
changeData,
|
||||||
|
|
||||||
|
submitRelease,
|
||||||
|
deleteRelease,
|
||||||
|
|
||||||
|
canSubmit,
|
||||||
|
isNewRelease,
|
||||||
|
} = useReleaseEditor(release_id)
|
||||||
|
|
||||||
|
const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
|
||||||
|
defaultKey: "info",
|
||||||
|
queryKey: "tab",
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDelete = React.useCallback(() => {
|
||||||
|
app.layout.modal.confirm({
|
||||||
|
headerText: "Are you sure you want to delete this release?",
|
||||||
|
descriptionText: "This action cannot be undone.",
|
||||||
|
onConfirm: deleteRelease,
|
||||||
|
})
|
||||||
|
}, [deleteRelease])
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<antd.Result
|
||||||
|
status="warning"
|
||||||
|
title="Error"
|
||||||
|
subTitle={loadError.message}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <antd.Skeleton active />
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tab = Tabs.find(({ key }) => key === selectedTab)
|
||||||
|
|
||||||
|
if (!Tab) {
|
||||||
|
return (
|
||||||
|
<antd.Result
|
||||||
|
status="error"
|
||||||
|
title="Error"
|
||||||
|
subTitle="Tab not found"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="music-studio-release-editor-content">
|
||||||
|
{submitError && (
|
||||||
|
<antd.Alert message={submitError.message} type="error" />
|
||||||
|
)}
|
||||||
|
{React.createElement(Tab.render, {
|
||||||
|
data: data,
|
||||||
|
changeData: changeData,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="music-studio-release-editor">
|
||||||
|
<div className="music-studio-release-editor-menu">
|
||||||
|
<antd.Menu
|
||||||
|
onClick={(e) => setSelectedTab(e.key)}
|
||||||
|
selectedKeys={[selectedTab]}
|
||||||
|
items={Tabs}
|
||||||
|
mode="vertical"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="music-studio-release-editor-menu-actions">
|
||||||
|
<antd.Button
|
||||||
|
type="primary"
|
||||||
|
onClick={submitRelease}
|
||||||
|
icon={
|
||||||
|
isNewRelease ? <Icons.MdSend /> : <Icons.FiSave />
|
||||||
|
}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
loading={submitting}
|
||||||
|
>
|
||||||
|
{isNewRelease ? "Release" : "Save"}
|
||||||
|
</antd.Button>
|
||||||
|
|
||||||
|
{!isNewRelease && (
|
||||||
|
<antd.Button
|
||||||
|
icon={<Icons.IoMdTrash />}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</antd.Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isNewRelease && (
|
||||||
|
<antd.Button
|
||||||
|
icon={<Icons.MdLink />}
|
||||||
|
onClick={() =>
|
||||||
|
app.location.push(`/music/list/${data._id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Go to release
|
||||||
|
</antd.Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseEditor.options = {
|
||||||
|
layout: {
|
||||||
|
type: "default",
|
||||||
|
centeredContent: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReleaseEditor
|
@ -0,0 +1,136 @@
|
|||||||
|
.music-studio-release-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
min-width: 700px;
|
||||||
|
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
.music-studio-release-editor-custom-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
.music-studio-release-editor-custom-page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
background-color: var(--background-color-accent);
|
||||||
|
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
p,
|
||||||
|
span {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-studio-release-editor-custom-page-header-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-studio-release-editor-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-studio-release-editor-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-menu {
|
||||||
|
background-color: var(--background-color-accent) !important;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
.ant-menu-item {
|
||||||
|
padding: 5px 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-menu-item-selected {
|
||||||
|
background-color: var(--background-color-primary-2) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-studio-release-editor-menu-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-studio-release-editor-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.music-studio-release-editor-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-form-item {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
height: fit-content;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,136 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
|
||||||
|
import { Icons } from "@components/Icons"
|
||||||
|
|
||||||
|
import CoverEditor from "@components/CoverEditor"
|
||||||
|
|
||||||
|
const ReleasesTypes = [
|
||||||
|
{
|
||||||
|
value: "single",
|
||||||
|
label: "Single",
|
||||||
|
icon: <Icons.MdMusicNote />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ep",
|
||||||
|
label: "Episode",
|
||||||
|
icon: <Icons.MdAlbum />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "album",
|
||||||
|
label: "Album",
|
||||||
|
icon: <Icons.MdAlbum />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "compilation",
|
||||||
|
label: "Compilation",
|
||||||
|
icon: <Icons.MdAlbum />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const BasicInformation = ({ data, changeData }) => {
|
||||||
|
const handleFormChange = React.useCallback(
|
||||||
|
(changes) => {
|
||||||
|
changeData((prev) => ({ ...prev, ...changes }))
|
||||||
|
},
|
||||||
|
[data],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="music-studio-release-editor-tab">
|
||||||
|
<h1>Release Information</h1>
|
||||||
|
|
||||||
|
<antd.Form
|
||||||
|
name="basic"
|
||||||
|
layout="vertical"
|
||||||
|
requiredMark={false}
|
||||||
|
onValuesChange={handleFormChange}
|
||||||
|
>
|
||||||
|
<antd.Form.Item
|
||||||
|
label=""
|
||||||
|
name="cover"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: "Input a cover for the release",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
initialValue={data?.cover}
|
||||||
|
>
|
||||||
|
<CoverEditor defaultUrl="https://storage.ragestudio.net/comty-static-assets/default_song.png" />
|
||||||
|
</antd.Form.Item>
|
||||||
|
|
||||||
|
{data?._id && (
|
||||||
|
<antd.Form.Item
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<Icons.MdTag /> <span>ID</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
name="_id"
|
||||||
|
initialValue={data._id}
|
||||||
|
>
|
||||||
|
<antd.Input placeholder="Release ID" disabled />
|
||||||
|
</antd.Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<antd.Form.Item
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<Icons.MdMusicNote /> <span>Title</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
name="title"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: "Input a title for the release",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
initialValue={data?.title}
|
||||||
|
>
|
||||||
|
<antd.Input
|
||||||
|
placeholder="Release title"
|
||||||
|
maxLength={128}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</antd.Form.Item>
|
||||||
|
|
||||||
|
<antd.Form.Item
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<Icons.MdAlbum /> <span>Type</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
name="type"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: "Select a type for the release",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
initialValue={data?.type}
|
||||||
|
>
|
||||||
|
<antd.Select
|
||||||
|
placeholder="Release type"
|
||||||
|
options={ReleasesTypes}
|
||||||
|
/>
|
||||||
|
</antd.Form.Item>
|
||||||
|
|
||||||
|
<antd.Form.Item
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<Icons.MdPublic /> <span>Public</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
name="public"
|
||||||
|
initialValue={data?.public}
|
||||||
|
>
|
||||||
|
<antd.Switch />
|
||||||
|
</antd.Form.Item>
|
||||||
|
</antd.Form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BasicInformation
|
@ -0,0 +1,145 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { useSortable } from "@dnd-kit/sortable"
|
||||||
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
|
import classnames from "classnames"
|
||||||
|
|
||||||
|
import { Icons } from "@components/Icons"
|
||||||
|
import TrackEditor from "../TrackEditor"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const stateToString = {
|
||||||
|
uploading: "Uploading",
|
||||||
|
transmuxing: "Processing...",
|
||||||
|
uploading_s3: "Archiving...",
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTitleString = ({ track, progress }) => {
|
||||||
|
if (progress) {
|
||||||
|
return stateToString[progress.state] || progress.state
|
||||||
|
}
|
||||||
|
return track.title
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableTrackItem = ({
|
||||||
|
id,
|
||||||
|
release,
|
||||||
|
track,
|
||||||
|
index,
|
||||||
|
progress,
|
||||||
|
disabled,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id,
|
||||||
|
disabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.8 : 1,
|
||||||
|
zIndex: isDragging ? 1000 : 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditTrack = React.useCallback(() => {
|
||||||
|
app.layout.drawer.open("track-editor", TrackEditor, {
|
||||||
|
props: {
|
||||||
|
release: release,
|
||||||
|
track: track,
|
||||||
|
onUpdate: (updatedTrack) => onUpdate(track.uid, updatedTrack),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [track, onUpdate])
|
||||||
|
|
||||||
|
const handleRemoveTrack = React.useCallback(() => {
|
||||||
|
onDelete?.(track.uid)
|
||||||
|
}, [onDelete, track.uid])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={classnames(
|
||||||
|
"music-studio-release-editor-tracks-list-item",
|
||||||
|
{
|
||||||
|
["disabled"]: disabled,
|
||||||
|
["dragging"]: isDragging,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="music-studio-release-editor-tracks-list-item-progress"
|
||||||
|
style={{
|
||||||
|
"--upload-progress": `${progress?.percent ?? 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* <div className="music-studio-release-editor-tracks-list-item-index">
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{progress !== null && <Icons.LoadingOutlined />}
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={track.cover}
|
||||||
|
className="music-studio-release-editor-tracks-list-item-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="music-studio-release-editor-tracks-list-item-info">
|
||||||
|
<span>{getTitleString({ track, progress })}</span>
|
||||||
|
{!progress && (
|
||||||
|
<>
|
||||||
|
<span id="artist">{track.artist}</span>
|
||||||
|
<span id="album">{track.album}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="music-studio-release-editor-tracks-list-item-actions">
|
||||||
|
<antd.Popconfirm
|
||||||
|
title="Are you sure you want to delete this track?"
|
||||||
|
onConfirm={handleRemoveTrack}
|
||||||
|
okText="Yes"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<antd.Button
|
||||||
|
type="ghost"
|
||||||
|
icon={<Icons.FiTrash2 />}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</antd.Popconfirm>
|
||||||
|
|
||||||
|
<antd.Button
|
||||||
|
type="ghost"
|
||||||
|
icon={<Icons.FiEdit2 />}
|
||||||
|
onClick={handleEditTrack}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="music-studio-release-editor-tracks-list-item-dragger"
|
||||||
|
title="Drag to reorder track"
|
||||||
|
role="button"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-label="Drag to reorder track"
|
||||||
|
>
|
||||||
|
<Icons.MdDragIndicator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SortableTrackItem
|
@ -0,0 +1,86 @@
|
|||||||
|
.music-studio-release-editor-tracks-list-item {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
|
||||||
|
transition: all 200ms ease-in-out;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
opacity: 0.8;
|
||||||
|
z-index: 1000;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-studio-release-editor-tracks-list-item-progress {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
width: var(--upload-progress);
|
||||||
|
height: 3px;
|
||||||
|
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
background-color: var(--colorPrimary);
|
||||||
|
|
||||||
|
transition: width 150ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-studio-release-editor-tracks-list-item-cover {
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
object-fit: cover;
|
||||||
|
|
||||||
|
background-color: var(--background-color-accent);
|
||||||
|
border: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-studio-release-editor-tracks-list-item-info {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
#artist,
|
||||||
|
#album {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-studio-release-editor-tracks-list-item-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-studio-release-editor-tracks-list-item-dragger {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,109 @@
|
|||||||
|
import React from "react"
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core"
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable"
|
||||||
|
import {
|
||||||
|
restrictToVerticalAxis,
|
||||||
|
restrictToParentElement,
|
||||||
|
} from "@dnd-kit/modifiers"
|
||||||
|
|
||||||
|
import SortableTrackItem from "../ListItem"
|
||||||
|
|
||||||
|
const SortableTrackList = ({
|
||||||
|
release,
|
||||||
|
tracks = [],
|
||||||
|
onReorder,
|
||||||
|
getUploadProgress,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragEnd = React.useCallback(
|
||||||
|
(event) => {
|
||||||
|
const { active, over } = event
|
||||||
|
|
||||||
|
if (active.id !== over?.id) {
|
||||||
|
const oldIndex = tracks.findIndex(
|
||||||
|
(track) => (track._id || track.uid) === active.id,
|
||||||
|
)
|
||||||
|
const newIndex = tracks.findIndex(
|
||||||
|
(track) => (track._id || track.uid) === over.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
const newTracks = arrayMove(tracks, oldIndex, newIndex)
|
||||||
|
onReorder?.(newTracks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tracks, onReorder],
|
||||||
|
)
|
||||||
|
|
||||||
|
const trackIds = React.useMemo(
|
||||||
|
() => tracks.map((track) => track._id || track.uid),
|
||||||
|
[tracks],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={trackIds}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="music-studio-release-editor-tracks-list">
|
||||||
|
{tracks.map((track, index) => {
|
||||||
|
const progress = getUploadProgress?.(track.uid)
|
||||||
|
const isDisabled = disabled || !!progress
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableTrackItem
|
||||||
|
key={track._id || track.uid}
|
||||||
|
id={track._id || track.uid}
|
||||||
|
track={track}
|
||||||
|
index={index}
|
||||||
|
progress={progress}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onDelete={onDelete}
|
||||||
|
release={release}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SortableTrackList
|
@ -0,0 +1,152 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
|
||||||
|
import CoverEditor from "@components/CoverEditor"
|
||||||
|
import { Icons } from "@components/Icons"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const TrackField = ({ icon, label, children }) => (
|
||||||
|
<div className="track-editor-field">
|
||||||
|
<div className="track-editor-field-header">
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const TrackEditor = ({
|
||||||
|
release,
|
||||||
|
track: initialTrack = {},
|
||||||
|
onUpdate,
|
||||||
|
close,
|
||||||
|
setHeader,
|
||||||
|
}) => {
|
||||||
|
const [track, setTrack] = React.useState(initialTrack)
|
||||||
|
|
||||||
|
const handleSave = React.useCallback(async () => {
|
||||||
|
onUpdate?.(track)
|
||||||
|
close?.()
|
||||||
|
}, [track, onUpdate, close])
|
||||||
|
|
||||||
|
const handleChange = React.useCallback((key, value) => {
|
||||||
|
setTrack((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClickEditLyrics = React.useCallback(() => {
|
||||||
|
app.layout.modal.confirm({
|
||||||
|
headerText: "Save your changes",
|
||||||
|
descriptionText:
|
||||||
|
"All unsaved changes will be lost, make sure you have saved & submitted your changes before proceeding.",
|
||||||
|
onConfirm: async () => {
|
||||||
|
close()
|
||||||
|
app.location.push(`/studio/music/track_lyrics/${track._id}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setParentCover = React.useCallback(() => {
|
||||||
|
handleChange("cover", release.cover || "")
|
||||||
|
}, [handleChange, release])
|
||||||
|
|
||||||
|
const hasChanges = React.useMemo(() => {
|
||||||
|
return JSON.stringify(initialTrack) !== JSON.stringify(track)
|
||||||
|
}, [initialTrack, track])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setHeader?.({
|
||||||
|
title: "Track Editor",
|
||||||
|
actions: [
|
||||||
|
<antd.Button
|
||||||
|
key="save"
|
||||||
|
type="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
icon={<Icons.FiSave />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</antd.Button>,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}, [setHeader, handleSave, hasChanges])
|
||||||
|
|
||||||
|
console.log(track, release)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="track-editor">
|
||||||
|
<TrackField icon={<Icons.MdImage />} label="Cover">
|
||||||
|
<CoverEditor
|
||||||
|
value={track.cover}
|
||||||
|
onChange={(url) => handleChange("cover", url)}
|
||||||
|
extraActions={[
|
||||||
|
<antd.Button
|
||||||
|
key="parent-cover"
|
||||||
|
onClick={setParentCover}
|
||||||
|
>
|
||||||
|
Use Parent
|
||||||
|
</antd.Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TrackField>
|
||||||
|
|
||||||
|
<TrackField icon={<Icons.MdOutlineMusicNote />} label="Title">
|
||||||
|
<antd.Input
|
||||||
|
value={track.title}
|
||||||
|
placeholder="Track title"
|
||||||
|
onChange={(e) => handleChange("title", e.target.value)}
|
||||||
|
/>
|
||||||
|
</TrackField>
|
||||||
|
|
||||||
|
<TrackField icon={<Icons.FiUser />} label="Artist">
|
||||||
|
<antd.Input
|
||||||
|
value={track.artist}
|
||||||
|
placeholder="Artist"
|
||||||
|
onChange={(e) => handleChange("artist", e.target.value)}
|
||||||
|
/>
|
||||||
|
</TrackField>
|
||||||
|
|
||||||
|
<TrackField icon={<Icons.MdAlbum />} label="Album">
|
||||||
|
<antd.Input
|
||||||
|
value={track.album}
|
||||||
|
placeholder="Album"
|
||||||
|
onChange={(e) => handleChange("album", e.target.value)}
|
||||||
|
/>
|
||||||
|
</TrackField>
|
||||||
|
|
||||||
|
<TrackField icon={<Icons.MdExplicit />} label="Explicit">
|
||||||
|
<antd.Switch
|
||||||
|
checked={track.explicit}
|
||||||
|
onChange={(value) => handleChange("explicit", value)}
|
||||||
|
/>
|
||||||
|
</TrackField>
|
||||||
|
|
||||||
|
<TrackField icon={<Icons.FiEye />} label="Public">
|
||||||
|
<antd.Switch
|
||||||
|
value={track.public}
|
||||||
|
onChange={(checked) => handleChange("public", checked)}
|
||||||
|
/>
|
||||||
|
</TrackField>
|
||||||
|
|
||||||
|
<TrackField icon={<Icons.MdLyrics />} label="Enhanced Lyrics">
|
||||||
|
<div className="track-editor-field-actions">
|
||||||
|
<antd.Button
|
||||||
|
disabled={!track.params?._id}
|
||||||
|
onClick={handleClickEditLyrics}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</antd.Button>
|
||||||
|
|
||||||
|
{!track.params?._id && (
|
||||||
|
<span>
|
||||||
|
You cannot edit Video and Lyrics without releasing
|
||||||
|
first
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TrackField>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrackEditor
|
@ -0,0 +1,60 @@
|
|||||||
|
.track-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
min-width: 600px;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
.track-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
align-self: center;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-editor-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.track-editor-field-header {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 7px;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-editor-field-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
|
||||||
|
import { Icons } from "@components/Icons"
|
||||||
|
import useTracksManager from "../../../../hooks/useTracksManager"
|
||||||
|
|
||||||
|
import UploadHint from "./components/UploadHint"
|
||||||
|
import SortableTrackList from "./components/SortableTrackList"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const ReleaseTracks = ({ data, changeData }) => {
|
||||||
|
const {
|
||||||
|
tracks,
|
||||||
|
getUploadProgress,
|
||||||
|
uploadToStorage,
|
||||||
|
handleUploadStateChange,
|
||||||
|
removeTrack,
|
||||||
|
updateTrack,
|
||||||
|
reorderTracks,
|
||||||
|
} = useTracksManager(data.items, (tracks) =>
|
||||||
|
changeData({
|
||||||
|
items: tracks,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle reorder with new tracks array
|
||||||
|
const handleReorder = React.useCallback(
|
||||||
|
(newTracksArray) => {
|
||||||
|
reorderTracks(newTracksArray)
|
||||||
|
},
|
||||||
|
[reorderTracks],
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderUploadButton = () => {
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
return <UploadHint />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<antd.Button className="uploadMoreButton" icon={<Icons.FiPlus />}>
|
||||||
|
Add another
|
||||||
|
</antd.Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTracksList = () => {
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
return <antd.Result status="info" title="No tracks" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableTrackList
|
||||||
|
release={data}
|
||||||
|
tracks={tracks}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
getUploadProgress={getUploadProgress}
|
||||||
|
onUpdate={updateTrack}
|
||||||
|
onDelete={removeTrack}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="music-studio-release-editor-tab">
|
||||||
|
<h1>Tracks</h1>
|
||||||
|
|
||||||
|
<div className="music-studio-release-editor-tracks">
|
||||||
|
<antd.Upload
|
||||||
|
className="music-studio-tracks-uploader"
|
||||||
|
onChange={handleUploadStateChange}
|
||||||
|
customRequest={uploadToStorage}
|
||||||
|
showUploadList={false}
|
||||||
|
accept="audio/*"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
{renderUploadButton()}
|
||||||
|
</antd.Upload>
|
||||||
|
|
||||||
|
<div className="music-studio-release-editor-tracks-container">
|
||||||
|
{renderTracksList()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReleaseTracks
|
@ -0,0 +1,52 @@
|
|||||||
|
.music-studio-release-editor-tracks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.music-studio-tracks-uploader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.ant-upload {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-studio-release-editor-tracks-container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-studio-release-editor-tracks-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* gap: 10px; */
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
&:empty::after {
|
||||||
|
content: "No tracks uploaded yet";
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 60px;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
border: 2px dashed var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--background-color-accent);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,124 @@
|
|||||||
|
import React from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import { Button, Slider, Flex } from "antd"
|
||||||
|
import {
|
||||||
|
PlayCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
SoundOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
} from "@ant-design/icons"
|
||||||
|
|
||||||
|
import { useAudioPlayer } from "../../hooks/useAudioPlayer"
|
||||||
|
|
||||||
|
import TimeIndicators from "../TimeIndicators"
|
||||||
|
import SeekBar from "../SeekBar"
|
||||||
|
|
||||||
|
const speedOptions = [
|
||||||
|
{ label: "0.5x", value: 0.5 },
|
||||||
|
{ label: "0.75x", value: 0.75 },
|
||||||
|
{ label: "1x", value: 1 },
|
||||||
|
{ label: "1.25x", value: 1.25 },
|
||||||
|
{ label: "1.5x", value: 1.5 },
|
||||||
|
{ label: "2x", value: 2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const InlinePlayer = React.forwardRef(({ src }, ref) => {
|
||||||
|
const {
|
||||||
|
audio,
|
||||||
|
toggle,
|
||||||
|
seek,
|
||||||
|
setSpeed,
|
||||||
|
setVolume,
|
||||||
|
playbackSpeed,
|
||||||
|
volume,
|
||||||
|
isPlaying,
|
||||||
|
isLoading,
|
||||||
|
} = useAudioPlayer(src)
|
||||||
|
|
||||||
|
React.useImperativeHandle(ref, () => {
|
||||||
|
return {
|
||||||
|
audio: audio,
|
||||||
|
toggle: toggle,
|
||||||
|
seek: seek,
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-player">
|
||||||
|
<Flex horizontal align="center" justify="space-between">
|
||||||
|
<Flex horizontal align="center" gap={20}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
icon={
|
||||||
|
isLoading ? (
|
||||||
|
<LoadingOutlined spin />
|
||||||
|
) : isPlaying ? (
|
||||||
|
<PauseCircleOutlined />
|
||||||
|
) : (
|
||||||
|
<PlayCircleOutlined />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="control-button play-button"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Flex horizontal align="center" gap={5}>
|
||||||
|
<SoundOutlined />
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={setVolume}
|
||||||
|
className="volume-slider"
|
||||||
|
tooltip={{
|
||||||
|
formatter: (value) =>
|
||||||
|
`${Math.round(value * 100)}%`,
|
||||||
|
}}
|
||||||
|
icon={<SoundOutlined />}
|
||||||
|
style={{ width: "100px" }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<code className="contime-display">
|
||||||
|
<TimeIndicators audio={audio} />
|
||||||
|
</code>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex vertical gap={10}>
|
||||||
|
<SeekBar audio={audio} onSeek={seek} />
|
||||||
|
|
||||||
|
<div className="speed-controls">
|
||||||
|
{speedOptions.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type={
|
||||||
|
playbackSpeed === option.value
|
||||||
|
? "primary"
|
||||||
|
: "default"
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setSpeed(option.value)}
|
||||||
|
className="speed-button"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
InlinePlayer.displayName = "InlinePlayer"
|
||||||
|
|
||||||
|
InlinePlayer.propTypes = {
|
||||||
|
src: PropTypes.string.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InlinePlayer
|
@ -0,0 +1,243 @@
|
|||||||
|
.inline-player {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
// Fixed dimensions to prevent layout shift
|
||||||
|
.time-display {
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable button sizes
|
||||||
|
.control-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
&.play-button {
|
||||||
|
min-width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress slider container
|
||||||
|
.progress-container {
|
||||||
|
margin: 16px 0;
|
||||||
|
|
||||||
|
.ant-slider {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
.ant-slider-rail {
|
||||||
|
background: #f5f5f5;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-slider-track {
|
||||||
|
background: #1890ff;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-slider-handle {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: -5px;
|
||||||
|
border: 2px solid #1890ff;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
box-shadow: 0 0 0 5px rgba(24, 144, 255, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .ant-slider-track {
|
||||||
|
background: #40a9ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speed controls
|
||||||
|
.speed-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.speed-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-right: 8px;
|
||||||
|
min-width: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-button {
|
||||||
|
min-width: 50px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&.ant-btn-primary {
|
||||||
|
background: #1890ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume controls
|
||||||
|
.volume-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 140px;
|
||||||
|
|
||||||
|
.volume-icon {
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
min-width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
width: 80px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
.ant-slider-rail {
|
||||||
|
background: #f0f0f0;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-slider-track {
|
||||||
|
background: #52c41a;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-slider-handle {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin-top: -4px;
|
||||||
|
border: 2px solid #52c41a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main controls layout
|
||||||
|
.main-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive adjustments
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
min-width: 100px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.speed-controls,
|
||||||
|
.volume-controls {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-controls {
|
||||||
|
min-width: 120px;
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.speed-controls {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.speed-button {
|
||||||
|
min-width: 45px;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth transitions
|
||||||
|
.ant-btn {
|
||||||
|
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
.ant-btn-loading {
|
||||||
|
.anticon {
|
||||||
|
animation: ant-spin 1s infinite linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state styling
|
||||||
|
&.has-error {
|
||||||
|
.ant-slider {
|
||||||
|
.ant-slider-rail {
|
||||||
|
background: #ffccc7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-slider-track {
|
||||||
|
background: #ff4d4f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus styles for accessibility
|
||||||
|
.ant-btn:focus-visible {
|
||||||
|
outline: 2px solid #1890ff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-slider:focus-within {
|
||||||
|
.ant-slider-handle {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Slider } from "antd"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
|
||||||
|
import formatTime from "../../utils/formatTime"
|
||||||
|
|
||||||
|
const SeekBar = ({ audio, onSeek }) => {
|
||||||
|
const [currentTime, setCurrentTime] = React.useState(0)
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false)
|
||||||
|
const [tempProgress, setTempProgress] = React.useState(0)
|
||||||
|
|
||||||
|
const intervalRef = React.useRef(null)
|
||||||
|
|
||||||
|
const duration = audio.current.duration ?? 0
|
||||||
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||||
|
|
||||||
|
const handleProgressStart = React.useCallback(() => {
|
||||||
|
setIsDragging(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleProgressChange = React.useCallback((value) => {
|
||||||
|
const duration = audio.current.duration ?? 0
|
||||||
|
|
||||||
|
setTempProgress(value)
|
||||||
|
onSeek((value / 100) * duration)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleProgressEnd = React.useCallback((value) => {
|
||||||
|
const duration = audio.current.duration ?? 0
|
||||||
|
|
||||||
|
setIsDragging(false)
|
||||||
|
onSeek((value / 100) * duration)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateCurrentTime = React.useCallback(() => {
|
||||||
|
setCurrentTime(audio.current.currentTime)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
intervalRef.current = setInterval(updateCurrentTime, 100)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
}
|
||||||
|
}, [!audio.current.paused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="progress-container">
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.1}
|
||||||
|
value={isDragging ? tempProgress : progress}
|
||||||
|
onChange={handleProgressChange}
|
||||||
|
onChangeComplete={handleProgressEnd}
|
||||||
|
onBeforeChange={handleProgressStart}
|
||||||
|
tooltip={{
|
||||||
|
formatter: (value) => {
|
||||||
|
const time = (value / 100) * duration
|
||||||
|
return formatTime(time)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SeekBar.propTypes = {
|
||||||
|
audio: PropTypes.object.isRequired,
|
||||||
|
onSeek: PropTypes.func.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SeekBar
|
@ -0,0 +1,37 @@
|
|||||||
|
import React from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import formatTime from "../../utils/formatTime"
|
||||||
|
|
||||||
|
const TimeIndicators = ({ audio }) => {
|
||||||
|
const [currentTime, setCurrentTime] = React.useState(0)
|
||||||
|
const frameId = React.useRef(null)
|
||||||
|
|
||||||
|
const timeTick = React.useCallback(() => {
|
||||||
|
setCurrentTime(audio.current.currentTime)
|
||||||
|
frameId.current = requestAnimationFrame(timeTick)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log("starting frame")
|
||||||
|
timeTick()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (frameId.current) {
|
||||||
|
console.log("canceling frame")
|
||||||
|
cancelAnimationFrame(frameId.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{formatTime(currentTime)} / {formatTime(audio.current.duration)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeIndicators.propTypes = {
|
||||||
|
audio: PropTypes.object.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimeIndicators
|
@ -0,0 +1,206 @@
|
|||||||
|
import React from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Track data
|
||||||
|
track: null,
|
||||||
|
|
||||||
|
// Audio state
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
|
isPlaying: false,
|
||||||
|
isLoading: false,
|
||||||
|
playbackSpeed: 1,
|
||||||
|
volume: 1,
|
||||||
|
|
||||||
|
// Lyrics state
|
||||||
|
lyrics: {},
|
||||||
|
selectedLanguage: "original",
|
||||||
|
|
||||||
|
// Video state
|
||||||
|
videoSource: null,
|
||||||
|
videoSyncTime: null,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
editMode: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const LyricsEditorContext = React.createContext()
|
||||||
|
|
||||||
|
function lyricsReducer(state, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_TRACK": {
|
||||||
|
return { ...state, track: action.payload }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_AUDIO_PLAYING": {
|
||||||
|
return { ...state, isPlaying: action.payload }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_AUDIO_SPEED": {
|
||||||
|
return { ...state, playbackSpeed: action.payload }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_AUDIO_VOLUME": {
|
||||||
|
return { ...state, volume: action.payload }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_AUDIO_LOADING": {
|
||||||
|
return { ...state, isLoading: action.payload }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_LYRICS": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
lyrics: {
|
||||||
|
lrc: {
|
||||||
|
original: [],
|
||||||
|
},
|
||||||
|
...action.payload,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "OVERRIDE_LINES": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
lyrics: {
|
||||||
|
...state.lyrics,
|
||||||
|
[state.selectedLanguage]: action.payload,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ADD_LINE": {
|
||||||
|
let lines = state.lyrics[state.selectedLanguage] ?? []
|
||||||
|
|
||||||
|
if (lines.find((line) => line.time === action.payload.time)) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(action.payload)
|
||||||
|
|
||||||
|
lines = lines.sort((a, b) => {
|
||||||
|
if (a.time === null) return -1
|
||||||
|
if (b.time === null) return 1
|
||||||
|
return a.time - b.time
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
lyrics: {
|
||||||
|
...state.lyrics,
|
||||||
|
[state.selectedLanguage]: lines,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_LINE": {
|
||||||
|
let lines = state.lyrics[state.selectedLanguage] ?? []
|
||||||
|
|
||||||
|
lines = lines.map((line) => {
|
||||||
|
if (line.time === action.payload.time) {
|
||||||
|
return action.payload
|
||||||
|
}
|
||||||
|
|
||||||
|
return line
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
lyrics: {
|
||||||
|
...state.lyrics,
|
||||||
|
[state.selectedLanguage]: lines,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "REMOVE_LINE": {
|
||||||
|
let lines = state.lyrics[state.selectedLanguage] ?? []
|
||||||
|
|
||||||
|
lines = lines.filter((line) => line.time !== action.payload.time)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
lyrics: {
|
||||||
|
...state.lyrics,
|
||||||
|
[state.selectedLanguage]: lines,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_SELECTED_LANGUAGE": {
|
||||||
|
return { ...state, selectedLanguage: action.payload }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_VIDEO_SOURCE": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoSource: action.payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_VIDEO_SYNC": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoSyncTime: action.payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_LOADING": {
|
||||||
|
return { ...state, loading: action.payload }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_SAVING": {
|
||||||
|
return { ...state, saving: action.payload }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "RESET_STATE": {
|
||||||
|
return { ...initialState }
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LyricsEditorProvider({ children }) {
|
||||||
|
const [state, dispatch] = React.useReducer(lyricsReducer, initialState)
|
||||||
|
|
||||||
|
const value = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
dispatch,
|
||||||
|
}),
|
||||||
|
[state],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LyricsEditorContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</LyricsEditorContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LyricsEditorProvider.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLyricsEditor() {
|
||||||
|
const context = React.useContext(LyricsEditorContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useLyricsEditor must be used within a LyricsEditorProvider",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LyricsEditorContext
|
@ -0,0 +1,251 @@
|
|||||||
|
import React, { useEffect, useCallback } from "react"
|
||||||
|
import shaka from "shaka-player/dist/shaka-player.compiled.js"
|
||||||
|
import { useLyricsEditor } from "../context/LyricsEditorContext"
|
||||||
|
|
||||||
|
export const useAudioPlayer = (src) => {
|
||||||
|
const { state, dispatch } = useLyricsEditor()
|
||||||
|
|
||||||
|
const audioRef = React.useRef(new Audio())
|
||||||
|
const playerRef = React.useRef(null)
|
||||||
|
const waitTimeoutRef = React.useRef(null)
|
||||||
|
const lastSeekTimeRef = React.useRef(0)
|
||||||
|
const scrubTimeoutRef = React.useRef(null)
|
||||||
|
|
||||||
|
const initializePlayer = useCallback(async () => {
|
||||||
|
if (!src) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
dispatch({ type: "SET_AUDIO_LOADING", payload: true })
|
||||||
|
dispatch({ type: "SET_AUDIO_ERROR", payload: null })
|
||||||
|
|
||||||
|
audioRef.current.loop = true
|
||||||
|
|
||||||
|
// Cleanup existing player
|
||||||
|
if (playerRef.current) {
|
||||||
|
await playerRef.current.destroy()
|
||||||
|
playerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check browser support
|
||||||
|
if (!shaka.Player.isBrowserSupported()) {
|
||||||
|
throw new Error("Browser does not support DASH playback")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new player
|
||||||
|
playerRef.current = new shaka.Player()
|
||||||
|
|
||||||
|
await playerRef.current.attach(audioRef.current)
|
||||||
|
|
||||||
|
// Setup DASH error handling
|
||||||
|
playerRef.current.addEventListener("error", (event) => {
|
||||||
|
const error = event.detail
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "SET_AUDIO_ERROR",
|
||||||
|
payload: `DASH Error: ${error.message || "Playback failed"}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load the source
|
||||||
|
await playerRef.current.load(src)
|
||||||
|
dispatch({ type: "SET_AUDIO_LOADING", payload: false })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Player initialization error:", error)
|
||||||
|
dispatch({ type: "SET_AUDIO_ERROR", payload: error.message })
|
||||||
|
dispatch({ type: "SET_AUDIO_LOADING", payload: false })
|
||||||
|
}
|
||||||
|
}, [src, dispatch])
|
||||||
|
|
||||||
|
// Audio controls
|
||||||
|
const play = useCallback(async () => {
|
||||||
|
if (!audioRef.current) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await audioRef.current.play()
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_AUDIO_ERROR",
|
||||||
|
payload:
|
||||||
|
error.name === "NotAllowedError"
|
||||||
|
? "Playback blocked. Please interact with the page first."
|
||||||
|
: "Failed to play audio",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [dispatch])
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (audioRef.current.paused) {
|
||||||
|
play()
|
||||||
|
} else {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}, [audioRef.current])
|
||||||
|
|
||||||
|
const seek = useCallback((time, scrub = false) => {
|
||||||
|
if (audioRef.current && audioRef.current.duration > 0) {
|
||||||
|
const clampedTime = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(time, audioRef.current.duration),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update currentTime immediately for responsive UI
|
||||||
|
audioRef.current.currentTime = clampedTime
|
||||||
|
|
||||||
|
if (audioRef.current.paused) {
|
||||||
|
if (scrub === true) {
|
||||||
|
// Clear any pending scrub preview
|
||||||
|
if (scrubTimeoutRef.current) {
|
||||||
|
clearTimeout(scrubTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrubDuration = 100
|
||||||
|
|
||||||
|
audioRef.current.play().then(() => {
|
||||||
|
scrubTimeoutRef.current = setTimeout(() => {
|
||||||
|
audioRef.current.pause()
|
||||||
|
audioRef.current.currentTime = clampedTime
|
||||||
|
}, scrubDuration)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
audioRef.current.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSpeed = useCallback(
|
||||||
|
(speed) => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
const clampedSpeed = Math.max(0.25, Math.min(4, speed))
|
||||||
|
audioRef.current.playbackRate = clampedSpeed
|
||||||
|
dispatch({ type: "SET_AUDIO_SPEED", payload: clampedSpeed })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setVolume = useCallback(
|
||||||
|
(volume) => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
const clampedVolume = Math.max(0, Math.min(1, volume))
|
||||||
|
audioRef.current.volume = clampedVolume
|
||||||
|
dispatch({ type: "SET_AUDIO_VOLUME", payload: clampedVolume })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize player when src changes
|
||||||
|
useEffect(() => {
|
||||||
|
initializePlayer()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (playerRef.current) {
|
||||||
|
playerRef.current.destroy().catch(console.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initializePlayer])
|
||||||
|
|
||||||
|
// Setup audio event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
|
||||||
|
if (!audio) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
dispatch({ type: "SET_AUDIO_PLAYING", payload: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
dispatch({ type: "SET_AUDIO_PLAYING", payload: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWaiting = () => {
|
||||||
|
if (waitTimeoutRef.current) {
|
||||||
|
clearTimeout(waitTimeoutRef.current)
|
||||||
|
waitTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
waitTimeoutRef.current = setTimeout(() => {
|
||||||
|
dispatch({ type: "SET_AUDIO_LOADING", payload: true })
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlaying = () => {
|
||||||
|
if (waitTimeoutRef.current) {
|
||||||
|
clearTimeout(waitTimeoutRef.current)
|
||||||
|
waitTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
waitTimeoutRef.current = setTimeout(() => {
|
||||||
|
dispatch({ type: "SET_AUDIO_LOADING", payload: false })
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
const error = audio.error
|
||||||
|
let errorMessage = "Audio playback error"
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
switch (error.code) {
|
||||||
|
case error.MEDIA_ERR_NETWORK:
|
||||||
|
errorMessage = "Network error loading audio"
|
||||||
|
break
|
||||||
|
case error.MEDIA_ERR_DECODE:
|
||||||
|
errorMessage = "Audio decoding error"
|
||||||
|
break
|
||||||
|
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||||
|
errorMessage = "Audio format not supported"
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
errorMessage = "Unknown audio error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({ type: "SET_AUDIO_ERROR", payload: errorMessage })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
audio.addEventListener("play", handlePlay)
|
||||||
|
audio.addEventListener("pause", handlePause)
|
||||||
|
audio.addEventListener("waiting", handleWaiting)
|
||||||
|
audio.addEventListener("playing", handlePlaying)
|
||||||
|
audio.addEventListener("error", handleError)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Remove event listeners
|
||||||
|
audio.removeEventListener("play", handlePlay)
|
||||||
|
audio.removeEventListener("pause", handlePause)
|
||||||
|
audio.removeEventListener("waiting", handleWaiting)
|
||||||
|
audio.removeEventListener("playing", handlePlaying)
|
||||||
|
audio.removeEventListener("error", handleError)
|
||||||
|
}
|
||||||
|
}, [dispatch])
|
||||||
|
|
||||||
|
return {
|
||||||
|
audio: audioRef,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
toggle,
|
||||||
|
seek,
|
||||||
|
setSpeed,
|
||||||
|
setVolume,
|
||||||
|
isPlaying: state.isPlaying,
|
||||||
|
playbackSpeed: state.playbackSpeed,
|
||||||
|
volume: state.volume,
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
error: state.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAudioPlayer
|
@ -0,0 +1,228 @@
|
|||||||
|
import React, { useEffect } from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import { Button, Segmented, Alert, Flex } from "antd"
|
||||||
|
import { SaveOutlined } from "@ant-design/icons"
|
||||||
|
|
||||||
|
import {
|
||||||
|
LyricsEditorProvider,
|
||||||
|
useLyricsEditor,
|
||||||
|
} from "./context/LyricsEditorContext"
|
||||||
|
|
||||||
|
import Skeleton from "@components/Skeleton"
|
||||||
|
|
||||||
|
import VideoEditor from "./tabs/videos"
|
||||||
|
import LyricsEditor from "./tabs/lyrics"
|
||||||
|
import InlinePlayer from "./components/InlinePlayer"
|
||||||
|
import MusicModel from "@models/music"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const EnhancedLyricsEditorContent = ({ trackId }) => {
|
||||||
|
const { state, dispatch } = useLyricsEditor()
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = React.useState("lyrics")
|
||||||
|
const playerRef = React.useRef(null)
|
||||||
|
|
||||||
|
const loadTrackData = async () => {
|
||||||
|
dispatch({ type: "SET_LOADING", payload: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const track = await MusicModel.getTrackData(trackId)
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
throw new Error("Track not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({ type: "SET_TRACK", payload: track })
|
||||||
|
|
||||||
|
const lyrics = await MusicModel.getTrackLyrics(trackId).catch(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
lrc: {
|
||||||
|
original: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dispatch({ type: "SET_LYRICS", payload: lyrics.lrc })
|
||||||
|
|
||||||
|
if (lyrics.video_source) {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_VIDEO_SOURCE",
|
||||||
|
payload: lyrics.video_source,
|
||||||
|
})
|
||||||
|
dispatch({
|
||||||
|
type: "SET_VIDEO_SYNC",
|
||||||
|
payload: lyrics.video_starts_at ?? lyrics.sync_audio_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load track:", error)
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_LOADING", payload: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saveData = {
|
||||||
|
video_source: state.videoSource || null,
|
||||||
|
video_starts_at: state.videoSyncTime || null,
|
||||||
|
lrc: state.lyrics,
|
||||||
|
}
|
||||||
|
|
||||||
|
await MusicModel.putTrackLyrics(trackId, saveData)
|
||||||
|
|
||||||
|
app.message.success("Changes saved successfully")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Save failed:", error)
|
||||||
|
app.message.error("Failed to save changes")
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboardEvents = {
|
||||||
|
Space: () => {
|
||||||
|
const { toggle } = playerRef.current
|
||||||
|
|
||||||
|
toggle()
|
||||||
|
},
|
||||||
|
ArrowLeft: (event) => {
|
||||||
|
const { seek, audio } = playerRef.current
|
||||||
|
|
||||||
|
if (event.ctrlKey) {
|
||||||
|
if (event.ctrlKey && event.shiftKey) {
|
||||||
|
seek(audio.current.currentTime - 0.001, true)
|
||||||
|
} else {
|
||||||
|
seek(audio.current.currentTime - 0.1, true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seek(audio.current.currentTime - 1, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ArrowRight: (event) => {
|
||||||
|
const { seek, audio } = playerRef.current
|
||||||
|
|
||||||
|
if (event.ctrlKey) {
|
||||||
|
if (event.ctrlKey && event.shiftKey) {
|
||||||
|
seek(audio.current.currentTime + 0.001, true)
|
||||||
|
} else {
|
||||||
|
seek(audio.current.currentTime + 0.1, true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seek(audio.current.currentTime + 1, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback((event) => {
|
||||||
|
// check the target is not a input element
|
||||||
|
if (
|
||||||
|
event.target.nodeName === "INPUT" ||
|
||||||
|
event.target.nodeName === "TEXTAREA" ||
|
||||||
|
event.target.nodeName === "SELECT" ||
|
||||||
|
event.target.nodeName === "OPTION" ||
|
||||||
|
event.target.nodeName === "BUTTON"
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyboardEvents[event.code]) {
|
||||||
|
keyboardEvents[event.code](event)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Loader effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (trackId) {
|
||||||
|
loadTrackData()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (state.loading || !state.track) {
|
||||||
|
return <Skeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="avlyrics-editor">
|
||||||
|
<Flex horizontal align="center" justify="space-between">
|
||||||
|
<h1>{state.track.title}</h1>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={state.saving}
|
||||||
|
//disabled={!state.isDirty}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Segmented
|
||||||
|
value={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
options={[
|
||||||
|
{ label: "Lyrics", value: "lyrics" },
|
||||||
|
{ label: "Video", value: "video" },
|
||||||
|
]}
|
||||||
|
style={{ marginBottom: "20px" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InlinePlayer ref={playerRef} src={state.track.source} />
|
||||||
|
|
||||||
|
{activeTab === "lyrics" && <LyricsEditor player={playerRef} />}
|
||||||
|
{activeTab === "video" && <VideoEditor />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
EnhancedLyricsEditorContent.propTypes = {
|
||||||
|
trackId: PropTypes.string.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnhancedLyricsEditor = ({ params }) => {
|
||||||
|
const trackId = params?.track_id
|
||||||
|
|
||||||
|
if (!trackId) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message="Invalid Track"
|
||||||
|
description="No track ID provided in the URL parameters"
|
||||||
|
type="error"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LyricsEditorProvider>
|
||||||
|
<EnhancedLyricsEditorContent trackId={trackId} />
|
||||||
|
</LyricsEditorProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
EnhancedLyricsEditor.options = {
|
||||||
|
layout: {
|
||||||
|
type: "default",
|
||||||
|
centeredContent: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
EnhancedLyricsEditor.propTypes = {
|
||||||
|
params: PropTypes.shape({
|
||||||
|
track_id: PropTypes.string.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EnhancedLyricsEditor
|
@ -0,0 +1,9 @@
|
|||||||
|
.avlyrics-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
|
||||||
|
gap: 20px;
|
||||||
|
}
|
@ -0,0 +1,489 @@
|
|||||||
|
import React from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import classnames from "classnames"
|
||||||
|
|
||||||
|
import { parseLRC } from "../../utils/lrcParser"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Select,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Popconfirm,
|
||||||
|
InputNumber,
|
||||||
|
Empty,
|
||||||
|
Flex,
|
||||||
|
Switch,
|
||||||
|
} from "antd"
|
||||||
|
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
} from "@ant-design/icons"
|
||||||
|
|
||||||
|
import { MdSpaceBar } from "react-icons/md"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
import { useLyricsEditor } from "../../context/LyricsEditorContext"
|
||||||
|
import { formatSecondsToLRC } from "../../utils/lrcParser"
|
||||||
|
|
||||||
|
import UploadButton from "@components/UploadButton"
|
||||||
|
import Languages from "@config/languages"
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
const { TextArea } = Input
|
||||||
|
|
||||||
|
const languageOptions = [
|
||||||
|
...Object.entries(Languages).map(([key, value]) => ({
|
||||||
|
label: value,
|
||||||
|
value: key,
|
||||||
|
})),
|
||||||
|
{ label: "Original", value: "original" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const Line = ({
|
||||||
|
line,
|
||||||
|
editData,
|
||||||
|
setEditData,
|
||||||
|
|
||||||
|
active,
|
||||||
|
|
||||||
|
handleSeek,
|
||||||
|
handleDeleteLine,
|
||||||
|
handleEditLineSave,
|
||||||
|
handleEditLineCancel,
|
||||||
|
handleEditLineStart,
|
||||||
|
handleClickDuplicate,
|
||||||
|
handleEditLineSetAsBreak,
|
||||||
|
}) => {
|
||||||
|
const editMode = editData && editData.time === line.time
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
return (
|
||||||
|
<List.Item>
|
||||||
|
<div style={{ width: "100%" }}>
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
value={editData.text}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditData({
|
||||||
|
...editData,
|
||||||
|
text: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
autoSize={{
|
||||||
|
minRows: 1,
|
||||||
|
maxRows: 3,
|
||||||
|
}}
|
||||||
|
style={{ resize: "none" }}
|
||||||
|
/>
|
||||||
|
<Row gutter={8} align="middle">
|
||||||
|
<Col span={6}>
|
||||||
|
<InputNumber
|
||||||
|
value={editData.time}
|
||||||
|
onChange={(value) =>
|
||||||
|
setEditData({
|
||||||
|
...editData,
|
||||||
|
time: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
step={0.1}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
placeholder="Time (s)"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={18}>
|
||||||
|
<Space size="small">
|
||||||
|
<Switch
|
||||||
|
defaultChecked={editData.break}
|
||||||
|
onChange={(checked) => {
|
||||||
|
setEditData({
|
||||||
|
...editData,
|
||||||
|
break: checked,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
label="Break"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleEditLineSave}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={handleEditLineCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classnames("avlyrics-editor-list-item", {
|
||||||
|
active: active,
|
||||||
|
})}
|
||||||
|
id={`t${parseInt(line.time * 1000)}`}
|
||||||
|
>
|
||||||
|
<Row
|
||||||
|
justify="space-between"
|
||||||
|
align="middle"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Col flex="80px">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
handleSeek(line.time)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
height: "auto",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatSecondsToLRC(line.time)}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col
|
||||||
|
flex="1"
|
||||||
|
style={{
|
||||||
|
marginLeft: 16,
|
||||||
|
marginRight: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line.break && "<break>"}
|
||||||
|
{!line.break && line.text}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
<Col flex="80px" style={{ textAlign: "right" }}>
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleClickDuplicate(line)}
|
||||||
|
>
|
||||||
|
D
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEditLineStart(line)}
|
||||||
|
style={{ padding: "4px" }}
|
||||||
|
/>
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete this line?"
|
||||||
|
onConfirm={() => handleDeleteLine(line)}
|
||||||
|
okText="Delete"
|
||||||
|
cancelText="Cancel"
|
||||||
|
placement="topRight"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
danger
|
||||||
|
style={{
|
||||||
|
padding: "4px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LyricsEditor = ({ player }) => {
|
||||||
|
const { state, dispatch } = useLyricsEditor()
|
||||||
|
|
||||||
|
const newLineTextRef = React.useRef(null)
|
||||||
|
const linesListRef = React.useRef(null)
|
||||||
|
|
||||||
|
// ticker
|
||||||
|
const tickerRef = React.useRef(null)
|
||||||
|
const [followTime, setFollowTime] = React.useState(true)
|
||||||
|
const [lineIndex, setLineIndex] = React.useState(null)
|
||||||
|
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = React.useState("original")
|
||||||
|
const [newLineText, setNewLineText] = React.useState("")
|
||||||
|
|
||||||
|
const [editData, setEditData] = React.useState(null)
|
||||||
|
|
||||||
|
const lines = state.lyrics[state.selectedLanguage] ?? []
|
||||||
|
|
||||||
|
const scrollToTime = React.useCallback((time) => {
|
||||||
|
const lineSelector = `#t${parseInt(time * 1000)}`
|
||||||
|
|
||||||
|
const lineElement = linesListRef.current.querySelector(lineSelector)
|
||||||
|
|
||||||
|
if (lineElement) {
|
||||||
|
lineElement.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleAddLine = () => {
|
||||||
|
if (!newLineText.trim()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = player.current.audio.current.currentTime
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_LINE",
|
||||||
|
payload: {
|
||||||
|
text: newLineText.trim(),
|
||||||
|
time: time,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setNewLineText("")
|
||||||
|
scrollToTime(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditLineStart = (line) => {
|
||||||
|
setEditData({
|
||||||
|
text: line.text,
|
||||||
|
time: line.time || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditLineSave = () => {
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_LINE",
|
||||||
|
payload: editData,
|
||||||
|
})
|
||||||
|
|
||||||
|
setEditData(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditLineCancel = () => {
|
||||||
|
setEditData(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteLine = (line) => {
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_LINE",
|
||||||
|
payload: line,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddLineBreak = () => {
|
||||||
|
const time = player.current.audio.current.currentTime
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_LINE",
|
||||||
|
payload: {
|
||||||
|
break: true,
|
||||||
|
time: time,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
scrollToTime(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClickDuplicate = (line) => {
|
||||||
|
const nextTime = line.time + 0.4
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_LINE",
|
||||||
|
payload: {
|
||||||
|
text: line.text,
|
||||||
|
time: nextTime,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSeek = (time) => {
|
||||||
|
// TODO: call to player seek function
|
||||||
|
player.current.seek(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLanguageUpload = async (url) => {
|
||||||
|
const data = await fetch(url)
|
||||||
|
let text = await data.text()
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "OVERRIDE_LINES",
|
||||||
|
payload: parseLRC(text),
|
||||||
|
})
|
||||||
|
|
||||||
|
app.message.success("Language file loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
const followLineTick = () => {
|
||||||
|
const currentTime = player.current.audio.current.currentTime
|
||||||
|
|
||||||
|
const lineIndex = lines.findLastIndex((line) => {
|
||||||
|
return currentTime >= line.time
|
||||||
|
})
|
||||||
|
|
||||||
|
if (lineIndex <= -1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setLineIndex(lineIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (state.isPlaying) {
|
||||||
|
if (tickerRef.current) {
|
||||||
|
clearInterval(tickerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
tickerRef.current = setInterval(followLineTick, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(tickerRef.current)
|
||||||
|
}
|
||||||
|
}, [followTime, state.isPlaying])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (followTime === true && lineIndex !== -1) {
|
||||||
|
const line = lines[lineIndex]
|
||||||
|
|
||||||
|
if (line) {
|
||||||
|
scrollToTime(line.time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [lineIndex])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||||
|
<Row gutter={16} align="middle">
|
||||||
|
<Col span={6}>
|
||||||
|
<Select
|
||||||
|
value={selectedLanguage}
|
||||||
|
onChange={setSelectedLanguage}
|
||||||
|
options={languageOptions}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
placeholder="Select language"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={18}>
|
||||||
|
<UploadButton
|
||||||
|
onSuccess={(_, data) => handleLanguageUpload(data.url)}
|
||||||
|
accept={["text/*"]}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Load file
|
||||||
|
</UploadButton>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Flex horizontal align="center" gap={8}>
|
||||||
|
<TextArea
|
||||||
|
ref={newLineTextRef}
|
||||||
|
value={newLineText}
|
||||||
|
onChange={(e) => setNewLineText(e.target.value)}
|
||||||
|
placeholder="Enter text and press Enter to add to current time"
|
||||||
|
autoSize={{ minRows: 1, maxRows: 3 }}
|
||||||
|
onPressEnter={(e) => {
|
||||||
|
if (!e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
newLineTextRef.current.blur()
|
||||||
|
handleAddLine()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ resize: "none" }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddLine}
|
||||||
|
disabled={!newLineText.trim()}
|
||||||
|
style={{ width: "fit-content", minWidth: "30px" }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<MdSpaceBar />}
|
||||||
|
onClick={handleAddLineBreak}
|
||||||
|
style={{ width: "fit-content", minWidth: "30px" }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{state.lyrics.length === 0 && (
|
||||||
|
<Empty
|
||||||
|
description="No lyrics available"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
>
|
||||||
|
<Text type="secondary">
|
||||||
|
Add lyrics manually or upload an LRC file
|
||||||
|
</Text>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row justify="space-between" align="middle">
|
||||||
|
<Text type="secondary" style={{ fontSize: "12px" }}>
|
||||||
|
{lines.length} lines
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div className="avlyrics-editor-list" ref={linesListRef}>
|
||||||
|
{lines.map((line, index) => {
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={index}
|
||||||
|
line={line}
|
||||||
|
active={index === lineIndex && followTime}
|
||||||
|
setEditData={setEditData}
|
||||||
|
editData={editData}
|
||||||
|
handleSeek={handleSeek}
|
||||||
|
handleDeleteLine={handleDeleteLine}
|
||||||
|
handleEditLineStart={handleEditLineStart}
|
||||||
|
handleEditLineSave={handleEditLineSave}
|
||||||
|
handleEditLineCancel={handleEditLineCancel}
|
||||||
|
handleClickDuplicate={handleClickDuplicate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LyricsEditor.propTypes = {
|
||||||
|
lyrics: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LyricsEditor
|
@ -0,0 +1,61 @@
|
|||||||
|
.avlyrics-editor-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
height: 500px;
|
||||||
|
|
||||||
|
overflow: overlay;
|
||||||
|
|
||||||
|
background-color: var(--background-color-accent);
|
||||||
|
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avlyrics-editor-list-item {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
padding: 15px 10px;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
&:before {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
content: "";
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
margin: auto;
|
||||||
|
margin-left: 3px;
|
||||||
|
|
||||||
|
width: 5px;
|
||||||
|
height: 70%;
|
||||||
|
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: var(--colorPrimary);
|
||||||
|
|
||||||
|
//animation: active-line-indicator-enter 150ms ease-in-out linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes active-line-indicator-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,189 @@
|
|||||||
|
import React, { useState, useCallback } from "react"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Input,
|
||||||
|
TimePicker,
|
||||||
|
Space,
|
||||||
|
Button,
|
||||||
|
Empty,
|
||||||
|
Switch,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from "antd"
|
||||||
|
import {
|
||||||
|
VideoCameraOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
} from "@ant-design/icons"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import customParseFormat from "dayjs/plugin/customParseFormat"
|
||||||
|
|
||||||
|
import { useLyricsEditor } from "../../context/LyricsEditorContext"
|
||||||
|
import UploadButton from "@components/UploadButton"
|
||||||
|
import VideoPlayer from "@components/VideoPlayer"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
dayjs.extend(customParseFormat)
|
||||||
|
|
||||||
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
|
const VideoEditor = () => {
|
||||||
|
const { state, dispatch } = useLyricsEditor()
|
||||||
|
const [inputUrl, setInputUrl] = useState(state.videoSource || "")
|
||||||
|
|
||||||
|
const handleVideoUpload = useCallback(
|
||||||
|
(response) => {
|
||||||
|
const url = response.url
|
||||||
|
dispatch({ type: "SET_VIDEO_SOURCE", payload: url })
|
||||||
|
setInputUrl(url)
|
||||||
|
message.success("Video uploaded successfully")
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleUrlChange = useCallback((e) => {
|
||||||
|
const url = e.target.value
|
||||||
|
setInputUrl(url)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleUrlSet = useCallback(() => {
|
||||||
|
if (inputUrl !== state.videoSource) {
|
||||||
|
dispatch({ type: "SET_VIDEO_SOURCE", payload: inputUrl })
|
||||||
|
message.success("Video URL updated")
|
||||||
|
}
|
||||||
|
}, [inputUrl, state.videoSource, dispatch])
|
||||||
|
|
||||||
|
const handleSyncTimeChange = useCallback(
|
||||||
|
(time, timeString) => {
|
||||||
|
console.log("changed:", time, timeString)
|
||||||
|
dispatch({ type: "SET_VIDEO_SYNC", payload: timeString })
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleLoopingChange = useCallback((checked) => {
|
||||||
|
// Note: looping is not in simplified context, could be local state if needed
|
||||||
|
console.log("Looping changed:", checked)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const videoControls = [
|
||||||
|
"play",
|
||||||
|
"current-time",
|
||||||
|
"seek-time",
|
||||||
|
"duration",
|
||||||
|
"progress",
|
||||||
|
"settings",
|
||||||
|
]
|
||||||
|
|
||||||
|
const syncTime = state.videoSyncTime
|
||||||
|
? dayjs(state.videoSyncTime, "mm:ss:SSS")
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="video-editor"
|
||||||
|
title={
|
||||||
|
<Title
|
||||||
|
level={3}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VideoCameraOutlined />
|
||||||
|
Video Editor
|
||||||
|
</Title>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{state.videoSource ? (
|
||||||
|
<div className="video-preview">
|
||||||
|
<VideoPlayer
|
||||||
|
controls={videoControls}
|
||||||
|
src={state.videoSource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={
|
||||||
|
<VideoCameraOutlined
|
||||||
|
style={{ fontSize: 64, color: "#d9d9d9" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
description="No video loaded"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }} size="large">
|
||||||
|
<div className="sync-controls">
|
||||||
|
<Space align="center" wrap>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
<Text strong>Video sync time:</Text>
|
||||||
|
<Text code>{state.videoSyncTime || "not set"}</Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space align="center" wrap>
|
||||||
|
<Text>Set sync point:</Text>
|
||||||
|
<TimePicker
|
||||||
|
showNow={false}
|
||||||
|
value={syncTime}
|
||||||
|
format="mm:ss:SSS"
|
||||||
|
onChange={handleSyncTimeChange}
|
||||||
|
placeholder="mm:ss:SSS"
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={false}
|
||||||
|
onChange={handleLoopingChange}
|
||||||
|
checkedChildren="Loop"
|
||||||
|
unCheckedChildren="Once"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="upload-controls">
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
<Space wrap>
|
||||||
|
<UploadButton
|
||||||
|
onSuccess={(_, data) =>
|
||||||
|
handleVideoUpload(data.url)
|
||||||
|
}
|
||||||
|
accept={["video/*"]}
|
||||||
|
headers={{ transformations: "mq-hls" }}
|
||||||
|
disabled={state.saving}
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
>
|
||||||
|
Upload Video
|
||||||
|
</UploadButton>
|
||||||
|
<Text type="secondary">or</Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space.Compact style={{ width: "100%" }}>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter video HLS URL..."
|
||||||
|
value={inputUrl}
|
||||||
|
onChange={handleUrlChange}
|
||||||
|
disabled={state.saving}
|
||||||
|
onPressEnter={handleUrlSet}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleUrlSet}
|
||||||
|
disabled={
|
||||||
|
!inputUrl ||
|
||||||
|
inputUrl === state.videoSource ||
|
||||||
|
state.saving
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Set URL
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VideoEditor
|
@ -0,0 +1,203 @@
|
|||||||
|
.video-editor {
|
||||||
|
.ant-card-head {
|
||||||
|
border-bottom: 1px solid var(--border-color-light);
|
||||||
|
|
||||||
|
.ant-card-head-title {
|
||||||
|
padding: 16px 0;
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
.anticon {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 350px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--background-color-dark);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border: 1px solid var(--border-color-light);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-empty {
|
||||||
|
padding: 60px 20px;
|
||||||
|
background: var(--background-color-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px dashed var(--border-color-light);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.ant-empty-description {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-controls {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--background-color-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color-light);
|
||||||
|
|
||||||
|
.ant-space {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-picker {
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-switch {
|
||||||
|
&.ant-switch-checked {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
&.ant-typography code {
|
||||||
|
background: var(--background-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-controls {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--background-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color-light);
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--primary-color-hover);
|
||||||
|
border-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input {
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-group-compact {
|
||||||
|
.ant-btn {
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark theme support
|
||||||
|
.dark & {
|
||||||
|
.video-preview {
|
||||||
|
background: var(--background-color-darker);
|
||||||
|
border-color: var(--border-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-controls,
|
||||||
|
.upload-controls {
|
||||||
|
background: var(--background-color-darker);
|
||||||
|
border-color: var(--border-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-empty {
|
||||||
|
background: var(--background-color-darker);
|
||||||
|
border-color: var(--border-color-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile responsiveness
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-controls,
|
||||||
|
.upload-controls {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-controls {
|
||||||
|
.ant-space-compact {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.ant-input,
|
||||||
|
.ant-btn {
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// High contrast mode
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
.video-preview,
|
||||||
|
.sync-controls,
|
||||||
|
.upload-controls {
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduced motion
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ant-btn:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
export default (seconds) => {
|
||||||
|
if (!seconds || isNaN(seconds)) {
|
||||||
|
return "00:00.000"
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
const ms = Math.floor((seconds % 1) * 1000)
|
||||||
|
|
||||||
|
return `${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${ms.toString().padStart(3, "0")}`
|
||||||
|
}
|
@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* LRC Parser Utility
|
||||||
|
* Handles parsing and formatting of LRC (Lyric) files
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse time string in format MM:SS.SSS or MM:SS to seconds
|
||||||
|
* @param {string} timeStr - Time string like "01:23.45"
|
||||||
|
* @returns {number} Time in seconds
|
||||||
|
*/
|
||||||
|
export const parseTimeToSeconds = (timeStr) => {
|
||||||
|
const match = timeStr.match(/^(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?$/)
|
||||||
|
if (!match) return 0
|
||||||
|
|
||||||
|
const minutes = parseInt(match[1], 10)
|
||||||
|
const seconds = parseInt(match[2], 10)
|
||||||
|
const milliseconds = match[3] ? parseInt(match[3].padEnd(3, "0"), 10) : 0
|
||||||
|
|
||||||
|
return minutes * 60 + seconds + milliseconds / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert seconds to LRC time format MM:SS.SSS
|
||||||
|
* @param {number} seconds - Time in seconds
|
||||||
|
* @returns {string} Formatted time string
|
||||||
|
*/
|
||||||
|
export const formatSecondsToLRC = (seconds) => {
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
const ms = Math.floor((seconds % 1) * 1000)
|
||||||
|
|
||||||
|
return `${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${ms.toString().padStart(3, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse LRC content into structured data
|
||||||
|
* @param {string} lrcContent - Raw LRC file content
|
||||||
|
* @returns {Object} Parsed LRC data
|
||||||
|
*/
|
||||||
|
export const parseLRC = (lrcContent) => {
|
||||||
|
if (!lrcContent || typeof lrcContent !== "string") {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = lrcContent
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const lyrics = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Check for timestamped lyrics [MM:SS.SSS]text
|
||||||
|
const timestampMatch = line.match(
|
||||||
|
/^\[(\d{1,2}:\d{2}(?:\.\d{1,3})?)\](.*)$/,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (timestampMatch) {
|
||||||
|
const [, timeStr, text] = timestampMatch
|
||||||
|
const time = parseTimeToSeconds(timeStr)
|
||||||
|
|
||||||
|
if (text.trim() === "") {
|
||||||
|
lyrics.push({
|
||||||
|
time: time,
|
||||||
|
break: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics.push({
|
||||||
|
time: time,
|
||||||
|
text: text.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort lyrics by timestamp
|
||||||
|
lyrics.sort((a, b) => {
|
||||||
|
if (a.time === null) return -1
|
||||||
|
if (b.time === null) return 1
|
||||||
|
return a.time - b.time
|
||||||
|
})
|
||||||
|
|
||||||
|
return lyrics
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert structured lyrics data back to LRC format
|
||||||
|
* @param {Object} lrcData - Structured LRC data
|
||||||
|
* @returns {string} LRC formatted string
|
||||||
|
*/
|
||||||
|
export const formatToLRC = (lrcData) => {
|
||||||
|
const { metadata = {}, lyrics = [] } = lrcData
|
||||||
|
const lines = []
|
||||||
|
|
||||||
|
// Add metadata
|
||||||
|
const metadataMapping = {
|
||||||
|
artist: "ar",
|
||||||
|
title: "ti",
|
||||||
|
album: "al",
|
||||||
|
author: "au",
|
||||||
|
length: "length",
|
||||||
|
creator: "by",
|
||||||
|
editor: "re",
|
||||||
|
version: "ve",
|
||||||
|
offset: "offset",
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(metadata).forEach(([key, value]) => {
|
||||||
|
const tag = metadataMapping[key] || key
|
||||||
|
lines.push(`[${tag}:${value}]`)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (lines.length > 0) {
|
||||||
|
lines.push("") // Empty line after metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add lyrics
|
||||||
|
lyrics.forEach((lyric) => {
|
||||||
|
if (lyric.time !== null) {
|
||||||
|
const timeStr = lyric.timeStr || formatSecondsToLRC(lyric.time)
|
||||||
|
lines.push(`[${timeStr}]${lyric.text}`)
|
||||||
|
} else {
|
||||||
|
lines.push(lyric.text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the current lyric line based on current time
|
||||||
|
* @param {Array} lyrics - Array of lyric objects
|
||||||
|
* @param {number} currentTime - Current playback time in seconds
|
||||||
|
* @returns {Object|null} Current lyric object
|
||||||
|
*/
|
||||||
|
export const getCurrentLyric = (lyrics, currentTime) => {
|
||||||
|
if (!lyrics || lyrics.length === 0) return null
|
||||||
|
|
||||||
|
// Filter out lyrics without timestamps
|
||||||
|
const timedLyrics = lyrics.filter((lyric) => lyric.time !== null)
|
||||||
|
|
||||||
|
if (timedLyrics.length === 0) return null
|
||||||
|
|
||||||
|
// Find the last lyric that has passed
|
||||||
|
let currentLyric = null
|
||||||
|
for (let i = 0; i < timedLyrics.length; i++) {
|
||||||
|
if (timedLyrics[i].time <= currentTime) {
|
||||||
|
currentLyric = timedLyrics[i]
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentLyric
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get next lyric line
|
||||||
|
* @param {Array} lyrics - Array of lyric objects
|
||||||
|
* @param {number} currentTime - Current playback time in seconds
|
||||||
|
* @returns {Object|null} Next lyric object
|
||||||
|
*/
|
||||||
|
export const getNextLyric = (lyrics, currentTime) => {
|
||||||
|
if (!lyrics || lyrics.length === 0) return null
|
||||||
|
|
||||||
|
const timedLyrics = lyrics.filter((lyric) => lyric.time !== null)
|
||||||
|
|
||||||
|
for (const lyric of timedLyrics) {
|
||||||
|
if (lyric.time > currentTime) {
|
||||||
|
return lyric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a new lyric at specific time
|
||||||
|
* @param {Array} lyrics - Current lyrics array
|
||||||
|
* @param {number} time - Time in seconds
|
||||||
|
* @param {string} text - Lyric text
|
||||||
|
* @returns {Array} Updated lyrics array
|
||||||
|
*/
|
||||||
|
export const insertLyric = (lyrics, time, text) => {
|
||||||
|
const newLyric = {
|
||||||
|
time,
|
||||||
|
timeStr: formatSecondsToLRC(time),
|
||||||
|
text,
|
||||||
|
id: `${time}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedLyrics = [...lyrics, newLyric]
|
||||||
|
|
||||||
|
// Sort by time
|
||||||
|
return updatedLyrics.sort((a, b) => {
|
||||||
|
if (a.time === null) return -1
|
||||||
|
if (b.time === null) return 1
|
||||||
|
return a.time - b.time
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing lyric
|
||||||
|
* @param {Array} lyrics - Current lyrics array
|
||||||
|
* @param {string} id - Lyric ID to update
|
||||||
|
* @param {Object} updates - Updates to apply
|
||||||
|
* @returns {Array} Updated lyrics array
|
||||||
|
*/
|
||||||
|
export const updateLyric = (lyrics, id, updates) => {
|
||||||
|
return lyrics.map((lyric) => {
|
||||||
|
if (lyric.id === id) {
|
||||||
|
const updated = { ...lyric, ...updates }
|
||||||
|
// Update timeStr if time was changed
|
||||||
|
if (updates.time !== undefined && updates.time !== null) {
|
||||||
|
updated.timeStr = formatSecondsToLRC(updates.time)
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
return lyric
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove lyric by ID
|
||||||
|
* @param {Array} lyrics - Current lyrics array
|
||||||
|
* @param {string} id - Lyric ID to remove
|
||||||
|
* @returns {Array} Updated lyrics array
|
||||||
|
*/
|
||||||
|
export const removeLyric = (lyrics, id) => {
|
||||||
|
return lyrics.filter((lyric) => lyric.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate LRC format
|
||||||
|
* @param {string} lrcContent - LRC content to validate
|
||||||
|
* @returns {Object} Validation result
|
||||||
|
*/
|
||||||
|
export const validateLRC = (lrcContent) => {
|
||||||
|
const errors = []
|
||||||
|
const warnings = []
|
||||||
|
|
||||||
|
if (!lrcContent || typeof lrcContent !== "string") {
|
||||||
|
errors.push("Invalid LRC content")
|
||||||
|
return { isValid: false, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = lrcContent.split("\n")
|
||||||
|
let hasTimestamps = false
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
|
||||||
|
// Check metadata format
|
||||||
|
const metadataMatch = trimmed.match(/^\[([a-z]+):(.+)\]$/i)
|
||||||
|
if (metadataMatch) return
|
||||||
|
|
||||||
|
// Check timestamp format
|
||||||
|
const timestampMatch = trimmed.match(
|
||||||
|
/^\[(\d{1,2}:\d{2}(?:\.\d{1,3})?)\](.*)$/,
|
||||||
|
)
|
||||||
|
if (timestampMatch) {
|
||||||
|
hasTimestamps = true
|
||||||
|
const [, timeStr] = timestampMatch
|
||||||
|
const time = parseTimeToSeconds(timeStr)
|
||||||
|
if (time < 0) {
|
||||||
|
errors.push(
|
||||||
|
`Invalid timestamp at line ${index + 1}: ${timeStr}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for malformed brackets
|
||||||
|
if (trimmed.includes("[") || trimmed.includes("]")) {
|
||||||
|
warnings.push(
|
||||||
|
`Possible malformed tag at line ${index + 1}: ${trimmed}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hasTimestamps) {
|
||||||
|
warnings.push("No timestamps found in LRC content")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user