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 MyReleasesList from "@components/MusicStudio/MyReleasesList"
|
||||
import MyReleasesList from "./components/MyReleasesList"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const ReleasesAnalytics = () => {
|
||||
return <div>
|
||||
<h1>Analytics</h1>
|
||||
</div>
|
||||
}
|
||||
const MusicStudioPage = () => {
|
||||
return (
|
||||
<div className="music-studio-page">
|
||||
<div className="music-studio-page-header">
|
||||
<h1>Music Studio</h1>
|
||||
|
||||
const MusicStudioPage = (props) => {
|
||||
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/release/new")
|
||||
}}
|
||||
>
|
||||
New Release
|
||||
</antd.Button>
|
||||
</div>
|
||||
|
||||
<antd.Button
|
||||
type="primary"
|
||||
icon={<Icons.FiPlusCircle />}
|
||||
onClick={() => {
|
||||
app.location.push("/studio/music/new")
|
||||
}}
|
||||
>
|
||||
New Release
|
||||
</antd.Button>
|
||||
</div>
|
||||
|
||||
<ReleasesAnalytics />
|
||||
|
||||
<MyReleasesList />
|
||||
</div>
|
||||
<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