diff --git a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/LyricsEditor/index.jsx b/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/LyricsEditor/index.jsx
deleted file mode 100644
index b4d00173..00000000
--- a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/LyricsEditor/index.jsx
+++ /dev/null
@@ -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 (
-
-
-
-
- Lyrics
-
-
-
-
Language:
-
-
- (option?.label.toLowerCase() ?? "").includes(
- input.toLowerCase(),
- )
- }
- filterSort={(optionA, optionB) =>
- (optionA?.label.toLowerCase() ?? "")
- .toLowerCase()
- .localeCompare(
- (
- optionB?.label.toLowerCase() ?? ""
- ).toLowerCase(),
- )
- }
- onChange={setSelectedLang}
- />
-
- {selectedLang && (
- {
- updateCurrentLang(data.url)
- }}
- accept={["text/*"]}
- />
- )}
-
-
-
- {!langs[selectedLang] && (
-
No lyrics uploaded for this language
- )}
-
- {langs[selectedLang] && (
-
- )}
-
- )
-}
-
-export default LyricsEditor
diff --git a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/LyricsEditor/index.less b/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/LyricsEditor/index.less
deleted file mode 100644
index be94fc7b..00000000
--- a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/LyricsEditor/index.less
+++ /dev/null
@@ -1,11 +0,0 @@
-.lyrics-editor {
- display: flex;
- flex-direction: column;
-
- gap: 20px;
- padding: 15px;
-
- border-radius: 12px;
-
- background-color: var(--background-color-accent);
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/VideoEditor/index.jsx b/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/VideoEditor/index.jsx
deleted file mode 100644
index 52020552..00000000
--- a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/VideoEditor/index.jsx
+++ /dev/null
@@ -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 (
-
-
-
- Video
-
-
- {!props.videoSourceURL && (
-
}
- description="No video"
- />
- )}
-
- {props.videoSourceURL && (
-
-
-
- )}
-
-
-
-
-
- Start video sync at
-
-
- {props.startSyncAt ?? "not set"}
-
-
-
-
Set to:
-
-
{
- handleChange("startSyncAt", str)
- }}
- />
-
-
-
-
-
{
- handleChange("videoSourceURL", response.url)
- }}
- accept={["video/*"]}
- headers={{
- transformations: "mq-hls",
- }}
- disabled={props.loading}
- >
- Upload video
-
- or
-
{
- handleChange("videoSourceURL", e.target.value)
- }}
- value={props.videoSourceURL}
- disabled={props.loading}
- />
-
-
- )
-}
-
-export default VideoEditor
diff --git a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/VideoEditor/index.less b/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/VideoEditor/index.less
deleted file mode 100644
index 6def957e..00000000
--- a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/VideoEditor/index.less
+++ /dev/null
@@ -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;
- }
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/index.jsx b/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/index.jsx
deleted file mode 100644
index 39f7fb0f..00000000
--- a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/index.jsx
+++ /dev/null
@@ -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
- }
-
- return
-
{this.props.track.title}
-
- {
- this.setState({
- videoOptions: {
- ...this.state.videoOptions,
- [key]: value
- }
- })
- }}
- />
-
- {
- this.setState({
- lyricsOptions: {
- ...this.state.lyricsOptions,
- [key]: value
- }
- })
- }}
- />
-
- }
-}
-
-export default EnhancedLyricsEditor
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/index.less b/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/index.less
deleted file mode 100644
index 84319bb9..00000000
--- a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/index.less
+++ /dev/null
@@ -1,6 +0,0 @@
-.enhanced_lyrics_editor-wrapper {
- display: flex;
- flex-direction: column;
-
- gap: 20px;
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/LyricsTextView/index.jsx b/packages/app/src/components/MusicStudio/LyricsTextView/index.jsx
deleted file mode 100644
index b30828b7..00000000
--- a/packages/app/src/components/MusicStudio/LyricsTextView/index.jsx
+++ /dev/null
@@ -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
- }
-
- if (loading) {
- return
- }
-
- if (!lyrics) {
- return No lyrics provided
- }
-
- return
- {
- lyrics?.map((line, index) => {
- return
- {line}
-
- })
- }
-
-}
-
-export default LyricsTextView
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/LyricsTextView/index.less b/packages/app/src/components/MusicStudio/LyricsTextView/index.less
deleted file mode 100644
index 9abe5f1b..00000000
--- a/packages/app/src/components/MusicStudio/LyricsTextView/index.less
+++ /dev/null
@@ -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;
- }
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/MyReleasesList/index.jsx b/packages/app/src/components/MusicStudio/MyReleasesList/index.jsx
deleted file mode 100644
index 973a515b..00000000
--- a/packages/app/src/components/MusicStudio/MyReleasesList/index.jsx
+++ /dev/null
@@ -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
-
-
Your Releases
-
-
- {
- L_MyReleases && !E_MyReleases &&
- }
- {
- E_MyReleases &&
- }
- {
- !L_MyReleases && !E_MyReleases && R_MyReleases && R_MyReleases.items.length === 0 &&
- }
-
- {
- !L_MyReleases && !E_MyReleases && R_MyReleases && R_MyReleases.items.length > 0 &&
- {
- R_MyReleases.items.map((item) => {
- return
- })
- }
-
- }
-
-}
-
-export default MyReleasesList
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx
deleted file mode 100644
index ae6e7576..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx
+++ /dev/null
@@ -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 (
-
- )
- }
-
- if (loading) {
- return
- }
-
- const Tab = Tabs.find(({ key }) => key === selectedTab)
-
- const CustomPageProps = {
- close: () => {
- renderCustomPage(null, null)
- },
- }
-
- return (
-
-
- {customPage && (
-
- {customPage.header && (
-
-
-
}
- onClick={() =>
- renderCustomPage(null, null)
- }
- />
-
-
{customPage.header}
-
-
- {Array.isArray(customPageActions) &&
- customPageActions.map((action, index) => {
- return (
-
{
- if (
- typeof action.onClick ===
- "function"
- ) {
- await action.onClick()
- }
-
- if (action.fireEvent) {
- app.eventBus.emit(
- action.fireEvent,
- )
- }
- }}
- disabled={action.disabled}
- >
- {action.label}
-
- )
- })}
-
- )}
-
- {customPage.content &&
- (React.isValidElement(customPage.content)
- ? React.cloneElement(customPage.content, {
- ...CustomPageProps,
- ...customPage.props,
- })
- : React.createElement(customPage.content, {
- ...CustomPageProps,
- ...customPage.props,
- }))}
-
- )}
- {!customPage && (
- <>
-
-
setSelectedTab(e.key)}
- selectedKeys={[selectedTab]}
- items={Tabs}
- mode="vertical"
- />
-
-
-
- ) : (
-
- )
- }
- disabled={
- submitting || loading || !canFinish()
- }
- loading={submitting}
- >
- {release_id !== "new" ? "Save" : "Release"}
-
-
- {release_id !== "new" ? (
-
}
- disabled={loading}
- onClick={handleDelete}
- >
- Delete
-
- ) : null}
-
- {release_id !== "new" ? (
-
}
- onClick={() =>
- app.location.push(
- `/music/list/${globalState._id}`,
- )
- }
- >
- Go to release
-
- ) : null}
-
-
-
-
- {submitError && (
-
- )}
- {!Tab && (
-
- )}
- {Tab &&
- React.createElement(Tab.render, {
- release: globalState,
-
- state: globalState,
- setState: setGlobalState,
-
- references: {
- basic: basicInfoRef,
- },
- })}
-
- >
- )}
-
-
- )
-}
-
-export default ReleaseEditor
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/index.less b/packages/app/src/components/MusicStudio/ReleaseEditor/index.less
deleted file mode 100644
index 80e4f075..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/index.less
+++ /dev/null
@@ -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;
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/BasicInformation/index.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/BasicInformation/index.jsx
deleted file mode 100644
index 78d64114..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/BasicInformation/index.jsx
+++ /dev/null
@@ -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: ,
- },
- {
- value: "ep",
- label: "Episode",
- icon: ,
- },
- {
- value: "album",
- label: "Album",
- icon: ,
- },
- {
- value: "compilation",
- label: "Compilation",
- icon: ,
- }
-]
-
-const BasicInformation = (props) => {
- const { release, onFinish, setState, state } = props
-
- async function onFormChange(change) {
- setState((globalState) => {
- return {
- ...globalState,
- ...change
- }
- })
- }
-
- return
-
Release Information
-
-
-
-
-
-
- {
- release._id && ID>}
- name="_id"
- initialValue={release._id}
- disabled
- >
-
-
- }
-
- Title>}
- name="title"
- rules={[{ required: true, message: "Input a title for the release" }]}
- initialValue={state?.title}
- >
-
-
-
- Type>}
- name="type"
- rules={[{ required: true, message: "Select a type for the release" }]}
- initialValue={state?.type}
- >
-
-
-
- Public>}
- name="public"
- initialValue={state?.public}
- >
-
-
-
-
-}
-
-export default BasicInformation
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.jsx
deleted file mode 100644
index 23f4fcec..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.jsx
+++ /dev/null
@@ -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: ,
- props: {
- track: track,
- },
- })
- }
-
- async function onClickRemoveTrack() {
- props.onDelete(track.uid)
- }
-
- return (
-
-
-
-
- {props.index + 1}
-
-
- {progress !== null &&
}
-
-
-
-
{getTitleString({ track, progress })}
-
-
-
- }
- disabled={props.disabled}
- />
-
-
}
- onClick={onClickEditTrack}
- disabled={props.disabled}
- />
-
-
-
-
-
-
- )
-}
-
-export default TrackListItem
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.less b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.less
deleted file mode 100644
index 5b67c3f3..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.less
+++ /dev/null
@@ -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;
- }
- }
- }
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.jsx
deleted file mode 100644
index 14c2c29d..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.jsx
+++ /dev/null
@@ -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 (
-
-
- {this.state.items.length === 0 ? (
-
- ) : (
- }
- >
- Add another
-
- )}
-
-
-
- {this.state.items.length === 0 && (
-
- )}
-
- {this.state.items.map((track, index) => {
- const progress = this.getUploadProgress(track.uid)
-
- return (
-
- 0}
- />
-
- )
- })}
-
-
- )
- }
-}
-
-const ReleaseTracks = (props) => {
- const { state, setState } = props
-
- return (
-
-
Tracks
-
- {
- setState({
- ...state,
- ...managerState,
- })
- }}
- />
-
- )
-}
-
-export default ReleaseTracks
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.less b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.less
deleted file mode 100644
index 9ab8d7e2..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.less
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/sortableList.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/sortableList.jsx
deleted file mode 100644
index ae29526f..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/sortableList.jsx
+++ /dev/null
@@ -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 (
-
- {children({
- ...attributes,
- ...listeners,
- ref: setActivatorNodeRef,
- style: { cursor: "grab", touchAction: "none" },
- })}
-
- )
-}
-
-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 (
-
-
- {items.map((item, index) => (
-
- {(handleProps) => (
-
- {renderItem(item, index)}
-
-
- )}
-
- ))}
-
-
- )
-}
diff --git a/packages/app/src/components/MusicStudio/ReleaseItem/index.jsx b/packages/app/src/components/MusicStudio/ReleaseItem/index.jsx
deleted file mode 100644
index 431f6a15..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseItem/index.jsx
+++ /dev/null
@@ -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
-
-
-
- {release.title}
-
-
-
-
-
- {release.type}
-
-
-
-
- {release._id}
-
-
- {/*
-
- {release.analytics?.listen_count ?? 0}
-
*/}
-
-
-}
-
-export default ReleaseItem
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/TrackEditor/index.jsx b/packages/app/src/components/MusicStudio/TrackEditor/index.jsx
deleted file mode 100644
index caf1edd5..00000000
--- a/packages/app/src/components/MusicStudio/TrackEditor/index.jsx
+++ /dev/null
@@ -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 (
-
-
-
-
- Cover
-
-
-
handleChange("cover", url)}
- extraActions={[
-
- Use Parent
- ,
- ]}
- />
-
-
-
-
-
- Title
-
-
-
handleChange("title", e.target.value)}
- />
-
-
-
-
-
- Artist
-
-
-
handleChange("artist", e.target.value)}
- />
-
-
-
-
-
- Album
-
-
-
handleChange("album", e.target.value)}
- />
-
-
-
-
-
- Explicit
-
-
-
handleChange("explicit", value)}
- />
-
-
-
-
-
- Public
-
-
-
handleChange("public", value)}
- />
-
-
-
-
-
- Enhanced Lyrics
-
-
-
-
- Edit
-
-
- {!track.params._id && (
-
- You cannot edit Video and Lyrics without release
- first
-
- )}
-
-
-
- )
-}
-
-export default TrackEditor
diff --git a/packages/app/src/components/MusicStudio/TrackEditor/index.less b/packages/app/src/components/MusicStudio/TrackEditor/index.less
deleted file mode 100644
index 1b8bdacb..00000000
--- a/packages/app/src/components/MusicStudio/TrackEditor/index.less
+++ /dev/null
@@ -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%;
- }
- }
-}
\ No newline at end of file
diff --git a/packages/app/src/pages/studio/music/[release_id]/index.jsx b/packages/app/src/pages/studio/music/[release_id]/index.jsx
deleted file mode 100644
index d51afc29..00000000
--- a/packages/app/src/pages/studio/music/[release_id]/index.jsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from "react"
-
-import ReleaseEditor from "@components/MusicStudio/ReleaseEditor"
-
-const ReleaseEditorPage = (props) => {
- const { release_id } = props.params
-
- return
-}
-
-export default ReleaseEditorPage
\ No newline at end of file
diff --git a/packages/app/src/pages/studio/music/components/MyReleasesList/index.jsx b/packages/app/src/pages/studio/music/components/MyReleasesList/index.jsx
new file mode 100644
index 00000000..f13c9fff
--- /dev/null
+++ b/packages/app/src/pages/studio/music/components/MyReleasesList/index.jsx
@@ -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
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ if (!response?.items?.length) {
+ return
+ }
+
+ return (
+
+ {response.items.map((release) => (
+
+ ))}
+
+ )
+ }
+
+ return (
+
+
+
Your Releases
+
+ {renderContent()}
+
+ )
+}
+
+export default MyReleasesList
diff --git a/packages/app/src/components/MusicStudio/MyReleasesList/index.less b/packages/app/src/pages/studio/music/components/MyReleasesList/index.less
similarity index 100%
rename from packages/app/src/components/MusicStudio/MyReleasesList/index.less
rename to packages/app/src/pages/studio/music/components/MyReleasesList/index.less
diff --git a/packages/app/src/pages/studio/music/components/ReleaseItem/index.jsx b/packages/app/src/pages/studio/music/components/ReleaseItem/index.jsx
new file mode 100644
index 00000000..fa6645d8
--- /dev/null
+++ b/packages/app/src/pages/studio/music/components/ReleaseItem/index.jsx
@@ -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 (
+
+
+
+ {release.title}
+
+
+
+
+
+ {release.type}
+
+
+
+
+ {release._id}
+
+
+ {/*
+
+ {release.analytics?.listen_count ?? 0}
+
*/}
+
+
+ )
+}
+
+export default ReleaseItem
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/ReleaseItem/index.less b/packages/app/src/pages/studio/music/components/ReleaseItem/index.less
similarity index 100%
rename from packages/app/src/components/MusicStudio/ReleaseItem/index.less
rename to packages/app/src/pages/studio/music/components/ReleaseItem/index.less
diff --git a/packages/app/src/pages/studio/music/hooks/useReleaseEditor.js b/packages/app/src/pages/studio/music/hooks/useReleaseEditor.js
new file mode 100644
index 00000000..69bd4de7
--- /dev/null
+++ b/packages/app/src/pages/studio/music/hooks/useReleaseEditor.js
@@ -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
diff --git a/packages/app/src/pages/studio/music/hooks/useTracksManager.js b/packages/app/src/pages/studio/music/hooks/useTracksManager.js
new file mode 100644
index 00000000..31c9056b
--- /dev/null
+++ b/packages/app/src/pages/studio/music/hooks/useTracksManager.js
@@ -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
diff --git a/packages/app/src/pages/studio/music/index.jsx b/packages/app/src/pages/studio/music/index.jsx
index 79a3f2d0..190b6f79 100644
--- a/packages/app/src/pages/studio/music/index.jsx
+++ b/packages/app/src/pages/studio/music/index.jsx
@@ -3,38 +3,30 @@ import * as antd from "antd"
import { Icons } from "@components/Icons"
-import MyReleasesList from "@components/MusicStudio/MyReleasesList"
+import MyReleasesList from "./components/MyReleasesList"
import "./index.less"
-const ReleasesAnalytics = () => {
- return
-
Analytics
-
+const MusicStudioPage = () => {
+ return (
+
+
+
Music Studio
+
+
}
+ onClick={() => {
+ app.location.push("/studio/music/release/new")
+ }}
+ >
+ New Release
+
+
+
+
+
+ )
}
-const MusicStudioPage = (props) => {
- return
-
-
Music Studio
-
-
}
- onClick={() => {
- app.location.push("/studio/music/new")
- }}
- >
- New Release
-
-
-
-
-
-
-
-}
-
-export default MusicStudioPage
\ No newline at end of file
+export default MusicStudioPage
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/index.jsx
new file mode 100644
index 00000000..a99f244b
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/index.jsx
@@ -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 (
+
+ )
+ }
+
+ if (loading) {
+ return
+ }
+
+ const Tab = Tabs.find(({ key }) => key === selectedTab)
+
+ if (!Tab) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {submitError && (
+
+ )}
+ {React.createElement(Tab.render, {
+ data: data,
+ changeData: changeData,
+ })}
+
+ )
+ }
+
+ return (
+
+
+
setSelectedTab(e.key)}
+ selectedKeys={[selectedTab]}
+ items={Tabs}
+ mode="vertical"
+ />
+
+
+
:
+ }
+ disabled={!canSubmit}
+ loading={submitting}
+ >
+ {isNewRelease ? "Release" : "Save"}
+
+
+ {!isNewRelease && (
+
}
+ disabled={loading}
+ onClick={handleDelete}
+ >
+ Delete
+
+ )}
+
+ {!isNewRelease && (
+
}
+ onClick={() =>
+ app.location.push(`/music/list/${data._id}`)
+ }
+ >
+ Go to release
+
+ )}
+
+
+
+ {renderContent()}
+
+ )
+}
+
+ReleaseEditor.options = {
+ layout: {
+ type: "default",
+ centeredContent: true,
+ },
+}
+
+export default ReleaseEditor
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/index.less b/packages/app/src/pages/studio/music/release/[release_id]/index.less
new file mode 100644
index 00000000..6d35dbb7
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/index.less
@@ -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;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Advanced/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Advanced/index.jsx
similarity index 100%
rename from packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Advanced/index.jsx
rename to packages/app/src/pages/studio/music/release/[release_id]/tabs/Advanced/index.jsx
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/BasicInformation/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/BasicInformation/index.jsx
new file mode 100644
index 00000000..35b07f2d
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/BasicInformation/index.jsx
@@ -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: ,
+ },
+ {
+ value: "ep",
+ label: "Episode",
+ icon: ,
+ },
+ {
+ value: "album",
+ label: "Album",
+ icon: ,
+ },
+ {
+ value: "compilation",
+ label: "Compilation",
+ icon: ,
+ },
+]
+
+const BasicInformation = ({ data, changeData }) => {
+ const handleFormChange = React.useCallback(
+ (changes) => {
+ changeData((prev) => ({ ...prev, ...changes }))
+ },
+ [data],
+ )
+
+ return (
+
+
Release Information
+
+
+
+
+
+
+ {data?._id && (
+
+ ID
+ >
+ }
+ name="_id"
+ initialValue={data._id}
+ >
+
+
+ )}
+
+
+ Title
+ >
+ }
+ name="title"
+ rules={[
+ {
+ required: true,
+ message: "Input a title for the release",
+ },
+ ]}
+ initialValue={data?.title}
+ >
+
+
+
+
+ Type
+ >
+ }
+ name="type"
+ rules={[
+ {
+ required: true,
+ message: "Select a type for the release",
+ },
+ ]}
+ initialValue={data?.type}
+ >
+
+
+
+
+ Public
+ >
+ }
+ name="public"
+ initialValue={data?.public}
+ >
+
+
+
+
+ )
+}
+
+export default BasicInformation
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/ListItem/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/ListItem/index.jsx
new file mode 100644
index 00000000..5ac3f3b9
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/ListItem/index.jsx
@@ -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 (
+
+
+
+ {/*
+ {index + 1}
+
*/}
+
+ {progress !== null &&
}
+
+

+
+
+ {getTitleString({ track, progress })}
+ {!progress && (
+ <>
+ {track.artist}
+ {track.album}
+ >
+ )}
+
+
+
+
+ }
+ disabled={disabled}
+ />
+
+
+
}
+ onClick={handleEditTrack}
+ disabled={disabled}
+ />
+
+
+
+
+
+
+ )
+}
+
+export default SortableTrackItem
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/ListItem/index.less b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/ListItem/index.less
new file mode 100644
index 00000000..b066c515
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/ListItem/index.less
@@ -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);
+ }
+ }
+}
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/SortableTrackList/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/SortableTrackList/index.jsx
new file mode 100644
index 00000000..824804f0
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/SortableTrackList/index.jsx
@@ -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 (
+
+
+
+ {tracks.map((track, index) => {
+ const progress = getUploadProgress?.(track.uid)
+ const isDisabled = disabled || !!progress
+
+ return (
+
+ )
+ })}
+
+
+
+ )
+}
+
+export default SortableTrackList
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/TrackEditor/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/TrackEditor/index.jsx
new file mode 100644
index 00000000..3e0d93d2
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/TrackEditor/index.jsx
@@ -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 }) => (
+
+
+ {icon}
+ {label}
+
+ {children}
+
+)
+
+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: [
+ }
+ >
+ Save
+ ,
+ ],
+ })
+ }, [setHeader, handleSave, hasChanges])
+
+ console.log(track, release)
+
+ return (
+
+
} label="Cover">
+
handleChange("cover", url)}
+ extraActions={[
+
+ Use Parent
+ ,
+ ]}
+ />
+
+
+ } label="Title">
+ handleChange("title", e.target.value)}
+ />
+
+
+ } label="Artist">
+ handleChange("artist", e.target.value)}
+ />
+
+
+ } label="Album">
+ handleChange("album", e.target.value)}
+ />
+
+
+ } label="Explicit">
+ handleChange("explicit", value)}
+ />
+
+
+ } label="Public">
+ handleChange("public", checked)}
+ />
+
+
+ } label="Enhanced Lyrics">
+
+
+ Edit
+
+
+ {!track.params?._id && (
+
+ You cannot edit Video and Lyrics without releasing
+ first
+
+ )}
+
+
+
+ )
+}
+
+export default TrackEditor
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/TrackEditor/index.less b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/TrackEditor/index.less
new file mode 100644
index 00000000..4db45b38
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/TrackEditor/index.less
@@ -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%;
+ }
+ }
+}
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/UploadHint/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/UploadHint/index.jsx
similarity index 100%
rename from packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/UploadHint/index.jsx
rename to packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/UploadHint/index.jsx
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/UploadHint/index.less b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/UploadHint/index.less
similarity index 100%
rename from packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/UploadHint/index.less
rename to packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/UploadHint/index.less
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/index.jsx
new file mode 100644
index 00000000..d0965e84
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/index.jsx
@@ -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
+ }
+
+ return (
+ }>
+ Add another
+
+ )
+ }
+
+ const renderTracksList = () => {
+ if (tracks.length === 0) {
+ return
+ }
+
+ return (
+
+ )
+ }
+
+ return (
+
+
Tracks
+
+
+
+ {renderUploadButton()}
+
+
+
+ {renderTracksList()}
+
+
+
+ )
+}
+
+export default ReleaseTracks
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/index.less b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/index.less
new file mode 100644
index 00000000..c7b4bb81
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/index.less
@@ -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);
+ }
+}
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/index.jsx
similarity index 100%
rename from packages/app/src/components/MusicStudio/ReleaseEditor/tabs/index.jsx
rename to packages/app/src/pages/studio/music/release/[release_id]/tabs/index.jsx
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/InlinePlayer/index.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/InlinePlayer/index.jsx
new file mode 100644
index 00000000..59cb2ce2
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/InlinePlayer/index.jsx
@@ -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 (
+
+
+
+
+ ) : isPlaying ? (
+
+ ) : (
+
+ )
+ }
+ onClick={toggle}
+ disabled={isLoading}
+ className="control-button play-button"
+ />
+
+
+
+
+ `${Math.round(value * 100)}%`,
+ }}
+ icon={}
+ style={{ width: "100px" }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {speedOptions.map((option) => (
+
+ ))}
+
+
+
+ )
+})
+
+InlinePlayer.displayName = "InlinePlayer"
+
+InlinePlayer.propTypes = {
+ src: PropTypes.string.isRequired,
+}
+
+export default InlinePlayer
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/InlinePlayer/index.less b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/InlinePlayer/index.less
new file mode 100644
index 00000000..cda95d8e
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/InlinePlayer/index.less
@@ -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);
+ }
+ }
+}
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/SeekBar/index.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/SeekBar/index.jsx
new file mode 100644
index 00000000..1c4ddd6c
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/SeekBar/index.jsx
@@ -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 (
+
+ {
+ const time = (value / 100) * duration
+ return formatTime(time)
+ },
+ }}
+ />
+
+ )
+}
+
+SeekBar.propTypes = {
+ audio: PropTypes.object.isRequired,
+ onSeek: PropTypes.func.isRequired,
+}
+
+export default SeekBar
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/TimeIndicators/index.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/TimeIndicators/index.jsx
new file mode 100644
index 00000000..1cd6b343
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/TimeIndicators/index.jsx
@@ -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
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/context/LyricsEditorContext.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/context/LyricsEditorContext.jsx
new file mode 100644
index 00000000..361cc815
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/context/LyricsEditorContext.jsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+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
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/hooks/useAudioPlayer.js b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/hooks/useAudioPlayer.js
new file mode 100644
index 00000000..9b24545f
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/hooks/useAudioPlayer.js
@@ -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
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/index.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/index.jsx
new file mode 100644
index 00000000..c79645ec
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/index.jsx
@@ -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
+ }
+
+ return (
+
+
+ {state.track.title}
+
+ }
+ onClick={handleSave}
+ loading={state.saving}
+ //disabled={!state.isDirty}
+ >
+ Save Changes
+
+
+
+
+
+
+
+ {activeTab === "lyrics" && }
+ {activeTab === "video" && }
+
+ )
+}
+
+EnhancedLyricsEditorContent.propTypes = {
+ trackId: PropTypes.string.isRequired,
+}
+
+const EnhancedLyricsEditor = ({ params }) => {
+ const trackId = params?.track_id
+
+ if (!trackId) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+EnhancedLyricsEditor.options = {
+ layout: {
+ type: "default",
+ centeredContent: true,
+ },
+}
+
+EnhancedLyricsEditor.propTypes = {
+ params: PropTypes.shape({
+ track_id: PropTypes.string.isRequired,
+ }).isRequired,
+}
+
+export default EnhancedLyricsEditor
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/index.less b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/index.less
new file mode 100644
index 00000000..e5652204
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/index.less
@@ -0,0 +1,9 @@
+.avlyrics-editor {
+ display: flex;
+ flex-direction: column;
+
+ width: 100%;
+ max-width: 800px;
+
+ gap: 20px;
+}
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/lyrics/index.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/lyrics/index.jsx
new file mode 100644
index 00000000..8eb4fae5
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/lyrics/index.jsx
@@ -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 (
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ }
+ onClick={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ handleSeek(line.time)
+ }}
+ style={{
+ padding: 0,
+ height: "auto",
+ fontSize: "12px",
+ }}
+ >
+ {formatSecondsToLRC(line.time)}
+
+
+
+
+
+ {line.break && ""}
+ {!line.break && line.text}
+
+
+
+
+
+ }
+ onClick={() => handleEditLineStart(line)}
+ style={{ padding: "4px" }}
+ />
+ handleDeleteLine(line)}
+ okText="Delete"
+ cancelText="Cancel"
+ placement="topRight"
+ >
+ }
+ danger
+ style={{
+ padding: "4px",
+ }}
+ />
+
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+
+
+
+ handleLanguageUpload(data.url)}
+ accept={["text/*"]}
+ size="small"
+ >
+ Load file
+
+
+
+
+
+
+
+ {state.lyrics.length === 0 && (
+
+
+ Add lyrics manually or upload an LRC file
+
+
+ )}
+
+
+
+ {lines.length} lines
+
+
+
+
+ {lines.map((line, index) => {
+ return (
+
+ )
+ })}
+
+
+ )
+}
+
+LyricsEditor.propTypes = {
+ lyrics: PropTypes.arrayOf(PropTypes.string).isRequired,
+}
+
+export default LyricsEditor
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/lyrics/index.less b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/lyrics/index.less
new file mode 100644
index 00000000..c1ff7898
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/lyrics/index.less
@@ -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;
+ }
+}
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/videos/index.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/videos/index.jsx
new file mode 100644
index 00000000..36b90592
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/videos/index.jsx
@@ -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 (
+
+
+ Video Editor
+
+ }
+ >
+ {state.videoSource ? (
+
+
+
+ ) : (
+
+ }
+ description="No video loaded"
+ />
+ )}
+
+
+
+
+
+ Video sync time:
+ {state.videoSyncTime || "not set"}
+
+
+
+ Set sync point:
+
+
+
+
+
+
+
+
+
+ handleVideoUpload(data.url)
+ }
+ accept={["video/*"]}
+ headers={{ transformations: "mq-hls" }}
+ disabled={state.saving}
+ icon={}
+ >
+ Upload Video
+
+ or
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default VideoEditor
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/videos/index.less b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/videos/index.less
new file mode 100644
index 00000000..d7517828
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/videos/index.less
@@ -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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/utils/formatTime.js b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/utils/formatTime.js
new file mode 100644
index 00000000..ce00cf9d
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/utils/formatTime.js
@@ -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")}`
+}
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/utils/lrcParser.js b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/utils/lrcParser.js
new file mode 100644
index 00000000..25b22a7a
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/utils/lrcParser.js
@@ -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,
+ }
+}