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 ( +
+ + + + ))} +
+ +
+ ) +}) + +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}

+ + +
+ + + + + + {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 ( + +
+ +