Improvee music studio

This commit is contained in:
SrGooglo 2025-06-16 20:44:40 +00:00
parent 22c6279798
commit 8b9afae7eb
56 changed files with 4077 additions and 2094 deletions

View File

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

View File

@ -1,11 +0,0 @@
.lyrics-editor {
display: flex;
flex-direction: column;
gap: 20px;
padding: 15px;
border-radius: 12px;
background-color: var(--background-color-accent);
}

View File

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

View File

@ -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;
}
}

View File

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

View File

@ -1,6 +0,0 @@
.enhanced_lyrics_editor-wrapper {
display: flex;
flex-direction: column;
gap: 20px;
}

View File

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

View File

@ -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;
}
}

View File

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

View File

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

View File

@ -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;
}
}
}
}
}

View File

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

View File

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

View File

@ -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;
}
}
}
}

View File

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

View File

@ -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;
}

View File

@ -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>
)
}

View File

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

View File

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

View File

@ -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%;
}
}
}

View File

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

View File

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

View File

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

View 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

View 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

View File

@ -3,20 +3,13 @@ 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 = (props) => {
return <div
className="music-studio-page"
>
const MusicStudioPage = () => {
return (
<div className="music-studio-page">
<div className="music-studio-page-header">
<h1>Music Studio</h1>
@ -24,17 +17,16 @@ const MusicStudioPage = (props) => {
type="primary"
icon={<Icons.FiPlusCircle />}
onClick={() => {
app.location.push("/studio/music/new")
app.location.push("/studio/music/release/new")
}}
>
New Release
</antd.Button>
</div>
<ReleasesAnalytics />
<MyReleasesList />
</div>
)
}
export default MusicStudioPage

View File

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

View File

@ -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;
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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%;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
.avlyrics-editor {
display: flex;
flex-direction: column;
width: 100%;
max-width: 800px;
gap: 20px;
}

View File

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

View File

@ -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;
}
}

View File

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

View File

@ -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;
}
}
}

View File

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

View File

@ -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,
}
}