Add change tracking and update to use "items" property

This commit is contained in:
SrGooglo 2025-04-24 06:13:37 +00:00
parent d738995054
commit 74021f38b6
4 changed files with 450 additions and 388 deletions

View File

@ -1,280 +1,332 @@
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 {
DefaultReleaseEditorState,
ReleaseEditorStateContext,
} from "@contexts/MusicReleaseEditor"
import Tabs from "./tabs"
import "./index.less"
const ReleaseEditor = (props) => {
const { release_id } = props
const { release_id } = props
const basicInfoRef = React.useRef()
const basicInfoRef = React.useRef()
const [submitting, setSubmitting] = React.useState(false)
const [loading, setLoading] = React.useState(true)
const [submitError, setSubmitError] = React.useState(null)
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 [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 [customPage, setCustomPage] = React.useState(null)
const [customPageActions, setCustomPageActions] = React.useState([])
const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
defaultKey: "info",
queryKey: "tab"
})
const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
defaultKey: "info",
queryKey: "tab",
})
async function initialize() {
setLoading(true)
setLoadError(null)
async function initialize() {
setLoading(true)
setLoadError(null)
if (release_id !== "new") {
try {
let releaseData = await MusicModel.getReleaseData(release_id)
if (release_id !== "new") {
try {
let releaseData = await MusicModel.getReleaseData(release_id)
if (Array.isArray(releaseData.list)) {
releaseData.list = releaseData.list.map((item) => {
return new TrackManifest(item)
})
}
if (Array.isArray(releaseData.items)) {
releaseData.items = releaseData.items.map((item) => {
return new TrackManifest(item)
})
}
setGlobalState({
...globalState,
...releaseData,
})
} catch (error) {
setLoadError(error)
}
}
setGlobalState({
...globalState,
...releaseData,
})
setLoading(false)
}
setInitialValues(releaseData)
} catch (error) {
setLoadError(error)
}
}
async function renderCustomPage(page, actions) {
setCustomPage(page ?? null)
setCustomPageActions(actions ?? [])
}
setLoading(false)
}
async function handleSubmit() {
setSubmitting(true)
setSubmitError(null)
function hasChanges() {
const stagedChanges = {
title: globalState.title,
type: globalState.type,
public: globalState.public,
cover: globalState.cover,
items: globalState.items,
}
try {
// first sumbit tracks
const tracks = await MusicModel.putTrack({
list: globalState.list,
})
return !compareObjectsByProperties(
stagedChanges,
initialValues,
Object.keys(stagedChanges),
)
}
// 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,
list: tracks.list.map((item) => item._id),
})
async function renderCustomPage(page, actions) {
setCustomPage(page ?? null)
setCustomPageActions(actions ?? [])
}
app.location.push(`/studio/music/${result._id}`)
} catch (error) {
console.error(error)
app.message.error(error.message)
async function handleSubmit() {
setSubmitting(true)
setSubmitError(null)
setSubmitError(error)
setSubmitting(false)
try {
console.log("Submitting Tracks")
return false
}
// first sumbit tracks
const tracks = await MusicModel.putTrack({
items: globalState.items,
})
setSubmitting(false)
app.message.success("Release saved")
}
console.log("Submitting release")
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("/"))
},
})
}
// 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),
})
async function canFinish() {
return true
}
app.location.push(`/studio/music/${result._id}`)
} catch (error) {
console.error(error)
app.message.error(error.message)
React.useEffect(() => {
initialize()
}, [])
setSubmitError(error)
setSubmitting(false)
if (loadError) {
return <antd.Result
status="warning"
title="Error"
subTitle={loadError.message}
/>
}
return false
}
if (loading) {
return <antd.Skeleton active />
}
setSubmitting(false)
app.message.success("Release saved")
}
const Tab = Tabs.find(({ key }) => key === selectedTab)
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("/"),
)
},
})
}
const CustomPageProps = {
close: () => {
renderCustomPage(null, null)
}
}
function canFinish() {
return hasChanges()
}
return <ReleaseEditorStateContext.Provider
value={{
...globalState,
setGlobalState,
renderCustomPage,
setCustomPageActions,
}}
>
<div className="music-studio-release-editor">
{
customPage && <div className="music-studio-release-editor-custom-page">
{
customPage.header && <div className="music-studio-release-editor-custom-page-header">
<div className="music-studio-release-editor-custom-page-header-title">
<antd.Button
icon={<Icons.IoIosArrowBack />}
onClick={() => renderCustomPage(null, null)}
/>
React.useEffect(() => {
initialize()
}, [])
<h2>{customPage.header}</h2>
</div>
if (loadError) {
return (
<antd.Result
status="warning"
title="Error"
subTitle={loadError.message}
/>
)
}
{
Array.isArray(customPageActions) && customPageActions.map((action, index) => {
return <antd.Button
key={index}
type={action.type}
icon={createIconRender(action.icon)}
onClick={async () => {
if (typeof action.onClick === "function") {
await action.onClick()
}
if (loading) {
return <antd.Skeleton active />
}
if (action.fireEvent) {
app.eventBus.emit(action.fireEvent)
}
}}
disabled={action.disabled}
>
{action.label}
</antd.Button>
})
}
</div>
}
const Tab = Tabs.find(({ key }) => key === selectedTab)
{
customPage.content && (React.isValidElement(customPage.content) ?
React.cloneElement(customPage.content, {
...CustomPageProps,
...customPage.props
}) :
React.createElement(customPage.content, {
...CustomPageProps,
...customPage.props
})
)
}
</div>
}
{
!customPage && <>
<div className="music-studio-release-editor-menu">
<antd.Menu
onClick={(e) => setSelectedTab(e.key)}
selectedKeys={[selectedTab]}
items={Tabs}
mode="vertical"
/>
const CustomPageProps = {
close: () => {
renderCustomPage(null, null)
},
}
<div className="music-studio-release-editor-menu-actions">
<antd.Button
type="primary"
onClick={handleSubmit}
icon={release_id !== "new" ? <Icons.FiSave /> : <Icons.MdSend />}
disabled={submitting || loading || !canFinish()}
loading={submitting}
>
{release_id !== "new" ? "Save" : "Release"}
</antd.Button>
return (
<ReleaseEditorStateContext.Provider
value={{
...globalState,
setGlobalState,
renderCustomPage,
setCustomPageActions,
}}
>
<div className="music-studio-release-editor">
{customPage && (
<div className="music-studio-release-editor-custom-page">
{customPage.header && (
<div className="music-studio-release-editor-custom-page-header">
<div className="music-studio-release-editor-custom-page-header-title">
<antd.Button
icon={<Icons.IoIosArrowBack />}
onClick={() =>
renderCustomPage(null, null)
}
/>
{
release_id !== "new" ? <antd.Button
icon={<Icons.IoMdTrash />}
disabled={loading}
onClick={handleDelete}
>
Delete
</antd.Button> : null
}
<h2>{customPage.header}</h2>
</div>
{
release_id !== "new" ? <antd.Button
icon={<Icons.MdLink />}
onClick={() => app.location.push(`/music/release/${globalState._id}`)}
>
Go to release
</antd.Button> : null
}
</div>
</div>
{Array.isArray(customPageActions) &&
customPageActions.map((action, index) => {
return (
<antd.Button
key={index}
type={action.type}
icon={createIconRender(
action.icon,
)}
onClick={async () => {
if (
typeof action.onClick ===
"function"
) {
await action.onClick()
}
<div className="music-studio-release-editor-content">
{
submitError && <antd.Alert
message={submitError.message}
type="error"
/>
}
{
!Tab && <antd.Result
status="error"
title="Error"
subTitle="Tab not found"
/>
}
{
Tab && React.createElement(Tab.render, {
release: globalState,
if (action.fireEvent) {
app.eventBus.emit(
action.fireEvent,
)
}
}}
disabled={action.disabled}
>
{action.label}
</antd.Button>
)
})}
</div>
)}
state: globalState,
setState: setGlobalState,
{customPage.content &&
(React.isValidElement(customPage.content)
? React.cloneElement(customPage.content, {
...CustomPageProps,
...customPage.props,
})
: React.createElement(customPage.content, {
...CustomPageProps,
...customPage.props,
}))}
</div>
)}
{!customPage && (
<>
<div className="music-studio-release-editor-menu">
<antd.Menu
onClick={(e) => setSelectedTab(e.key)}
selectedKeys={[selectedTab]}
items={Tabs}
mode="vertical"
/>
references: {
basic: basicInfoRef
}
})
}
</div>
</>
}
</div>
</ReleaseEditorStateContext.Provider>
<div className="music-studio-release-editor-menu-actions">
<antd.Button
type="primary"
onClick={handleSubmit}
icon={
release_id !== "new" ? (
<Icons.FiSave />
) : (
<Icons.MdSend />
)
}
disabled={
submitting || loading || !canFinish()
}
loading={submitting}
>
{release_id !== "new" ? "Save" : "Release"}
</antd.Button>
{release_id !== "new" ? (
<antd.Button
icon={<Icons.IoMdTrash />}
disabled={loading}
onClick={handleDelete}
>
Delete
</antd.Button>
) : null}
{release_id !== "new" ? (
<antd.Button
icon={<Icons.MdLink />}
onClick={() =>
app.location.push(
`/music/release/${globalState._id}`,
)
}
>
Go to release
</antd.Button>
) : null}
</div>
</div>
<div className="music-studio-release-editor-content">
{submitError && (
<antd.Alert
message={submitError.message}
type="error"
/>
)}
{!Tab && (
<antd.Result
status="error"
title="Error"
subTitle="Tab not found"
/>
)}
{Tab &&
React.createElement(Tab.render, {
release: globalState,
state: globalState,
setState: setGlobalState,
references: {
basic: basicInfoRef,
},
})}
</div>
</>
)}
</div>
</ReleaseEditorStateContext.Provider>
)
}
export default ReleaseEditor

View File

@ -11,13 +11,27 @@ 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 } = props
const { track, progress } = props
async function onClickEditTrack() {
context.renderCustomPage({
@ -33,8 +47,6 @@ const TrackListItem = (props) => {
props.onDelete(track.uid)
}
console.log("render")
return (
<div
className={classnames(
@ -50,7 +62,7 @@ const TrackListItem = (props) => {
<div
className="music-studio-release-editor-tracks-list-item-progress"
style={{
"--upload-progress": `${props.uploading.progress}%`,
"--upload-progress": `${props.progress?.percent ?? 0}%`,
}}
/>
@ -58,7 +70,7 @@ const TrackListItem = (props) => {
<span>{props.index + 1}</span>
</div>
{props.uploading.working && <Icons.LoadingOutlined />}
{progress !== null && <Icons.LoadingOutlined />}
<Image
src={track.cover}
@ -69,7 +81,7 @@ const TrackListItem = (props) => {
}}
/>
<span>{track.title}</span>
<span>{getTitleString({ track, progress })}</span>
<div className="music-studio-release-editor-tracks-list-item-actions">
<antd.Popconfirm

View File

@ -17,12 +17,12 @@ class TracksManager extends React.Component {
swapyRef = React.createRef()
state = {
list: Array.isArray(this.props.list) ? this.props.list : [],
items: Array.isArray(this.props.items) ? this.props.items : [],
pendingUploads: [],
}
componentDidUpdate = (prevProps, prevState) => {
if (prevState.list !== this.state.list) {
if (prevState.items !== this.state.items) {
if (typeof this.props.onChangeState === "function") {
this.props.onChangeState(this.state)
}
@ -55,7 +55,7 @@ class TracksManager extends React.Component {
return false
}
return this.state.list.find((item) => item.uid === uid)
return this.state.items.find((item) => item.uid === uid)
}
addTrackToList = (track) => {
@ -64,7 +64,7 @@ class TracksManager extends React.Component {
}
this.setState({
list: [...this.state.list, track],
items: [...this.state.items, track],
})
}
@ -76,18 +76,17 @@ class TracksManager extends React.Component {
this.removeTrackUIDFromPendingUploads(uid)
this.setState({
list: this.state.list.filter((item) => item.uid !== uid),
items: this.state.items.filter((item) => item.uid !== uid),
})
}
modifyTrackByUid = (uid, track) => {
console.log("modifyTrackByUid", uid, track)
if (!uid || !track) {
return false
}
this.setState({
list: this.state.list.map((item) => {
items: this.state.items.map((item) => {
if (item.uid === uid) {
return {
...item,
@ -140,7 +139,7 @@ class TracksManager extends React.Component {
)
if (uploadProgressIndex === -1) {
return 0
return null
}
return this.state.pendingUploads[uploadProgressIndex].progress
@ -159,7 +158,7 @@ class TracksManager extends React.Component {
newData[uploadProgressIndex].progress = progress
console.log(`Updating progress for [${uid}] to [${progress}]`)
console.log(`Updating progress for [${uid}] to >`, progress)
this.setState({
pendingUploads: newData,
@ -189,7 +188,7 @@ class TracksManager extends React.Component {
// remove pending file
this.removeTrackUIDFromPendingUploads(uid)
let trackManifest = this.state.list.find(
let trackManifest = this.state.items.find(
(item) => item.uid === uid,
)
@ -231,9 +230,8 @@ class TracksManager extends React.Component {
const response = await app.cores.remoteStorage
.uploadFile(req.file, {
onProgress: this.handleTrackFileUploadProgress,
service: "b2",
headers: {
transmux: "a-dash",
transformations: "a-dash",
},
})
.catch((error) => {
@ -258,17 +256,17 @@ class TracksManager extends React.Component {
this.setState((prev) => {
// move all list items by id
const orderedIds = orderedIdsArray.map((id) =>
this.state.list.find((item) => item._id === id),
this.state.items.find((item) => item._id === id),
)
console.log("orderedIds", orderedIds)
return {
list: orderedIds,
items: orderedIds,
}
})
}
render() {
console.log(`Tracks List >`, this.state.list)
console.log(`Tracks List >`, this.state.items)
return (
<div className="music-studio-release-editor-tracks">
@ -280,7 +278,7 @@ class TracksManager extends React.Component {
accept="audio/*"
multiple
>
{this.state.list.length === 0 ? (
{this.state.items.length === 0 ? (
<UploadHint />
) : (
<antd.Button
@ -296,11 +294,11 @@ class TracksManager extends React.Component {
id="editor-tracks-list"
className="music-studio-release-editor-tracks-list"
>
{this.state.list.length === 0 && (
{this.state.items.length === 0 && (
<antd.Result status="info" title="No tracks" />
)}
{this.state.list.map((track, index) => {
{this.state.items.map((track, index) => {
const progress = this.getUploadProgress(track.uid)
return (
@ -310,12 +308,7 @@ class TracksManager extends React.Component {
track={track}
onEdit={this.modifyTrackByUid}
onDelete={this.removeTrackByUid}
uploading={{
progress: progress,
working: this.state.pendingUploads.find(
(item) => item.uid === track.uid,
),
}}
progress={progress}
disabled={progress > 0}
/>
</div>
@ -336,7 +329,7 @@ const ReleaseTracks = (props) => {
<TracksManager
_id={state._id}
list={state.list}
items={state.items}
onChangeState={(managerState) => {
setState({
...state,

View File

@ -10,158 +10,163 @@ import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
import "./index.less"
const TrackEditor = (props) => {
const context = React.useContext(ReleaseEditorStateContext)
const [track, setTrack] = React.useState(props.track ?? {})
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 handleChange(key, value) {
setTrack((prev) => {
return {
...prev,
[key]: value,
}
})
}
async function openEnhancedLyricsEditor() {
context.renderCustomPage({
header: "Enhanced Lyrics",
content: EnhancedLyricsEditor,
props: {
track: track,
}
})
}
async function openEnhancedLyricsEditor() {
context.renderCustomPage({
header: "Enhanced Lyrics",
content: EnhancedLyricsEditor,
props: {
track: track,
},
})
}
async function handleOnSave() {
setTrack((prev) => {
const listData = [...context.list]
async function handleOnSave() {
setTrack((prev) => {
const listData = [...context.items]
const trackIndex = listData.findIndex((item) => item.uid === prev.uid)
const trackIndex = listData.findIndex(
(item) => item.uid === prev.uid,
)
if (trackIndex === -1) {
return prev
}
if (trackIndex === -1) {
return prev
}
listData[trackIndex] = prev
listData[trackIndex] = prev
context.setGlobalState({
...context,
list: listData
})
context.setGlobalState({
...context,
items: listData,
})
return prev
})
}
props.close()
React.useEffect(() => {
context.setCustomPageActions([
{
label: "Save",
icon: "FiSave",
type: "primary",
onClick: handleOnSave,
disabled: props.track === track,
},
])
}, [track])
return prev
})
}
return <div className="track-editor">
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdImage />
<span>Cover</span>
</div>
function setParentCover() {
handleChange("cover", context.cover)
}
<CoverEditor
value={track.cover}
onChange={(url) => handleChange("cover", url)}
extraActions={[
<antd.Button>
Use Parent
</antd.Button>
]}
/>
</div>
React.useEffect(() => {
context.setCustomPageActions([
{
label: "Save",
icon: "FiSave",
type: "primary",
onClick: handleOnSave,
disabled: props.track === track,
},
])
}, [track])
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdOutlineMusicNote />
<span>Title</span>
</div>
return (
<div className="track-editor">
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdImage />
<span>Cover</span>
</div>
<antd.Input
value={track.title}
placeholder="Track title"
onChange={(e) => handleChange("title", e.target.value)}
/>
</div>
<CoverEditor
value={track.cover}
onChange={(url) => handleChange("cover", url)}
extraActions={[
<antd.Button onClick={setParentCover}>
Use Parent
</antd.Button>,
]}
/>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.FiUser />
<span>Artist</span>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdOutlineMusicNote />
<span>Title</span>
</div>
<antd.Input
value={track.artists?.join(", ")}
placeholder="Artist"
onChange={(e) => handleChange("artist", e.target.value)}
/>
</div>
<antd.Input
value={track.title}
placeholder="Track title"
onChange={(e) => handleChange("title", e.target.value)}
/>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdAlbum />
<span>Album</span>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.FiUser />
<span>Artist</span>
</div>
<antd.Input
value={track.album}
placeholder="Album"
onChange={(e) => handleChange("album", e.target.value)}
/>
</div>
<antd.Input
value={track.artist}
placeholder="Artist"
onChange={(e) => handleChange("artist", e.target.value)}
/>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdExplicit />
<span>Explicit</span>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdAlbum />
<span>Album</span>
</div>
<antd.Switch
checked={track.explicit}
onChange={(value) => handleChange("explicit", value)}
/>
</div>
<antd.Input
value={track.album}
placeholder="Album"
onChange={(e) => handleChange("album", e.target.value)}
/>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdLyrics />
<span>Enhanced Lyrics</span>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdExplicit />
<span>Explicit</span>
</div>
<antd.Switch
checked={track.lyrics_enabled}
onChange={(value) => handleChange("lyrics_enabled", value)}
disabled={!track.params._id}
/>
</div>
<antd.Switch
checked={track.explicit}
onChange={(value) => handleChange("explicit", value)}
/>
</div>
<div className="track-editor-field-actions">
<antd.Button
disabled={!track.params._id}
onClick={openEnhancedLyricsEditor}
>
Edit
</antd.Button>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdLyrics />
<span>Enhanced Lyrics</span>
</div>
{
!track.params._id && <span>
You cannot edit Video and Lyrics without release first
</span>
}
</div>
</div>
</div>
<div className="track-editor-field-actions">
<antd.Button
disabled={!track.params._id}
onClick={openEnhancedLyricsEditor}
>
Edit
</antd.Button>
{!track.params._id && (
<span>
You cannot edit Video and Lyrics without release
first
</span>
)}
</div>
</div>
</div>
)
}
export default TrackEditor