merge from local

This commit is contained in:
SrGooglo 2024-10-25 09:39:35 +00:00
parent 5ef95ac39a
commit 8c3e9a504b
175 changed files with 3757 additions and 4306 deletions

View File

@ -46,7 +46,7 @@
"capacitor-music-controls-plugin-v3": "^1.1.0",
"classnames": "2.3.1",
"dompurify": "^3.0.0",
"evite": "^0.17.0",
"vessel": "^0.18.0",
"fast-average-color": "^9.2.0",
"framer-motion": "^10.12.17",
"fuse.js": "6.5.3",

View File

@ -2,7 +2,7 @@ import "./patches"
import config from "@config"
import React from "react"
import { EviteRuntime } from "evite"
import { Runtime } from "vessel"
import { Helmet } from "react-helmet"
import { Translation } from "react-i18next"
import * as Sentry from "@sentry/browser"
@ -109,16 +109,12 @@ class ComtyApp extends React.Component {
},
openLoginForm: async (options = {}) => {
app.layout.draggable.open("login", Login, {
defaultLocked: options.defaultLocked ?? false,
componentProps: {
sessionController: this.sessionController,
},
props: {
fillEnd: true,
bodyStyle: {
height: "100%",
sessionController: this.sessionController,
onDone: () => {
app.layout.draggable.destroy("login")
}
}
},
})
},
openAppsMenu: () => {
@ -464,4 +460,4 @@ class ComtyApp extends React.Component {
}
}
export default new EviteRuntime(ComtyApp)
export default new Runtime(ComtyApp)

View File

@ -1,3 +1,5 @@
import TrackManifest from "../TrackManifest"
export default class TrackInstance {
constructor(player, manifest) {
if (!player) {
@ -14,6 +16,8 @@ export default class TrackInstance {
return this
}
_initialized = false
audio = null
contextElement = null
@ -24,57 +28,6 @@ export default class TrackInstance {
waitUpdateTimeout = null
resolveManifest = async () => {
if (typeof this.manifest === "string") {
this.manifest = {
src: this.manifest,
}
}
if (this.manifest.service) {
if (!this.player.service_providers.has(manifest.service)) {
throw new Error(`Service ${manifest.service} is not supported`)
}
// try to resolve source file
if (this.manifest.service !== "inherit" && !this.manifest.source) {
this.manifest = await this.player.service_providers.resolve(this.manifest.service, this.manifest)
}
}
if (!this.manifest.source) {
throw new Error("Manifest `source` is required")
}
if (!this.manifest.metadata) {
this.manifest.metadata = {}
}
if (!this.manifest.metadata.title) {
this.manifest.metadata.title = this.manifest.source.split("/").pop()
}
return this.manifest
}
initialize = async () => {
this.manifest = await this.resolveManifest()
this.audio = new Audio(this.manifest.source)
this.audio.signal = this.abortController.signal
this.audio.crossOrigin = "anonymous"
this.audio.preload = "metadata"
for (const [key, value] of Object.entries(this.mediaEvents)) {
this.audio.addEventListener(key, value)
}
this.contextElement = this.player.audioContext.createMediaElementSource(this.audio)
return this
}
mediaEvents = {
"ended": () => {
this.player.next()
@ -124,4 +77,63 @@ export default class TrackInstance {
this.player.eventBus.emit(`player.seeked`, this.audio.currentTime)
},
}
initialize = async () => {
this.manifest = await this.resolveManifest()
this.audio = new Audio(this.manifest.source)
this.audio.signal = this.abortController.signal
this.audio.crossOrigin = "anonymous"
this.audio.preload = "metadata"
for (const [key, value] of Object.entries(this.mediaEvents)) {
this.audio.addEventListener(key, value)
}
this.contextElement = this.player.audioContext.createMediaElementSource(this.audio)
this._initialized = true
return this
}
resolveManifest = async () => {
if (typeof this.manifest === "string") {
this.manifest = {
src: this.manifest,
}
}
this.manifest = new TrackManifest(this.manifest)
this.manifest = await this.manifest.analyzeCoverColor()
if (this.manifest.service) {
if (!this.player.service_providers.has(manifest.service)) {
throw new Error(`Service ${manifest.service} is not supported`)
}
// try to resolve source file
if (this.manifest.service !== "inherit" && !this.manifest.source) {
this.manifest = await this.player.service_providers.resolve(this.manifest.service, this.manifest)
}
}
if (!this.manifest.source) {
throw new Error("Manifest `source` is required")
}
// set empty metadata if not provided
if (!this.manifest.metadata) {
this.manifest.metadata = {}
}
// auto name if a title is not provided
if (!this.manifest.metadata.title) {
this.manifest.metadata.title = this.manifest.source.split("/").pop()
}
return this.manifest
}
}

View File

@ -0,0 +1,150 @@
import jsmediatags from "jsmediatags/dist/jsmediatags.min.js"
import { FastAverageColor } from "fast-average-color"
async function uploadBinaryArrayToStorage(bin, args) {
const { format, data } = bin
const filenameExt = format.split("/")[1]
const filename = `cover.${filenameExt}`
const byteArray = new Uint8Array(data)
const blob = new Blob([byteArray], { type: data.type })
// create a file object
const file = new File([blob], filename, {
type: format,
})
return await app.cores.remoteStorage.uploadFile(file, args)
}
export default class TrackManifest {
constructor(params) {
this.params = params
this.uid = params.uid ?? params._id
this._id = params._id
if (typeof params.cover !== "undefined") {
this.cover = params.cover
}
if (typeof params.title !== "undefined") {
this.title = params.title
}
if (typeof params.album !== "undefined") {
this.album = params.album
}
if (typeof params.artist !== "undefined") {
this.artist = params.artist
}
if (typeof params.artists !== "undefined" || Array.isArray(params.artists)) {
this.artistStr = params.artists.join(", ")
}
if (typeof params.source !== "undefined") {
this.source = params.source
}
if (typeof params.metadata !== "undefined") {
this.metadata = params.metadata
}
if (typeof params.lyrics_enabled !== "undefined") {
this.lyrics_enabled = params.lyrics_enabled
}
if (params.cover.startsWith("http")) {
try {
this.analyzeCoverColor()
} catch (error) {
// so bad...
}
}
return this
}
uid = null
cover = "https://storage.ragestudio.net/comty-static-assets/default_song.png"
title = "Untitled"
album = "Unknown"
artist = "Unknown"
source = null
metadata = {}
lyrics_enabled = false
analyzedMetadata = null
async initialize() {
if (this.params.file) {
this.metadata = await this.analyzeMetadata(this.params.file.originFileObj)
if (this.metadata.tags) {
if (this.metadata.tags.title) {
this.title = this.metadata.tags.title
}
if (this.metadata.tags.artist) {
this.artist = this.metadata.tags.artist
}
if (this.metadata.tags.album) {
this.album = this.metadata.tags.album
}
if (this.metadata.tags.picture) {
const coverUpload = await uploadBinaryArrayToStorage(this.metadata.tags.picture)
this.cover = coverUpload.url
}
this.handleChanges({
cover: this.cover,
title: this.title,
artist: this.artist,
album: this.album,
})
}
}
return this
}
handleChanges = (changes) => {
if (typeof this.params.onChange === "function") {
this.params.onChange(this.uid, changes)
}
}
analyzeMetadata = async (file) => {
return new Promise((resolve, reject) => {
jsmediatags.read(file, {
onSuccess: (data) => {
return resolve(data)
},
onError: (error) => {
return reject(error)
}
})
})
}
analyzeCoverColor = async () => {
const fac = new FastAverageColor()
this.cover_analysis = await fac.getColorAsync(this.cover)
return this
}
}

View File

@ -7,7 +7,7 @@ import RGBStringToValues from "@utils/rgbToValues"
import ImageViewer from "@components/ImageViewer"
import { Icons } from "@components/Icons"
import { Context as PlayerContext } from "@contexts/WithPlayerContext"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
import "./index.less"
@ -28,7 +28,7 @@ const Track = (props) => {
loading,
track_manifest,
playback_status,
} = React.useContext(PlayerContext)
} = usePlayerStateContext()
const playlist_ctx = React.useContext(PlaylistContext)
@ -186,12 +186,16 @@ const Track = (props) => {
{
props.track.service === "tidal" && <Icons.SiTidal />
}
{props.track.title}
{
props.track.title
}
</span>
</div>
<div className="music-track_artist">
<span>
{props.track.artist}
{
Array.isArray(props.track.artists) ? props.track.artists.join(", ") : props.track.artist
}
</span>
</div>
</div>

View File

@ -99,7 +99,7 @@ html {
align-items: center;
padding: 10px;
padding: 6px;
}
.music-track_actions {
@ -182,11 +182,11 @@ html {
overflow: hidden;
width: 50px;
height: 50px;
width: 35px;
height: 35px;
min-width: 50px;
min-height: 50px;
min-width: 35px;
min-height: 35px;
img {
width: 100%;

View File

@ -0,0 +1,97 @@
import React from "react"
import * as antd from "antd"
import LyricsTextView from "@components/MusicStudio/LyricsTextView"
import UploadButton from "@components/UploadButton"
import { Icons } from "@components/Icons"
import Languages from "@config/languages"
import "./index.less"
const LanguagesMap = Object.entries(Languages).map(([key, value]) => {
return {
label: value,
value: key,
}
})
const LyricsEditor = (props) => {
const { langs = {} } = props
const [selectedLang, setSelectedLang] = React.useState("original")
function handleChange(key, value) {
if (typeof props.onChange !== "function") {
return false
}
props.onChange(key, value)
}
function updateCurrentLang(url) {
handleChange("langs", {
...langs,
[selectedLang]: url
})
}
return <div className="lyrics-editor">
<div className="flex-row align-center justify-space-between gap-10">
<h1>
<Icons.MdOutlineMusicNote />
Lyrics
</h1>
<div className="flex-row aling-center gap-5">
<span>
Language:
</span>
<antd.Select
showSearch
style={{ width: "220px" }}
placeholder="Select a language"
value={selectedLang}
options={[
...LanguagesMap,
{
label: "Original",
value: "original",
}
]}
optionFilterProp="children"
filterOption={(input, option) => (option?.label.toLowerCase() ?? '').includes(input.toLowerCase())}
filterSort={(optionA, optionB) =>
(optionA?.label.toLowerCase() ?? '').toLowerCase().localeCompare((optionB?.label.toLowerCase() ?? '').toLowerCase())
}
onChange={setSelectedLang}
/>
{
selectedLang && <UploadButton
onSuccess={(file_uid, data) => {
updateCurrentLang(data.url)
}}
accept={[
"text/*"
]}
/>
}
</div>
</div>
{
!langs[selectedLang] && <span>
No lyrics uploaded for this language
</span>
}
{
langs[selectedLang] && <LyricsTextView
lrcURL={langs[selectedLang]}
/>
}
</div>
}
export default LyricsEditor

View File

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

View File

@ -0,0 +1,106 @@
import React from "react"
import * as antd from "antd"
import dayjs from "dayjs"
import customParseFormat from "dayjs/plugin/customParseFormat"
import UploadButton from "@components/UploadButton"
import { Icons } from "@components/Icons"
import VideoPlayer from "@components/VideoPlayer"
import "./index.less"
dayjs.extend(customParseFormat)
const VideoEditor = (props) => {
function handleChange(key, value) {
if (typeof props.onChange !== "function") {
return false
}
props.onChange(key, value)
}
return <div className="video-editor">
<h1>
<Icons.MdVideocam />
Video
</h1>
{
(!props.videoSourceURL) && <antd.Empty
image={<Icons.MdVideocam />}
description="No video"
/>
}
{
props.videoSourceURL && <div className="video-editor-preview">
<VideoPlayer
controls={[
"play",
"current-time",
"seek-time",
"duration",
"progress",
"settings",
]}
src={props.videoSourceURL}
/>
</div>
}
<div className="flex-column align-start gap10">
<div className="flex-row align-center gap10">
<span>
<Icons.MdAccessTime />
Start video sync at
</span>
<code>{props.startSyncAt ?? "not set"}</code>
</div>
<div className="flex-row align-center gap10">
<span>Set to:</span>
<antd.TimePicker
showNow={false}
defaultValue={props.startSyncAt && dayjs((props.startSyncAt), "mm:ss:SSS")}
format={"mm:ss:SSS"}
onChange={(time, str) => {
handleChange("startSyncAt", str)
}}
/>
</div>
</div>
<div className="video-editor-actions">
<UploadButton
onSuccess={(id, response) => {
handleChange("videoSourceURL", response.url)
}}
accept={[
"video/*",
]}
headers={{
"transmux": "mq-hls",
}}
disabled={props.loading}
>
Upload video
</UploadButton>
or
<antd.Input
placeholder="Set a video HLS URL"
onChange={(e) => {
handleChange("videoSourceURL", e.target.value)
}}
value={props.videoSourceURL}
disabled={props.loading}
/>
</div>
</div>
}
export default VideoEditor

View File

@ -0,0 +1,25 @@
.video-editor {
display: flex;
flex-direction: column;
gap: 20px;
padding: 15px;
border-radius: 12px;
background-color: var(--background-color-accent);
.video-editor-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.video-editor-preview {
width: 100%;
height: 350px;
}
}

View File

@ -0,0 +1,120 @@
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")
})
this.setState({
submitting: false
})
}
render() {
if (this.state.loading) {
return <Skeleton active />
}
return <div className="enhanced_lyrics_editor-wrapper">
<h1>{this.props.track.title}</h1>
<VideoEditor
loading={this.state.submitting}
videoSourceURL={this.state.videoOptions.videoSourceURL}
startSyncAt={this.state.videoOptions.startSyncAt}
onChange={(key, value) => {
this.setState({
videoOptions: {
...this.state.videoOptions,
[key]: value
}
})
}}
/>
<LyricsEditor
loading={this.state.submitting}
langs={this.state.lyricsOptions.langs}
onChange={(key, value) => {
this.setState({
lyricsOptions: {
...this.state.lyricsOptions,
[key]: value
}
})
}}
/>
</div>
}
}
export default EnhancedLyricsEditor

View File

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

View File

@ -1,116 +0,0 @@
import React from "react"
import * as antd from "antd"
import LyricsTextView from "../LyricsTextView"
import UploadButton from "@components/UploadButton"
import { Icons } from "@components/Icons"
import MusicService from "@models/music"
import Languages from "@config/languages"
const LanguagesMap = Object.entries(Languages).map(([key, value]) => {
return {
label: value,
value: key,
}
})
import "./index.less"
const LyricsEditor = (props) => {
const [L_TrackLyrics, R_TrackLyrics, E_TrackLyrics, F_TrackLyrics] = app.cores.api.useRequest(MusicService.getTrackLyrics, props.track._id)
const [langs, setLangs] = React.useState([])
const [selectedLang, setSelectedLang] = React.useState("original")
async function onUploadLRC(uid, data) {
const { url } = data
setLangs((prev) => {
const index = prev.findIndex((lang) => {
return lang.id === selectedLang
})
console.log(`Replacing value for id [${selectedLang}] at index [${index}]`)
if (index !== -1) {
prev[index].value = url
} else {
const lang = LanguagesMap.find((lang) => {
return lang.value === selectedLang
})
prev.push({
id: lang.value,
name: lang.label,
value: url
})
}
console.log(`new value =>`, prev)
return prev
})
}
React.useEffect(() => {
if (R_TrackLyrics) {
if (R_TrackLyrics.available_langs) {
setLangs(R_TrackLyrics.available_langs)
}
}
console.log(R_TrackLyrics)
}, [R_TrackLyrics])
const currentLangData = selectedLang && langs.find((lang) => {
return lang.id === selectedLang
})
console.log(langs, currentLangData)
return <div className="lyrics-editor">
<h1>Lyrics</h1>
<antd.Select
showSearch
style={{ width: "100%" }}
placeholder="Select a language"
value={selectedLang}
options={[...LanguagesMap, {
label: "Original",
value: "original",
}]}
optionFilterProp="children"
filterOption={(input, option) => (option?.label.toLowerCase() ?? '').includes(input.toLowerCase())}
filterSort={(optionA, optionB) =>
(optionA?.label.toLowerCase() ?? '').toLowerCase().localeCompare((optionB?.label.toLowerCase() ?? '').toLowerCase())
}
onChange={setSelectedLang}
/>
<span>
{selectedLang}
</span>
{
selectedLang && <UploadButton
onSuccess={onUploadLRC}
/>
}
{
currentLangData && currentLangData?.value && <LyricsTextView
track={props.track}
lang={currentLangData}
/>
}
{
!currentLangData || !currentLangData?.value && <antd.Empty
description="No lyrics available"
/>
}
</div>
}
export default LyricsEditor

View File

@ -2,8 +2,10 @@ import React from "react"
import * as antd from "antd"
import axios from "axios"
import "./index.less"
const LyricsTextView = (props) => {
const { lang, track } = props
const { lrcURL } = props
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState(null)
@ -24,19 +26,19 @@ const LyricsTextView = (props) => {
return null
})
if (data) {
setLyrics(data.data)
setLyrics(data.data.split("\n"))
}
setLoading(false)
}
React.useEffect(() => {
getLyrics(lang.value)
}, [lang])
getLyrics(lrcURL)
}, [lrcURL])
if (!lang) {
if (!lrcURL) {
return null
}
@ -52,8 +54,21 @@ const LyricsTextView = (props) => {
return <antd.Skeleton active />
}
return <div>
<p>{lyrics}</p>
if (!lyrics) {
return <p>No lyrics provided</p>
}
return <div className="lyrics-text-view">
{
lyrics?.map((line, index) => {
return <div
key={index}
className="lyrics-text-view-line"
>
{line}
</div>
})
}
</div>
}

View File

@ -0,0 +1,15 @@
.lyrics-text-view {
display: flex;
flex-direction: column;
gap: 10px;
.lyrics-text-view-line {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
}

View File

@ -1,31 +1,39 @@
import React from "react"
import * as antd from "antd"
import { Icons } from "@components/Icons"
import { Icons, createIconRender } from "@components/Icons"
import MusicModel from "@models/music"
import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey"
import TrackManifest from "@classes/TrackManifest"
import { DefaultReleaseEditorState, ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
import Tabs from "./tabs"
import "./index.less"
console.log(MusicModel.deleteRelease)
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 [loading, setLoading] = React.useState(true)
const [loadError, setLoadError] = React.useState(null)
const [globalState, setGlobalState] = React.useState(DefaultReleaseEditorState)
const [selectedTab, setSelectedTab] = React.useState("info")
const [customPage, setCustomPage] = React.useState(null)
const [customPageActions, setCustomPageActions] = React.useState([])
const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
defaultKey: "info",
queryKey: "tab"
})
async function initialize() {
setLoading(true)
@ -33,7 +41,13 @@ const ReleaseEditor = (props) => {
if (release_id !== "new") {
try {
const releaseData = await MusicModel.getReleaseData(release_id)
let releaseData = await MusicModel.getReleaseData(release_id)
if (Array.isArray(releaseData.list)) {
releaseData.list = releaseData.list.map((item) => {
return new TrackManifest(item)
})
}
setGlobalState({
...globalState,
@ -47,21 +61,23 @@ const ReleaseEditor = (props) => {
setLoading(false)
}
async function renderCustomPage(page, actions) {
setCustomPage(page ?? null)
setCustomPageActions(actions ?? [])
}
async function handleSubmit() {
setSubmitting(true)
setSubmitError(null)
try {
// first sumbit tracks
console.time("submit:tracks:")
const tracks = await MusicModel.putTrack({
list: globalState.list,
})
console.timeEnd("submit:tracks:")
// then submit release
console.time("submit:release:")
await MusicModel.putRelease({
const result = await MusicModel.putRelease({
_id: globalState._id,
title: globalState.title,
description: globalState.description,
@ -71,7 +87,8 @@ const ReleaseEditor = (props) => {
type: globalState.type,
list: tracks.list,
})
console.timeEnd("submit:release:")
app.location.push(`/studio/music/${result._id}`)
} catch (error) {
console.error(error)
app.message.error(error.message)
@ -84,8 +101,6 @@ const ReleaseEditor = (props) => {
setSubmitting(false)
app.message.success("Release saved")
return release
}
async function handleDelete() {
@ -99,10 +114,6 @@ const ReleaseEditor = (props) => {
})
}
async function onFinish(values) {
console.log(values)
}
async function canFinish() {
return true
}
@ -125,10 +136,18 @@ const ReleaseEditor = (props) => {
const Tab = Tabs.find(({ key }) => key === selectedTab)
const CustomPageProps = {
close: () => {
renderCustomPage(null, null)
}
}
return <ReleaseEditorStateContext.Provider
value={{
...globalState,
setCustomPage,
setGlobalState,
renderCustomPage,
setCustomPageActions,
}}
>
<div className="music-studio-release-editor">
@ -139,29 +158,47 @@ const ReleaseEditor = (props) => {
<div className="music-studio-release-editor-custom-page-header-title">
<antd.Button
icon={<Icons.IoIosArrowBack />}
onClick={() => setCustomPage(null)}
onClick={() => renderCustomPage(null, null)}
/>
<h2>{customPage.header}</h2>
</div>
{
customPage.props?.onSave && <antd.Button
type="primary"
icon={<Icons.FiSave />}
onClick={() => customPage.props.onSave()}
>
Save
</antd.Button>
Array.isArray(customPageActions) && customPageActions.map((action, index) => {
return <antd.Button
key={index}
type={action.type}
icon={createIconRender(action.icon)}
onClick={async () => {
if (typeof action.onClick === "function") {
await action.onClick()
}
if (action.fireEvent) {
app.eventBus.emit(action.fireEvent)
}
}}
disabled={action.disabled}
>
{action.label}
</antd.Button>
})
}
</div>
}
{
React.cloneElement(customPage.content, {
...customPage.props,
close: () => setCustomPage(null),
})
customPage.content && (React.isValidElement(customPage.content) ?
React.cloneElement(customPage.content, {
...CustomPageProps,
...customPage.props
}) :
React.createElement(customPage.content, {
...CustomPageProps,
...customPage.props
})
)
}
</div>
}
@ -179,11 +216,11 @@ const ReleaseEditor = (props) => {
<antd.Button
type="primary"
onClick={handleSubmit}
icon={<Icons.FiSave />}
icon={release_id !== "new" ? <Icons.FiSave /> : <Icons.MdSend />}
disabled={submitting || loading || !canFinish()}
loading={submitting}
>
Save
{release_id !== "new" ? "Save" : "Release"}
</antd.Button>
{
@ -208,6 +245,12 @@ const ReleaseEditor = (props) => {
</div>
<div className="music-studio-release-editor-content">
{
submitError && <antd.Alert
message={submitError.message}
type="error"
/>
}
{
!Tab && <antd.Result
status="error"
@ -218,7 +261,6 @@ const ReleaseEditor = (props) => {
{
Tab && React.createElement(Tab.render, {
release: globalState,
onFinish: onFinish,
state: globalState,
setState: setGlobalState,

View File

@ -20,17 +20,19 @@ const TrackListItem = (props) => {
const { track } = props
async function onClickEditTrack() {
context.setCustomPage({
context.renderCustomPage({
header: "Track Editor",
content: <TrackEditor track={track} />,
content: <TrackEditor />,
props: {
onSave: (newTrackData) => {
console.log("Saving track", newTrackData)
},
track: track,
}
})
}
async function onClickRemoveTrack() {
props.onDelete(track.uid)
}
return <Draggable
key={track._id}
draggableId={track._id}
@ -43,12 +45,20 @@ const TrackListItem = (props) => {
"music-studio-release-editor-tracks-list-item",
{
["loading"]: loading,
["failed"]: !!error
["failed"]: !!error,
["disabled"]: props.disabled,
}
)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
className="music-studio-release-editor-tracks-list-item-progress"
style={{
"--upload-progress": `${props.progress}%`,
}}
/>
<div className="music-studio-release-editor-tracks-list-item-index">
<span>{props.index + 1}</span>
</div>
@ -65,10 +75,23 @@ const TrackListItem = (props) => {
<span>{track.title}</span>
<div className="music-studio-release-editor-tracks-list-item-actions">
<antd.Popconfirm
title="Are you sure you want to delete this track?"
onConfirm={onClickRemoveTrack}
okText="Yes"
disabled={props.disabled}
>
<antd.Button
type="ghost"
icon={<Icons.FiTrash2 />}
disabled={props.disabled}
/>
</antd.Popconfirm>
<antd.Button
type="ghost"
icon={<Icons.FiEdit2 />}
onClick={onClickEditTrack}
disabled={props.disabled}
/>
<div

View File

@ -14,6 +14,21 @@
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;

View File

@ -2,7 +2,8 @@ import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { DragDropContext, Droppable } from "react-beautiful-dnd"
import jsmediatags from "jsmediatags/dist/jsmediatags.min.js"
import TrackManifest from "@classes/TrackManifest"
import { Icons } from "@components/Icons"
@ -11,124 +12,14 @@ import UploadHint from "./components/UploadHint"
import "./index.less"
async function uploadBinaryArrayToStorage(bin, args) {
const { format, data } = bin
const filenameExt = format.split("/")[1]
const filename = `cover.${filenameExt}`
const byteArray = new Uint8Array(data)
const blob = new Blob([byteArray], { type: data.type })
// create a file object
const file = new File([blob], filename, {
type: format,
})
return await app.cores.remoteStorage.uploadFile(file, args)
}
class TrackManifest {
constructor(params) {
this.params = params
if (params.uid) {
this.uid = params.uid
}
if (params.cover) {
this.cover = params.cover
}
if (params.title) {
this.title = params.title
}
if (params.album) {
this.album = params.album
}
if (params.artist) {
this.artist = params.artist
}
if (params.source) {
this.source = params.source
}
return this
}
uid = null
cover = "https://storage.ragestudio.net/comty-static-assets/default_song.png"
title = "Untitled"
album = "Unknown"
artist = "Unknown"
source = null
async initialize() {
const metadata = await this.analyzeMetadata(this.params.file.originFileObj)
console.log(metadata)
if (metadata.tags) {
if (metadata.tags.title) {
this.title = metadata.tags.title
}
if (metadata.tags.artist) {
this.artist = metadata.tags.artist
}
if (metadata.tags.album) {
this.album = metadata.tags.album
}
if (metadata.tags.picture) {
const coverUpload = await uploadBinaryArrayToStorage(metadata.tags.picture)
this.cover = coverUpload.url
}
}
return this
}
analyzeMetadata = async (file) => {
return new Promise((resolve, reject) => {
jsmediatags.read(file, {
onSuccess: (data) => {
return resolve(data)
},
onError: (error) => {
return reject(error)
}
})
})
}
}
class TracksManager extends React.Component {
state = {
list: [],
list: Array.isArray(this.props.list) ? this.props.list : [],
pendingUploads: [],
}
componentDidMount() {
if (typeof this.props.list !== "undefined" && Array.isArray(this.props.list)) {
this.setState({
list: this.props.list
})
}
}
componentDidUpdate = (prevProps, prevState) => {
if (prevState.list !== this.state.list || prevState.pendingUploads !== this.state.pendingUploads) {
if (prevState.list !== this.state.list) {
if (typeof this.props.onChangeState === "function") {
this.props.onChangeState(this.state)
}
@ -158,12 +49,16 @@ class TracksManager extends React.Component {
return false
}
this.removeTrackUIDFromPendingUploads(uid)
this.setState({
list: this.state.list.filter((item) => item.uid !== uid),
})
}
modifyTrackByUid = (uid, track) => {
console.log("modifyTrackByUid", uid, track)
if (!uid || !track) {
return false
}
@ -187,9 +82,17 @@ class TracksManager extends React.Component {
return false
}
if (!this.state.pendingUploads.includes(uid)) {
const pendingUpload = this.state.pendingUploads.find((item) => item.uid === uid)
if (!pendingUpload) {
this.setState({
pendingUploads: [...this.state.pendingUploads, uid],
pendingUploads: [
...this.state.pendingUploads,
{
uid: uid,
progress: 0
}
],
})
}
}
@ -200,13 +103,43 @@ class TracksManager extends React.Component {
}
this.setState({
pendingUploads: this.state.pendingUploads.filter((item) => item !== uid),
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 0
}
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)
@ -214,23 +147,20 @@ class TracksManager extends React.Component {
const trackManifest = new TrackManifest({
uid: uid,
file: change.file,
onChange: this.modifyTrackByUid
})
this.addTrackToList(trackManifest)
const trackData = await trackManifest.initialize()
this.modifyTrackByUid(uid, trackData)
break
}
case "done": {
// remove pending file
this.removeTrackUIDFromPendingUploads(uid)
const trackIndex = this.state.list.findIndex((item) => item.uid === uid)
const trackManifest = this.state.list.find((item) => item.uid === uid)
if (trackIndex === -1) {
if (!trackManifest) {
console.error(`Track with uid [${uid}] not found!`)
break
}
@ -240,6 +170,8 @@ class TracksManager extends React.Component {
source: change.file.response.url
})
await trackManifest.initialize()
break
}
case "error": {
@ -278,7 +210,7 @@ class TracksManager extends React.Component {
}
handleTrackFileUploadProgress = async (file, progress) => {
console.log(file, progress)
this.updateUploadProgress(file.uid, progress)
}
orderTrackList = (result) => {
@ -301,6 +233,7 @@ class TracksManager extends React.Component {
render() {
console.log(`Tracks List >`, this.state.list)
return <div className="music-studio-release-editor-tracks">
<antd.Upload
className="music-studio-tracks-uploader"
@ -341,9 +274,15 @@ class TracksManager extends React.Component {
}
{
this.state.list.map((track, index) => {
const progress = this.getUploadProgress(track.uid)
return <TrackListItem
index={index}
track={track}
onEdit={this.modifyTrackByUid}
onDelete={this.removeTrackByUid}
progress={progress}
disabled={progress > 0}
/>
})
}

View File

@ -3,9 +3,7 @@ import * as antd from "antd"
import CoverEditor from "@components/CoverEditor"
import { Icons } from "@components/Icons"
import LyricsEditor from "@components/MusicStudio/LyricsEditor"
import VideoEditor from "@components/MusicStudio/VideoEditor"
import EnhancedLyricsEditor from "@components/MusicStudio/EnhancedLyricsEditor"
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
@ -24,43 +22,48 @@ const TrackEditor = (props) => {
})
}
async function openLyricsEditor() {
context.setCustomPage({
header: "Lyrics Editor",
content: <LyricsEditor track={track} />,
async function openEnhancedLyricsEditor() {
context.renderCustomPage({
header: "Enhanced Lyrics",
content: EnhancedLyricsEditor,
props: {
onSave: () => {
console.log("Saved lyrics")
},
track: track,
}
})
}
async function openVideoEditor() {
context.setCustomPage({
header: "Video Editor",
content: <VideoEditor track={track} />,
props: {
onSave: () => {
console.log("Saved video")
},
async function handleOnSave() {
setTrack((prev) => {
const listData = [...context.list]
const trackIndex = listData.findIndex((item) => item.uid === prev.uid)
if (trackIndex === -1) {
return prev
}
listData[trackIndex] = prev
context.setGlobalState({
...context,
list: listData
})
return prev
})
}
async function onClose() {
if (typeof props.close === "function") {
props.close()
}
}
async function onSave() {
await props.onSave(track)
if (typeof props.close === "function") {
props.close()
}
}
React.useEffect(() => {
context.setCustomPageActions([
{
label: "Save",
icon: "FiSave",
type: "primary",
onClick: handleOnSave,
disabled: props.track === track,
},
])
}, [track])
return <div className="track-editor">
<div className="track-editor-field">
@ -131,49 +134,32 @@ const TrackEditor = (props) => {
/>
</div>
<antd.Divider
style={{
margin: "5px 0",
}}
/>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.TbMovie />
<span>Edit Video</span>
<Icons.MdLyrics />
<span>Enhanced Lyrics</span>
<antd.Switch
checked={track.lyrics_enabled}
onChange={(value) => handleChange("lyrics_enabled", value)}
disabled={!track.params._id}
/>
</div>
<antd.Button
onClick={openVideoEditor}
>
Edit
</antd.Button>
</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.MdTextFormat />
<span>Edit Lyrics</span>
{
!track.params._id && <span>
You cannot edit Video and Lyrics without release first
</span>
}
</div>
<antd.Button
onClick={openLyricsEditor}
>
Edit
</antd.Button>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdTimeline />
<span>Timestamps</span>
</div>
<antd.Button
disabled
>
Edit
</antd.Button>
</div>
</div>
}

View File

@ -35,6 +35,8 @@
justify-content: flex-start;
align-items: center;
gap: 7px;
width: 100%;
h3 {
@ -46,8 +48,8 @@
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
justify-content: center;
gap: 10px;

View File

@ -1,14 +0,0 @@
import React from "react"
import * as antd from "antd"
import { Icons } from "@components/Icons"
import "./index.less"
const VideoEditor = (props) => {
return <div className="video-editor">
</div>
}
export default VideoEditor

View File

@ -1,190 +0,0 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import Marquee from "react-fast-marquee"
import { Icons } from "@components/Icons"
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
import "./index.less"
function RGBStringToValues(rgbString) {
if (!rgbString) {
return [0, 0, 0]
}
const rgb = rgbString.replace("rgb(", "").replace(")", "").split(",").map((v) => parseInt(v))
return [rgb[0], rgb[1], rgb[2]]
}
export default (props) => {
return <WithPlayerContext>
<BackgroundMediaPlayer
{...props}
/>
</WithPlayerContext>
}
export class BackgroundMediaPlayer extends React.Component {
static contextType = Context
state = {
expanded: false,
}
events = {
"sidebar.expanded": (to) => {
if (!to) {
this.toggleExpand(false)
}
}
}
onClickMinimize = () => {
app.cores.player.minimize()
}
toggleExpand = (to) => {
if (typeof to !== "boolean") {
to = !this.state.expanded
}
this.setState({
expanded: to
})
}
componentDidMount = async () => {
for (const [event, handler] of Object.entries(this.events)) {
app.eventBus.on(event, handler)
}
}
componentWillUnmount() {
for (const [event, handler] of Object.entries(this.events)) {
app.eventBus.off(event, handler)
}
}
render() {
return <li
className={classnames(
"background_media_player",
{
["lightBackground"]: this.context.track_manifest?.cover_analysis?.isLight ?? false,
["expanded"]: this.state.expanded,
}
)}
style={{
backgroundColor: this.context.track_manifest?.cover_analysis?.rgba,
"--averageColorValues": this.context.track_manifest?.cover_analysis?.rgba,
}}
>
<div
className="background_media_player__background"
style={{
backgroundImage: `url(${this.context.track_manifest?.cover ?? this.context.track_manifest?.thumbnail})`
}}
/>
<div
className="background_media_player__row"
onClick={this.toggleExpand}
>
<div
id="sidebar_item_icon"
className={classnames(
"background_media_player__icon",
{
["bounce"]: this.context.playback_status === "playing",
}
)}
>
{
this.context.playback_status === "playing" ? <Icons.MdMusicNote /> : <Icons.MdPause />
}
</div>
<div
id="sidebar_item_content"
className="background_media_player__title"
>
{
!this.state.expanded && <Marquee
gradientColor={RGBStringToValues(this.context.track_cover_analysis?.rgb)}
gradientWidth={20}
play={this.context.playback_status !== "stopped"}
>
<h4>
{
this.context.playback_status === "stopped" ? "Nothing is playing" : <>
{`${this.context.track_manifest?.title} - ${this.context.track_manifest?.artist}` ?? "Untitled"}
</>
}
</h4>
</Marquee>
}
{
this.state.expanded && <h4>
<Icons.MdAlbum />
{
this.context.playback_status === "stopped" ? "Nothing is playing" : <>
{this.context.track_manifest?.title ?? "Untitled"}
</>
}
</h4>
}
{/* {
this.state.expanded && <p>
{this.state.currentPlaying?.artist ?? "Unknown artist"}
</p>
} */}
</div>
</div>
<div
className={classnames(
"background_media_player__row",
"background_media_player__controls",
{
["hidden"]: !this.state.expanded,
}
)}
>
<antd.Button
size="small"
shape="rounded"
type="ghost"
icon={<Icons.ChevronLeft />}
onClick={app.cores.player.playback.previous}
/>
<antd.Button
size="small"
type="ghost"
shape="circle"
icon={this.context.playback_status === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
onClick={app.cores.player.playback.toggle}
/>
<antd.Button
size="small"
shape="rounded"
type="ghost"
icon={<Icons.FiChevronRight />}
onClick={app.cores.player.playback.next}
/>
<antd.Button
size="small"
shape="rounded"
type="ghost"
icon={<Icons.FiMinimize />}
onClick={this.onClickMinimize}
/>
</div>
</li>
}
}

View File

@ -1,239 +0,0 @@
@import "@styles/animations.less";
.background_media_player {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 40px;
padding: 10px;
overflow: hidden;
border-radius: 12px;
transition: all 150ms ease-in-out;
line-height: 40px;
color: var(--text-color-white);
background-repeat: no-repeat;
background-size: cover;
&.expanded {
height: 120px;
.background_media_player__background {
opacity: 0.4;
}
.background_media_player__icon {
width: 0;
opacity: 0;
}
.background_media_player__title {
padding: 10px 0;
h4 {
word-break: break-all;
word-wrap: break-word;
white-space: break-spaces;
font-size: 1rem;
}
p {
height: 100%;
}
}
.background_media_player__controls {
margin-top: 10px;
background-color: var(--text-color-white);
}
}
&.lightBackground {
color: var(--text-color-black);
.background_media_player__icon {
svg {
color: var(--text-color-black);
}
}
.background_media_player__title {
color: var(--text-color-black);
h4,
p {
color: var(--text-color-black);
}
}
.background_media_player__controls {
color: var(--text-color-black);
.ant-btn {
color: var(--text-color-black);
svg {
color: var(--text-color-black);
}
}
}
}
.background_media_player__background {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 12px;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
transition: all 150ms ease-in-out;
opacity: 0;
}
.background_media_player__row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
z-index: 350;
width: 100%;
&.hidden {
pointer-events: none;
opacity: 0;
height: 0;
}
}
.background_media_player__icon {
svg {
margin-right: 0 !important;
color: var(--text-color-white);
}
&.bounce {
animation: bounce 1s infinite;
}
}
.background_media_player__title {
display: flex;
flex-direction: column;
justify-content: center;
max-height: 67px;
height: 100%;
transition: all 150ms ease-in-out;
color: var(--text-color-white);
width: 100%;
height: 100%;
overflow: hidden;
//gap: 16px;
h4 {
line-height: 1rem;
font-size: 0.8rem;
font-weight: 600;
margin: 0 0 0 10px;
height: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-family: "Space Grotesk", sans-serif;
color: var(--text-color-white);
}
p {
line-height: 0.8rem;
font-size: 0.7rem;
font-weight: 400;
margin: 0 0 0 10px;
height: 0;
justify-content: flex-end;
font-family: "Space Grotesk", sans-serif;
color: var(--text-color-white);
}
.marquee-container {
width: 100%;
.overlay {
width: 100%;
}
}
}
.background_media_player__controls {
display: flex;
flex-direction: row;
align-self: flex-end;
align-items: center;
justify-content: space-evenly;
width: 100%;
color: var(--text-color-white);
background-color: var(--text-color-black);
border-radius: 12px;
opacity: 0.5;
.ant-btn {
color: var(--text-color-black);
background-color: transparent;
svg {
color: var(--text-color-black);
}
}
}
}

View File

@ -9,7 +9,7 @@ import LikeButton from "@components/LikeButton"
import AudioVolume from "@components/Player/AudioVolume"
import AudioPlayerChangeModeButton from "@components/Player/ChangeModeButton"
import { Context } from "@contexts/WithPlayerContext"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import "./index.less"
@ -17,9 +17,6 @@ const EventsHandlers = {
"playback": () => {
return app.cores.player.playback.toggle()
},
"like": async (ctx) => {
await app.cores.player.toggleCurrentTrackLike(!ctx.track_manifest?.liked)
},
"previous": () => {
return app.cores.player.playback.previous()
},
@ -27,98 +24,96 @@ const EventsHandlers = {
return app.cores.player.playback.next()
},
"volume": (ctx, value) => {
return app.cores.player.volume(value)
return app.cores.player.controls.volume(value)
},
"mute": () => {
return app.cores.player.toggleMute()
}
return app.cores.player.controls.mute("toggle")
},
"like": async (ctx) => {
await app.cores.player.toggleCurrentTrackLike(!ctx.track_manifest?.liked)
},
}
const Controls = (props) => {
try {
const ctx = React.useContext(Context)
const playerState = usePlayerStateContext()
const handleAction = (event, ...args) => {
if (typeof EventsHandlers[event] !== "function") {
throw new Error(`Unknown event "${event}"`)
}
return EventsHandlers[event](ctx, ...args)
const handleAction = (event, ...args) => {
if (typeof EventsHandlers[event] !== "function") {
throw new Error(`Unknown event "${event}"`)
}
return <div
className={
props.className ?? "player-controls"
}
>
<AudioPlayerChangeModeButton
disabled={ctx.control_locked}
/>
<antd.Button
type="ghost"
shape="round"
icon={<Icons.FiChevronLeft />}
onClick={() => handleAction("previous")}
disabled={ctx.control_locked}
/>
<antd.Button
type="primary"
shape="circle"
icon={ctx.livestream_mode ? <Icons.MdStop /> : ctx.playback_status === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
onClick={() => handleAction("playback")}
className="playButton"
disabled={ctx.control_locked}
>
{
ctx.loading && <div className="loadCircle">
<UseAnimations
animation={LoadingAnimation}
size="100%"
/>
</div>
}
</antd.Button>
<antd.Button
type="ghost"
shape="round"
icon={<Icons.FiChevronRight />}
onClick={() => handleAction("next")}
disabled={ctx.control_locked}
/>
{
app.isMobile && <LikeButton
onClick={() => handleAction("like")}
liked={ctx.track_manifest?.liked}
/>
}
{
!app.isMobile && <antd.Popover
content={React.createElement(
AudioVolume,
{
onChange: (value) => handleAction("volume", value),
defaultValue: ctx.volume
}
)}
trigger="hover"
>
<button
className="muteButton"
onClick={() => handleAction("mute")}
>
{
ctx.muted
? <Icons.FiVolumeX />
: <Icons.FiVolume2 />
}
</button>
</antd.Popover>
}
</div>
} catch (error) {
console.error(error)
return null
return EventsHandlers[event](playerState, ...args)
}
return <div
className={
props.className ?? "player-controls"
}
>
<AudioPlayerChangeModeButton
disabled={playerState.control_locked}
/>
<antd.Button
type="ghost"
shape="round"
icon={<Icons.FiChevronLeft />}
onClick={() => handleAction("previous")}
disabled={playerState.control_locked}
/>
<antd.Button
type="primary"
shape="circle"
icon={playerState.livestream_mode ? <Icons.MdStop /> : playerState.playback_status === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
onClick={() => handleAction("playback")}
className="playButton"
disabled={playerState.control_locked}
>
{
playerState.loading && <div className="loadCircle">
<UseAnimations
animation={LoadingAnimation}
size="100%"
/>
</div>
}
</antd.Button>
<antd.Button
type="ghost"
shape="round"
icon={<Icons.FiChevronRight />}
onClick={() => handleAction("next")}
disabled={playerState.control_locked}
/>
{
app.isMobile && <LikeButton
onClick={() => handleAction("like")}
liked={playerState.track_manifest?.liked}
/>
}
{
!app.isMobile && <antd.Popover
content={React.createElement(
AudioVolume,
{
onChange: (value) => handleAction("volume", value),
defaultValue: playerState.volume
}
)}
trigger="hover"
>
<button
className="muteButton"
onClick={() => handleAction("mute")}
>
{
playerState.muted
? <Icons.FiVolumeX />
: <Icons.FiVolume2 />
}
</button>
</antd.Popover>
}
</div>
}
export default Controls

View File

@ -4,13 +4,13 @@ import { Button } from "antd"
import { Icons } from "@components/Icons"
import LikeButton from "@components/LikeButton"
import { Context } from "@contexts/WithPlayerContext"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
const ExtraActions = (props) => {
const ctx = React.useContext(Context)
const playerState = usePlayerStateContext()
const handleClickLike = async () => {
await app.cores.player.toggleCurrentTrackLike(!ctx.track_manifest?.liked)
await app.cores.player.toggleCurrentTrackLike(!playerState.track_manifest?.liked)
}
return <div className="extra_actions">
@ -18,12 +18,12 @@ const ExtraActions = (props) => {
app.isMobile && <Button
type="ghost"
icon={<Icons.MdAbc />}
disabled={!ctx.track_manifest?.lyricsEnabled}
disabled={!playerState.track_manifest?.lyrics_enabled}
/>
}
{
!app.isMobile && <LikeButton
liked={ctx.track_manifest?.liked ?? false}
liked={playerState.track_manifest?.liked ?? false}
onClick={handleClickLike}
/>
}

View File

@ -19,20 +19,20 @@ export default class SeekBar extends React.Component {
handleSeek = (value) => {
if (value > 0) {
// calculate the duration of the audio
const duration = app.cores.player.duration()
const duration = app.cores.player.controls.duration()
// calculate the seek of the audio
const seek = (value / 100) * duration
app.cores.player.seek(seek)
app.cores.player.controls.seek(seek)
} else {
app.cores.player.seek(0)
app.cores.player.controls.seek(0)
}
}
calculateDuration = (preCalculatedDuration) => {
// get current audio duration
const audioDuration = preCalculatedDuration ?? app.cores.player.duration()
const audioDuration = preCalculatedDuration ?? app.cores.player.controls.duration()
if (isNaN(audioDuration)) {
return
@ -46,7 +46,7 @@ export default class SeekBar extends React.Component {
calculateTime = () => {
// get current audio seek
const seek = app.cores.player.seek()
const seek = app.cores.player.controls.seek()
// set time
this.setState({
@ -59,8 +59,8 @@ export default class SeekBar extends React.Component {
return
}
const seek = app.cores.player.seek()
const duration = app.cores.player.duration()
const seek = app.cores.player.controls.seek()
const duration = app.cores.player.controls.duration()
const percent = (seek / duration) * 100
@ -74,8 +74,6 @@ export default class SeekBar extends React.Component {
this.updateProgressBar()
}
eventBus = app.cores.player.eventBus
events = {
// handle when player changes playback status
"player.state.update:playback_status": (status) => {
@ -140,13 +138,13 @@ export default class SeekBar extends React.Component {
this.tick()
for (const [event, callback] of Object.entries(this.events)) {
this.eventBus.on(event, callback)
app.cores.player.eventBus().on(event, callback)
}
}
componentWillUnmount = () => {
for (const [event, callback] of Object.entries(this.events)) {
this.eventBus.off(event, callback)
app.cores.player.eventBus().off(event, callback)
}
}
@ -199,7 +197,7 @@ export default class SeekBar extends React.Component {
}}
valueLabelDisplay="auto"
valueLabelFormat={(value) => {
return seekToTimeLabel((value / 100) * app.cores.player.duration())
return seekToTimeLabel((value / 100) * app.cores.player.controls.duration())
}}
/>
</div>

View File

@ -4,7 +4,7 @@ import Marquee from "react-fast-marquee"
import classnames from "classnames"
import { Icons } from "@components/Icons"
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import SeekBar from "@components/Player/SeekBar"
import Controls from "@components/Player/Controls"
@ -43,7 +43,7 @@ const ServiceIndicator = (props) => {
}
const Player = (props) => {
const ctx = React.useContext(Context)
const playerState = usePlayerStateContext()
const contentRef = React.useRef()
const titleRef = React.useRef()
@ -64,16 +64,16 @@ const Player = (props) => {
const {
title,
album,
artist,
artistStr,
liked,
service,
lyricsEnabled,
lyrics_enabled,
cover_analysis,
cover,
} = ctx.track_manifest ?? {}
} = playerState.track_manifest ?? {}
const playing = ctx.playback_status === "playing"
const stopped = ctx.playback_status === "stopped"
const playing = playerState.playback_status === "playing"
const stopped = playerState.playback_status === "stopped"
const titleText = (!playing && stopped) ? "Stopped" : (title ?? "Untitled")
const subtitleText = ""
@ -89,7 +89,7 @@ const Player = (props) => {
"toolbar_player_wrapper",
{
"hover": topActionsVisible,
"minimized": ctx.minimized,
"minimized": playerState.minimized,
"cover_light": cover_analysis?.isLight,
}
)}
@ -106,7 +106,7 @@ const Player = (props) => {
)}
>
{
!ctx.control_locked && <antd.Button
!playerState.control_locked && <antd.Button
icon={<Icons.MdCast />}
shape="circle"
@ -114,7 +114,7 @@ const Player = (props) => {
}
{
lyricsEnabled && <antd.Button
lyrics_enabled && <antd.Button
icon={<Icons.MdLyrics />}
shape="circle"
onClick={() => app.location.push("/lyrics")}
@ -170,7 +170,7 @@ const Player = (props) => {
titleOverflown && <Marquee
gradientColor={RGBStringToValues(cover_analysis?.rgb)}
gradientWidth={20}
play={ctx.playback_status !== "stopped"}
play={playerState.playback_status !== "stopped"}
>
<h1
className="toolbar_player_info_title"
@ -185,7 +185,7 @@ const Player = (props) => {
}
<p className="toolbar_player_info_subtitle">
{artist ?? ""}
{artistStr ?? ""}
</p>
</div>
@ -193,10 +193,10 @@ const Player = (props) => {
<Controls />
<SeekBar
stopped={ctx.playback_status === "stopped"}
playing={ctx.playback_status === "playing"}
streamMode={ctx.livestream_mode}
disabled={ctx.control_locked}
stopped={playerState.playback_status === "stopped"}
playing={playerState.playback_status === "playing"}
streamMode={playerState.livestream_mode}
disabled={playerState.control_locked}
/>
<ExtraActions />
@ -206,10 +206,4 @@ const Player = (props) => {
</div>
}
const PlayerContextHandler = () => {
return <WithPlayerContext>
<Player />
</WithPlayerContext>
}
export default PlayerContextHandler
export default Player

View File

@ -57,7 +57,7 @@ const MoreActionsItems = [
},
]
export default (props) => {
const PostActions = (props) => {
const [isSelf, setIsSelf] = React.useState(false)
const {
@ -128,4 +128,6 @@ export default (props) => {
</div>
</div>
</div>
}
}
export default PostActions

View File

@ -41,6 +41,7 @@ export default (props) => {
handleOnStart(req.file.uid, req.file)
await app.cores.remoteStorage.uploadFile(req.file, {
headers: props.headers,
onProgress: (file, progress) => {
setProgess(progress)
handleOnProgress(file.uid, progress)

View File

@ -0,0 +1,79 @@
import React from "react"
import HLS from "hls.js"
import Plyr from "plyr"
import "plyr-react/dist/plyr.css"
import "./index.less"
const VideoPlayer = (props) => {
const videoRef = React.createRef()
const [initializing, setInitializing] = React.useState(true)
const [player, setPlayer] = React.useState(null)
const [hls, setHls] = React.useState(null)
React.useEffect(() => {
setInitializing(true)
const hlsInstance = new HLS()
const plyrInstance = new Plyr(videoRef.current, {
controls: props.controls ?? [
"current-time",
"mute",
"volume",
"captions",
"settings",
"pip",
"airplay",
"fullscreen"
],
settings: ["quality", "speed"],
quality: {
default: 1080,
options: [
{ label: "Auto", value: "auto" },
{ label: "1080p", value: 1080 },
{ label: "720p", value: 720 },
{ label: "480p", value: 480 },
{ label: "360p", value: 360 },
{ label: "240p", value: 240 },
]
}
})
setHls(hlsInstance)
setPlayer(plyrInstance)
hlsInstance.attachMedia(videoRef.current)
hlsInstance.loadSource(props.src)
hlsInstance.on(HLS.Events.MANIFEST_PARSED, (event, data) => {
console.log(event, data)
plyrInstance.set
})
setInitializing(false)
return () => {
hlsInstance.destroy()
}
}, [])
React.useEffect(() => {
if (hls) {
hls.loadSource(props.src)
}
}, [props.src])
return <div className="video-player">
<video
ref={videoRef}
className="video-player-component"
controls={props.controls}
/>
</div>
}
export default VideoPlayer

View File

@ -0,0 +1,16 @@
.video-player {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 12px;
.video-player-component {
width: 100%;
height: 100%;
}
}

View File

@ -1,42 +1,52 @@
import React from "react"
export const DefaultContextValues = {
loading: false,
minimized: false,
function deepUnproxy(obj) {
// Verificar si es un array y hacer una copia en consecuencia
if (Array.isArray(obj)) {
obj = [...obj];
} else {
obj = Object.assign({}, obj);
}
muted: false,
volume: 1,
for (let key in obj) {
if (obj[key] && typeof obj[key] === "object") {
obj[key] = deepUnproxy(obj[key]); // Recursión para profundizar en objetos y arrays
}
}
sync_mode: false,
livestream_mode: false,
control_locked: false,
track_cover_analysis: null,
track_metadata: null,
playback_mode: "repeat",
playback_status: null,
return obj;
}
export const Context = React.createContext(DefaultContextValues)
export const usePlayerStateContext = (updater) => {
const [state, setState] = React.useState({ ...app.cores.player.state })
function handleStateChange(newState) {
newState = deepUnproxy(newState)
setState(newState)
if (typeof updater === "function") {
updater(newState)
}
}
React.useEffect(() => {
handleStateChange(app.cores.player.state)
app.cores.player.eventBus().on("player.state.update", handleStateChange)
return () => {
app.cores.player.eventBus().off("player.state.update", handleStateChange)
}
}, [])
return state
}
export const Context = React.createContext({})
export class WithPlayerContext extends React.Component {
state = {
loading: app.cores.player.state["loading"],
minimized: app.cores.player.state["minimized"],
muted: app.cores.player.state["muted"],
volume: app.cores.player.state["volume"],
sync_mode: app.cores.player.state["sync_mode"],
livestream_mode: app.cores.player.state["livestream_mode"],
control_locked: app.cores.player.state["control_locked"],
track_manifest: app.cores.player.state["track_manifest"],
playback_mode: app.cores.player.state["playback_mode"],
playback_status: app.cores.player.state["playback_status"],
}
state = app.cores.player.state
events = {
"player.state.update": (state) => {
@ -44,17 +54,15 @@ export class WithPlayerContext extends React.Component {
},
}
eventBus = app.cores.player.eventBus
componentDidMount() {
for (const [event, handler] of Object.entries(this.events)) {
this.eventBus.on(event, handler)
app.cores.player.eventBus().on(event, handler)
}
}
componentWillUnmount() {
for (const [event, handler] of Object.entries(this.events)) {
this.eventBus.off(event, handler)
app.cores.player.eventBus().off(event, handler)
}
}

View File

@ -26,7 +26,7 @@
padding: 4px;
font-weight: 600;
font-weight: 450;
font-family: var(--fontFamily);
font-size: 0.8rem;
color: var(--text-color);

View File

@ -0,0 +1,85 @@
import defaultAudioProccessors from "../processors"
export default class PlayerProcessors {
constructor(player) {
this.player = player
}
processors = []
public = {}
async initialize() {
// if already exists audio processors, destroy all before create new
if (this.processors.length > 0) {
this.player.console.log("Destroying audio processors")
this.processors.forEach((processor) => {
this.player.console.log(`Destroying audio processor ${processor.constructor.name}`, processor)
processor._destroy()
})
this.processors = []
}
// instanciate default audio processors
for await (const defaultProccessor of defaultAudioProccessors) {
this.processors.push(new defaultProccessor(this.player))
}
// initialize audio processors
for await (const processor of this.processors) {
if (typeof processor._init === "function") {
try {
await processor._init(this.player.audioContext)
} catch (error) {
this.player.console.error(`Failed to initialize audio processor ${processor.constructor.name} >`, error)
continue
}
}
// check if processor has exposed public methods
if (processor.exposeToPublic) {
Object.entries(processor.exposeToPublic).forEach(([key, value]) => {
const refName = processor.constructor.refName
if (typeof this.public[refName] === "undefined") {
// by default create a empty object
this.player.public[refName] = {}
}
this.player.public[refName][key] = value
})
}
}
}
async attachProcessorsToInstance(instance) {
this.player.console.log(instance, this.processors)
for await (const [index, processor] of this.processors.entries()) {
if (processor.constructor.node_bypass === true) {
instance.contextElement.connect(processor.processor)
processor.processor.connect(this.player.audioContext.destination)
continue
}
if (typeof processor._attach !== "function") {
this.player.console.error(`Processor ${processor.constructor.refName} not support attach`)
continue
}
instance = await processor._attach(instance, index)
}
const lastProcessor = instance.attachedProcessors[instance.attachedProcessors.length - 1].processor
// now attach to destination
lastProcessor.connect(this.player.audioContext.destination)
return instance
}
}

View File

@ -0,0 +1,37 @@
import { Observable } from "object-observer"
import AudioPlayerStorage from "../player.storage"
export default class PlayerState {
static defaultState = {
loading: false,
playback_status: "stopped",
track_manifest: null,
muted: app.isMobile ? false : (AudioPlayerStorage.get("mute") ?? false),
volume: app.isMobile ? 1 : (AudioPlayerStorage.get("volume") ?? 0.3),
playback_mode: AudioPlayerStorage.get("mode") ?? "normal",
}
constructor(player) {
this.player = player
this.state = Observable.from(PlayerState.defaultState)
Observable.observe(this.state, async (changes) => {
try {
changes.forEach((change) => {
if (change.type === "update") {
const stateKey = change.path[0]
this.player.eventBus.emit(`player.state.update:${stateKey}`, change.object[stateKey])
this.player.eventBus.emit("player.state.update", change.object)
}
})
} catch (error) {
this.player.console.error(`Failed to dispatch state updater >`, error)
}
})
return this.state
}
}

View File

@ -0,0 +1,40 @@
import ToolBarPlayer from "@components/Player/ToolBarPlayer"
export default class PlayerUI {
constructor(player) {
this.player = player
}
currentDomWindow = null
//
// UI Methods
//
attachPlayerComponent() {
if (this.currentDomWindow) {
this.player.console.warn("EmbbededMediaPlayer already attached")
return false
}
if (app.layout.tools_bar) {
this.currentDomWindow = app.layout.tools_bar.attachRender("mediaPlayer", ToolBarPlayer)
}
}
detachPlayerComponent() {
if (!this.currentDomWindow) {
this.player.console.warn("EmbbededMediaPlayer not attached")
return false
}
if (!app.layout.tools_bar) {
this.player.console.error("Tools bar not found")
return false
}
app.layout.tools_bar.detachRender("mediaPlayer")
this.currentDomWindow = null
}
}

View File

@ -1,4 +1,4 @@
import AudioPlayerStorage from "./player.storage"
import AudioPlayerStorage from "../player.storage"
export default class Presets {
constructor({

View File

@ -0,0 +1,37 @@
import AudioPlayerStorage from "../player.storage.js"
export default async (sampleRate) => {
// must be a integer
if (typeof sampleRate !== "number") {
this.console.error("Sample rate must be a number")
return null
}
// must be a integer
if (!Number.isInteger(sampleRate)) {
this.console.error("Sample rate must be a integer")
return null
}
return await new Promise((resolve) => {
app.confirm({
title: "Change sample rate",
content: `To change the sample rate, the app needs to be reloaded. Do you want to continue?`,
onOk: () => {
try {
AudioPlayerStorage.set("sample_rate", sampleRate)
app.navigation.reload()
return resolve(sampleRate)
} catch (error) {
app.message.error(`Failed to change sample rate, ${error.message}`)
return resolve(null)
}
},
onCancel: () => {
return resolve(null)
}
})
})
}

View File

@ -1,946 +0,0 @@
import { Core, EventBus } from "vessel"
import { Observable } from "object-observer"
import { FastAverageColor } from "fast-average-color"
import MusicModel from "comty.js/models/music"
import ToolBarPlayer from "@components/Player/ToolBarPlayer"
import BackgroundMediaPlayer from "@components/Player/BackgroundMediaPlayer"
import AudioPlayerStorage from "./player.storage"
import defaultAudioProccessors from "./processors"
import MediaSession from "./mediaSession"
import ServiceProviders from "./services"
export default class Player extends Core {
static dependencies = [
"api",
"settings"
]
static namespace = "player"
static bgColor = "aquamarine"
static textColor = "black"
static defaultSampleRate = 48000
static gradualFadeMs = 150
// buffer & precomputation
static maxManifestPrecompute = 3
service_providers = new ServiceProviders()
native_controls = new MediaSession()
currentDomWindow = null
audioContext = new AudioContext({
sampleRate: AudioPlayerStorage.get("sample_rate") ?? Player.defaultSampleRate,
latencyHint: "playback"
})
audioProcessors = []
eventBus = new EventBus()
fac = new FastAverageColor()
track_prev_instances = []
track_instance = null
track_next_instances = []
state = Observable.from({
loading: false,
minimized: false,
muted: app.isMobile ? false : (AudioPlayerStorage.get("mute") ?? false),
volume: app.isMobile ? 1 : (AudioPlayerStorage.get("volume") ?? 0.3),
sync_mode: false,
livestream_mode: false,
control_locked: false,
track_manifest: null,
playback_mode: AudioPlayerStorage.get("mode") ?? "normal",
playback_status: "stopped",
})
public = {
audioContext: this.audioContext,
setSampleRate: this.setSampleRate,
start: this.start.bind(this),
close: this.close.bind(this),
playback: {
mode: this.playbackMode.bind(this),
stop: this.stop.bind(this),
toggle: this.togglePlayback.bind(this),
pause: this.pausePlayback.bind(this),
play: this.resumePlayback.bind(this),
next: this.next.bind(this),
previous: this.previous.bind(this),
seek: this.seek.bind(this),
},
_setLoading: function (to) {
this.state.loading = !!to
}.bind(this),
duration: this.duration.bind(this),
volume: this.volume.bind(this),
mute: this.mute.bind(this),
toggleMute: this.toggleMute.bind(this),
seek: this.seek.bind(this),
minimize: this.toggleMinimize.bind(this),
collapse: this.toggleCollapse.bind(this),
state: new Proxy(this.state, {
get: (target, prop) => {
return target[prop]
},
set: (target, prop, value) => {
return false
}
}),
eventBus: new Proxy(this.eventBus, {
get: (target, prop) => {
return target[prop]
},
set: (target, prop, value) => {
return false
}
}),
gradualFadeMs: Player.gradualFadeMs,
trackInstance: () => {
return this.track_instance
}
}
internalEvents = {
"player.state.update:loading": () => {
//app.cores.sync.music.dispatchEvent("music.player.state.update", this.state)
},
"player.state.update:track_manifest": () => {
//app.cores.sync.music.dispatchEvent("music.player.state.update", this.state)
},
"player.state.update:playback_status": () => {
//app.cores.sync.music.dispatchEvent("music.player.state.update", this.state)
},
"player.seeked": (to) => {
//app.cores.sync.music.dispatchEvent("music.player.seek", to)
},
}
async onInitialize() {
this.native_controls.initialize()
this.initializeAudioProcessors()
for (const [eventName, eventHandler] of Object.entries(this.internalEvents)) {
this.eventBus.on(eventName, eventHandler)
}
Observable.observe(this.state, async (changes) => {
try {
changes.forEach((change) => {
if (change.type === "update") {
const stateKey = change.path[0]
this.eventBus.emit(`player.state.update:${stateKey}`, change.object[stateKey])
this.eventBus.emit("player.state.update", change.object)
}
})
} catch (error) {
this.console.error(`Failed to dispatch state updater >`, error)
}
})
}
async initializeBeforeRuntimeInitialize() {
for (const [eventName, eventHandler] of Object.entries(this.wsEvents)) {
app.cores.api.listenEvent(eventName, eventHandler, Player.websocketListen)
}
if (app.isMobile) {
this.state.audioVolume = 1
}
}
async initializeAudioProcessors() {
if (this.audioProcessors.length > 0) {
this.console.log("Destroying audio processors")
this.audioProcessors.forEach((processor) => {
this.console.log(`Destroying audio processor ${processor.constructor.name}`, processor)
processor._destroy()
})
this.audioProcessors = []
}
for await (const defaultProccessor of defaultAudioProccessors) {
this.audioProcessors.push(new defaultProccessor(this))
}
for await (const processor of this.audioProcessors) {
if (typeof processor._init === "function") {
try {
await processor._init(this.audioContext)
} catch (error) {
this.console.error(`Failed to initialize audio processor ${processor.constructor.name} >`, error)
continue
}
}
// check if processor has exposed public methods
if (processor.exposeToPublic) {
Object.entries(processor.exposeToPublic).forEach(([key, value]) => {
const refName = processor.constructor.refName
if (typeof this.public[refName] === "undefined") {
// by default create a empty object
this.public[refName] = {}
}
this.public[refName][key] = value
})
}
}
}
//
// UI Methods
//
attachPlayerComponent() {
if (this.currentDomWindow) {
this.console.warn("EmbbededMediaPlayer already attached")
return false
}
if (app.layout.tools_bar) {
this.currentDomWindow = app.layout.tools_bar.attachRender("mediaPlayer", ToolBarPlayer)
}
}
detachPlayerComponent() {
if (!this.currentDomWindow) {
this.console.warn("EmbbededMediaPlayer not attached")
return false
}
if (!app.layout.tools_bar) {
this.console.error("Tools bar not found")
return false
}
app.layout.tools_bar.detachRender("mediaPlayer")
this.currentDomWindow = null
}
//
// Instance managing methods
//
async abortPreloads() {
for await (const instance of this.track_next_instances) {
if (instance.abortController?.abort) {
instance.abortController.abort()
}
}
}
async preloadAudioInstance(instance) {
const isIndex = typeof instance === "number"
let index = isIndex ? instance : 0
if (isIndex) {
instance = this.track_next_instances[instance]
}
if (!instance) {
this.console.error("Instance not found to preload")
return false
}
if (!instance.manifest.cover_analysis) {
const cover_analysis = await this.fac.getColorAsync(`https://corsproxy.io/?${encodeURIComponent(instance.manifest.cover ?? instance.manifest.thumbnail)}`)
.catch((err) => {
this.console.error(err)
return false
})
instance.manifest.cover_analysis = cover_analysis
}
if (!instance._preloaded) {
instance.media.preload = "metadata"
instance._preloaded = true
}
if (isIndex) {
this.track_next_instances[index] = instance
}
return instance
}
async destroyCurrentInstance({ sync = false } = {}) {
if (!this.track_instance) {
return false
}
// stop playback
if (this.track_instance.media) {
this.track_instance.media.pause()
}
// reset track_instance
this.track_instance = null
// reset livestream mode
this.state.livestream_mode = false
}
async createInstance(manifest) {
if (!manifest) {
this.console.error("Manifest is required")
return false
}
if (typeof manifest === "string") {
manifest = {
src: manifest,
}
}
// check if manifest has `manifest` property, if is and not inherit or missing source, resolve
if (manifest.service) {
if (!this.service_providers.has(manifest.service)) {
this.console.error(`Service ${manifest.service} is not supported`)
return false
}
if (manifest.service !== "inherit" && !manifest.source) {
manifest = await this.service_providers.resolve(manifest.service, manifest)
}
}
if (!manifest.src && !manifest.source) {
this.console.error("Manifest source is required")
return false
}
const source = manifest.src ?? manifest.source
if (!manifest.metadata) {
manifest.metadata = {}
}
// if title is not set, use the audio source filename
if (!manifest.metadata.title) {
manifest.metadata.title = source.split("/").pop()
}
let instance = {
manifest: manifest,
attachedProcessors: [],
abortController: new AbortController(),
source: source,
media: new Audio(source),
duration: null,
seek: 0,
track: null,
}
instance.media.signal = instance.abortController.signal
instance.media.crossOrigin = "anonymous"
instance.media.preload = "metadata"
instance.media.loop = this.state.playback_mode === "repeat"
instance.media.volume = this.state.volume
// handle on end
instance.media.addEventListener("ended", () => {
this.next()
})
instance.media.addEventListener("loadeddata", () => {
this.state.loading = false
})
// update playback status
instance.media.addEventListener("play", () => {
this.state.playback_status = "playing"
})
instance.media.addEventListener("playing", () => {
this.state.loading = false
this.state.playback_status = "playing"
if (this.waitUpdateTimeout) {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
})
instance.media.addEventListener("pause", () => {
this.state.playback_status = "paused"
})
instance.media.addEventListener("durationchange", (duration) => {
if (instance.media.paused) {
return false
}
instance.duration = duration
})
instance.media.addEventListener("waiting", () => {
if (instance.media.paused) {
return false
}
if (this.waitUpdateTimeout) {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
// if takes more than 150ms to load, update loading state
this.waitUpdateTimeout = setTimeout(() => {
this.state.loading = true
}, 150)
})
instance.media.addEventListener("seeked", () => {
this.console.log(`Seeked to ${instance.seek}`)
this.eventBus.emit(`player.seeked`, instance.seek)
})
instance.media.addEventListener("loadedmetadata", () => {
if (instance.media.duration === Infinity) {
instance.manifest.stream = true
this.state.livestream_mode = true
}
}, { once: true })
instance.track = this.audioContext.createMediaElementSource(instance.media)
return instance
}
async attachProcessorsToInstance(instance) {
for await (const [index, processor] of this.audioProcessors.entries()) {
if (processor.constructor.node_bypass === true) {
instance.track.connect(processor.processor)
processor.processor.connect(this.audioContext.destination)
continue
}
if (typeof processor._attach !== "function") {
this.console.error(`Processor ${processor.constructor.refName} not support attach`)
continue
}
instance = await processor._attach(instance, index)
}
const lastProcessor = instance.attachedProcessors[instance.attachedProcessors.length - 1].processor
// now attach to destination
lastProcessor.connect(this.audioContext.destination)
return instance
}
//
// Playback methods
//
async play(instance, params = {}) {
if (typeof instance === "number") {
if (instance < 0) {
instance = this.track_prev_instances[instance]
}
if (instance > 0) {
instance = this.track_instances[instance]
}
if (instance === 0) {
instance = this.track_instance
}
}
if (!instance) {
throw new Error("Audio instance is required")
}
if (this.audioContext.state === "suspended") {
this.audioContext.resume()
}
if (this.track_instance) {
this.track_instance = this.track_instance.attachedProcessors[this.track_instance.attachedProcessors.length - 1]._destroy(this.track_instance)
this.destroyCurrentInstance()
}
// attach processors
instance = await this.attachProcessorsToInstance(instance)
// now set the current instance
this.track_instance = await this.preloadAudioInstance(instance)
// reconstruct audio src if is not set
if (this.track_instance.media.src !== instance.source) {
this.track_instance.media.src = instance.source
}
// set time to 0
this.track_instance.media.currentTime = 0
if (params.time >= 0) {
this.track_instance.media.currentTime = params.time
}
this.track_instance.media.muted = this.state.muted
this.track_instance.media.loop = this.state.playback_mode === "repeat"
// try to preload next audio
// TODO: Use a better way to preload queues
if (this.track_next_instances.length > 0) {
this.preloadAudioInstance(1)
}
// play
await this.track_instance.media.play()
this.console.debug(`Playing track >`, this.track_instance)
// update manifest
this.state.track_manifest = instance.manifest
this.native_controls.update(instance.manifest)
return this.track_instance
}
async start(manifest, { sync = false, time, startIndex = 0 } = {}) {
if (this.state.control_locked && !sync) {
this.console.warn("Controls are locked, cannot do this action")
return false
}
this.attachPlayerComponent()
// !IMPORTANT: abort preloads before destroying current instance
await this.abortPreloads()
await this.destroyCurrentInstance({
sync
})
this.state.loading = true
this.track_prev_instances = []
this.track_next_instances = []
let playlist = Array.isArray(manifest) ? manifest : [manifest]
if (playlist.length === 0) {
this.console.warn(`[PLAYER] Playlist is empty, aborting...`)
return false
}
if (playlist.some((item) => typeof item === "string")) {
playlist = await this.service_providers.resolveMany(playlist)
}
playlist = playlist.slice(startIndex)
for await (const [index, _manifest] of playlist.entries()) {
const instance = await this.createInstance(_manifest)
this.track_next_instances.push(instance)
if (index === 0) {
this.play(this.track_next_instances[0], {
time: time ?? 0
})
}
}
return manifest
}
next({ sync = false } = {}) {
if (this.state.control_locked && !sync) {
//this.console.warn("Sync mode is locked, cannot do this action")
return false
}
if (this.track_next_instances.length > 0) {
// move current audio instance to history
this.track_prev_instances.push(this.track_next_instances.shift())
}
if (this.track_next_instances.length === 0) {
this.console.log(`[PLAYER] No more tracks to play, stopping...`)
return this.stop()
}
let nextIndex = 0
if (this.state.playback_mode === "shuffle") {
nextIndex = Math.floor(Math.random() * this.track_next_instances.length)
}
this.play(this.track_next_instances[nextIndex])
}
previous({ sync = false } = {}) {
if (this.state.control_locked && !sync) {
//this.console.warn("Sync mode is locked, cannot do this action")
return false
}
if (this.track_prev_instances.length > 0) {
// move current audio instance to history
this.track_next_instances.unshift(this.track_prev_instances.pop())
return this.play(this.track_next_instances[0])
}
if (this.track_prev_instances.length === 0) {
this.console.log(`[PLAYER] No previous tracks, replying...`)
// replay the current track
return this.play(this.track_instance)
}
}
async togglePlayback() {
if (this.state.playback_status === "paused") {
await this.resumePlayback()
} else {
await this.pausePlayback()
}
}
async pausePlayback() {
return await new Promise((resolve, reject) => {
if (!this.track_instance) {
this.console.error("No audio instance")
return null
}
// set gain exponentially
this.track_instance.gainNode.gain.linearRampToValueAtTime(
0.0001,
this.audioContext.currentTime + (Player.gradualFadeMs / 1000)
)
setTimeout(() => {
this.track_instance.media.pause()
resolve()
}, Player.gradualFadeMs)
this.native_controls.updateIsPlaying(false)
})
}
async resumePlayback() {
if (!this.state.playback_status === "playing") {
return true
}
return await new Promise((resolve, reject) => {
if (!this.track_instance) {
this.console.error("No audio instance")
return null
}
// ensure audio elemeto starts from 0 volume
this.track_instance.gainNode.gain.value = 0.0001
this.track_instance.media.play().then(() => {
resolve()
})
// set gain exponentially
this.track_instance.gainNode.gain.linearRampToValueAtTime(
this.state.volume,
this.audioContext.currentTime + (Player.gradualFadeMs / 1000)
)
this.native_controls.updateIsPlaying(true)
})
}
stop() {
this.destroyCurrentInstance()
this.abortPreloads()
this.state.playback_status = "stopped"
this.state.track_manifest = null
this.state.livestream_mode = false
this.track_instance = null
this.track_next_instances = []
this.track_prev_instances = []
this.native_controls.destroy()
}
mute(to) {
if (app.isMobile && typeof to !== "boolean") {
this.console.warn("Cannot mute on mobile")
return false
}
if (typeof to === "boolean") {
this.state.muted = to
this.track_instance.media.muted = to
}
return this.state.muted
}
volume(volume) {
if (typeof volume !== "number") {
return this.state.volume
}
if (app.isMobile) {
this.console.warn("Cannot change volume on mobile")
return false
}
if (volume > 1) {
if (!app.cores.settings.get("player.allowVolumeOver100")) {
volume = 1
}
}
if (volume < 0) {
volume = 0
}
this.state.volume = volume
AudioPlayerStorage.set("volume", volume)
if (this.track_instance) {
if (this.track_instance.gainNode) {
this.track_instance.gainNode.gain.value = this.state.volume
}
}
return this.state.volume
}
seek(time, { sync = false } = {}) {
if (!this.track_instance || !this.track_instance.media) {
return false
}
// if time not provided, return current time
if (typeof time === "undefined") {
return this.track_instance.media.currentTime
}
if (this.state.control_locked && !sync) {
this.console.warn("Sync mode is locked, cannot do this action")
return false
}
// if time is provided, seek to that time
if (typeof time === "number") {
this.console.log(`Seeking to ${time} | Duration: ${this.track_instance.media.duration}`)
this.track_instance.media.currentTime = time
return time
}
}
playbackMode(mode) {
if (typeof mode !== "string") {
return this.state.playback_mode
}
this.state.playback_mode = mode
if (this.track_instance) {
this.track_instance.media.loop = this.state.playback_mode === "repeat"
}
AudioPlayerStorage.set("mode", mode)
return mode
}
duration() {
if (!this.track_instance) {
return false
}
return this.track_instance.media.duration
}
loop(to) {
if (typeof to !== "boolean") {
this.console.warn("Loop must be a boolean")
return false
}
this.state.loop = to ?? !this.state.loop
if (this.track_instance.media) {
this.track_instance.media.loop = this.state.loop
}
return this.state.loop
}
close() {
this.stop()
this.detachPlayerComponent()
}
toggleMinimize(to) {
this.state.minimized = to ?? !this.state.minimized
if (this.state.minimized) {
app.layout.sidebar.attachBottomItem("player", BackgroundMediaPlayer, {
noContainer: true
})
} else {
app.layout.sidebar.removeBottomItem("player")
}
return this.state.minimized
}
toggleCollapse(to) {
if (typeof to !== "boolean") {
this.console.warn("Collapse must be a boolean")
return false
}
this.state.collapsed = to ?? !this.state.collapsed
return this.state.collapsed
}
toggleSyncMode(to, lock) {
if (typeof to !== "boolean") {
this.console.warn("Sync mode must be a boolean")
return false
}
this.state.syncMode = to ?? !this.state.syncMode
this.state.syncModeLocked = lock ?? false
this.console.log(`Sync mode is now ${this.state.syncMode ? "enabled" : "disabled"} | Locked: ${this.state.syncModeLocked ? "yes" : "no"}`)
return this.state.syncMode
}
toggleMute(to) {
if (typeof to !== "boolean") {
to = !this.state.muted
}
return this.mute(to)
}
async getTracksByIds(list) {
if (!Array.isArray(list)) {
this.console.warn("List must be an array")
return false
}
let ids = []
list.forEach((item) => {
if (typeof item === "string") {
ids.push(item)
}
})
if (ids.length === 0) {
return list
}
const fetchedTracks = await MusicModel.getTracksData(ids).catch((err) => {
this.console.error(err)
return false
})
if (!fetchedTracks) {
return list
}
// replace fetched tracks with the ones in the list
fetchedTracks.forEach((fetchedTrack) => {
const index = list.findIndex((item) => item === fetchedTrack._id)
if (index !== -1) {
list[index] = fetchedTrack
}
})
return list
}
async setSampleRate(to) {
// must be a integer
if (typeof to !== "number") {
this.console.error("Sample rate must be a number")
return this.audioContext.sampleRate
}
// must be a integer
if (!Number.isInteger(to)) {
this.console.error("Sample rate must be a integer")
return this.audioContext.sampleRate
}
return await new Promise((resolve, reject) => {
app.confirm({
title: "Change sample rate",
content: `To change the sample rate, the app needs to be reloaded. Do you want to continue?`,
onOk: () => {
try {
this.audioContext = new AudioContext({ sampleRate: to })
AudioPlayerStorage.set("sample_rate", to)
app.navigation.reload()
return resolve(this.audioContext.sampleRate)
} catch (error) {
app.message.error(`Failed to change sample rate, ${error.message}`)
return resolve(this.audioContext.sampleRate)
}
},
onCancel: () => {
return resolve(this.audioContext.sampleRate)
}
})
})
}
}

View File

@ -1,243 +1,84 @@
import { Core, EventBus } from "vessel"
import { Observable } from "object-observer"
import { FastAverageColor } from "fast-average-color"
import { Core } from "vessel"
import ToolBarPlayer from "@components/Player/ToolBarPlayer"
import BackgroundMediaPlayer from "@components/Player/BackgroundMediaPlayer"
import TrackInstance from "@classes/TrackInstance"
import MediaSession from "./classes/MediaSession"
import ServiceProviders from "./classes/Services"
import PlayerState from "./classes/PlayerState"
import PlayerUI from "./classes/PlayerUI"
import PlayerProcessors from "./classes/PlayerProcessors"
import setSampleRate from "./helpers/setSampleRate"
import AudioPlayerStorage from "./player.storage"
import TrackInstanceClass from "./classes/TrackInstance"
import defaultAudioProccessors from "./processors"
import MediaSession from "./mediaSession"
import ServiceProviders from "./services"
export default class Player extends Core {
// core config
static dependencies = [
"api",
"settings"
]
static namespace = "player"
static bgColor = "aquamarine"
static textColor = "black"
// player config
static defaultSampleRate = 48000
static gradualFadeMs = 150
// buffer & precomputation
static maxManifestPrecompute = 3
state = new PlayerState(this)
ui = new PlayerUI(this)
service_providers = new ServiceProviders()
native_controls = new MediaSession()
currentDomWindow = null
audioContext = new AudioContext({
sampleRate: AudioPlayerStorage.get("sample_rate") ?? Player.defaultSampleRate,
latencyHint: "playback"
})
audioProcessors = []
eventBus = new EventBus()
fac = new FastAverageColor()
audioProcessors = new PlayerProcessors(this)
track_prev_instances = []
track_instance = null
track_next_instances = []
state = Observable.from({
loading: false,
minimized: false,
muted: app.isMobile ? false : (AudioPlayerStorage.get("mute") ?? false),
volume: app.isMobile ? 1 : (AudioPlayerStorage.get("volume") ?? 0.3),
sync_mode: false,
livestream_mode: false,
control_locked: false,
track_manifest: null,
playback_mode: AudioPlayerStorage.get("mode") ?? "normal",
playback_status: "stopped",
})
public = {
audioContext: this.audioContext,
setSampleRate: this.setSampleRate,
start: this.start.bind(this),
close: this.close.bind(this),
playback: {
mode: this.playbackMode.bind(this),
stop: this.stop.bind(this),
toggle: this.togglePlayback.bind(this),
pause: this.pausePlayback.bind(this),
play: this.resumePlayback.bind(this),
next: this.next.bind(this),
previous: this.previous.bind(this),
seek: this.seek.bind(this),
},
_setLoading: function (to) {
this.state.loading = !!to
}.bind(this),
duration: this.duration.bind(this),
volume: this.volume.bind(this),
mute: this.mute.bind(this),
toggleMute: this.toggleMute.bind(this),
seek: this.seek.bind(this),
minimize: this.toggleMinimize.bind(this),
collapse: this.toggleCollapse.bind(this),
state: new Proxy(this.state, {
get: (target, prop) => {
return target[prop]
},
set: (target, prop, value) => {
return false
}
start: this.start,
close: this.close,
playback: this.bindableReadOnlyProxy({
toggle: this.togglePlayback,
play: this.resumePlayback,
pause: this.pausePlayback,
stop: this.stopPlayback,
previous: this.previous,
next: this.next,
mode: this.playbackMode,
}),
eventBus: new Proxy(this.eventBus, {
get: (target, prop) => {
return target[prop]
},
set: (target, prop, value) => {
return false
}
controls: this.bindableReadOnlyProxy({
duration: this.duration,
volume: this.volume,
mute: this.mute,
seek: this.seek,
setSampleRate: setSampleRate,
}),
gradualFadeMs: Player.gradualFadeMs,
trackInstance: () => {
track: () => {
return this.track_instance
}
},
eventBus: () => {
return this.eventBus
},
state: this.state,
ui: this.ui.public,
audioContext: this.audioContext,
gradualFadeMs: Player.gradualFadeMs,
}
internalEvents = {
"player.state.update:loading": () => {
//app.cores.sync.music.dispatchEvent("music.player.state.update", this.state)
},
"player.state.update:track_manifest": () => {
//app.cores.sync.music.dispatchEvent("music.player.state.update", this.state)
},
"player.state.update:playback_status": () => {
//app.cores.sync.music.dispatchEvent("music.player.state.update", this.state)
},
"player.seeked": (to) => {
//app.cores.sync.music.dispatchEvent("music.player.seek", to)
},
}
async onInitialize() {
this.native_controls.initialize()
this.initializeAudioProcessors()
for (const [eventName, eventHandler] of Object.entries(this.internalEvents)) {
this.eventBus.on(eventName, eventHandler)
}
Observable.observe(this.state, async (changes) => {
try {
changes.forEach((change) => {
if (change.type === "update") {
const stateKey = change.path[0]
this.eventBus.emit(`player.state.update:${stateKey}`, change.object[stateKey])
this.eventBus.emit("player.state.update", change.object)
}
})
} catch (error) {
this.console.error(`Failed to dispatch state updater >`, error)
}
})
}
async initializeBeforeRuntimeInitialize() {
for (const [eventName, eventHandler] of Object.entries(this.wsEvents)) {
app.cores.api.listenEvent(eventName, eventHandler, Player.websocketListen)
}
async initializeAfterCoresInit() {
if (app.isMobile) {
this.state.audioVolume = 1
}
}
async initializeAudioProcessors() {
if (this.audioProcessors.length > 0) {
this.console.log("Destroying audio processors")
this.audioProcessors.forEach((processor) => {
this.console.log(`Destroying audio processor ${processor.constructor.name}`, processor)
processor._destroy()
})
this.audioProcessors = []
this.state.volume = 1
}
for await (const defaultProccessor of defaultAudioProccessors) {
this.audioProcessors.push(new defaultProccessor(this))
}
for await (const processor of this.audioProcessors) {
if (typeof processor._init === "function") {
try {
await processor._init(this.audioContext)
} catch (error) {
this.console.error(`Failed to initialize audio processor ${processor.constructor.name} >`, error)
continue
}
}
// check if processor has exposed public methods
if (processor.exposeToPublic) {
Object.entries(processor.exposeToPublic).forEach(([key, value]) => {
const refName = processor.constructor.refName
if (typeof this.public[refName] === "undefined") {
// by default create a empty object
this.public[refName] = {}
}
this.public[refName][key] = value
})
}
}
}
//
// UI Methods
//
attachPlayerComponent() {
if (this.currentDomWindow) {
this.console.warn("EmbbededMediaPlayer already attached")
return false
}
if (app.layout.tools_bar) {
this.currentDomWindow = app.layout.tools_bar.attachRender("mediaPlayer", ToolBarPlayer)
}
}
detachPlayerComponent() {
if (!this.currentDomWindow) {
this.console.warn("EmbbededMediaPlayer not attached")
return false
}
if (!app.layout.tools_bar) {
this.console.error("Tools bar not found")
return false
}
app.layout.tools_bar.detachRender("mediaPlayer")
this.currentDomWindow = null
await this.native_controls.initialize()
await this.audioProcessors.initialize()
}
//
@ -265,22 +106,6 @@ export default class Player extends Core {
return false
}
if (!instance.manifest.cover_analysis) {
const cover_analysis = await this.fac.getColorAsync(`https://corsproxy.io/?${encodeURIComponent(instance.manifest.cover ?? instance.manifest.thumbnail)}`)
.catch((err) => {
this.console.error(err)
return false
})
instance.manifest.cover_analysis = cover_analysis
}
if (!instance._preloaded) {
instance.audio.preload = "metadata"
instance._preloaded = true
}
if (isIndex) {
this.track_next_instances[index] = instance
}
@ -288,7 +113,7 @@ export default class Player extends Core {
return instance
}
async destroyCurrentInstance({ sync = false } = {}) {
async destroyCurrentInstance() {
if (!this.track_instance) {
return false
}
@ -300,36 +125,6 @@ export default class Player extends Core {
// reset track_instance
this.track_instance = null
// reset livestream mode
this.state.livestream_mode = false
}
async attachProcessorsToInstance(instance) {
for await (const [index, processor] of this.audioProcessors.entries()) {
if (processor.constructor.node_bypass === true) {
instance.contextElement.connect(processor.processor)
processor.processor.connect(this.audioContext.destination)
continue
}
if (typeof processor._attach !== "function") {
this.console.error(`Processor ${processor.constructor.refName} not support attach`)
continue
}
instance = await processor._attach(instance, index)
}
const lastProcessor = instance.attachedProcessors[instance.attachedProcessors.length - 1].processor
// now attach to destination
lastProcessor.connect(this.audioContext.destination)
return instance
}
//
@ -364,61 +159,50 @@ export default class Player extends Core {
this.destroyCurrentInstance()
}
// attach processors
instance = await this.attachProcessorsToInstance(instance)
// chage current track instance with provided
this.track_instance = instance
// now set the current instance
this.track_instance = await this.preloadAudioInstance(instance)
// initialize instance if is not
if (this.track_instance._initialized === false) {
this.track_instance = await instance.initialize()
}
// update manifest
this.state.track_manifest = this.track_instance.manifest
// attach processors
this.track_instance = await this.audioProcessors.attachProcessorsToInstance(this.track_instance)
// reconstruct audio src if is not set
if (this.track_instance.audio.src !== instance.manifest.source) {
this.track_instance.audio.src = instance.manifest.source
if (this.track_instance.audio.src !== this.track_instance.manifest.source) {
this.track_instance.audio.src = this.track_instance.manifest.source
}
// set time to 0
this.track_instance.audio.currentTime = 0
if (params.time >= 0) {
this.track_instance.audio.currentTime = params.time
}
// set time to provided time, if not, set to 0
this.track_instance.audio.currentTime = params.time ?? 0
this.track_instance.audio.muted = this.state.muted
this.track_instance.audio.loop = this.state.playback_mode === "repeat"
this.track_instance.gainNode.gain.value = this.state.volume
// try to preload next audio
// TODO: Use a better way to preload queues
if (this.track_next_instances.length > 0) {
this.preloadAudioInstance(1)
}
// play
await this.track_instance.audio.play()
this.console.debug(`Playing track >`, this.track_instance)
// update manifest
this.state.track_manifest = instance.manifest
this.native_controls.update(instance.manifest)
// update native controls
this.native_controls.update(this.track_instance.manifest)
return this.track_instance
}
async start(manifest, { sync = false, time, startIndex = 0 } = {}) {
if (this.state.control_locked && !sync) {
this.console.warn("Controls are locked, cannot do this action")
return false
}
this.attachPlayerComponent()
async start(manifest, { time, startIndex = 0 } = {}) {
this.ui.attachPlayerComponent()
// !IMPORTANT: abort preloads before destroying current instance
await this.abortPreloads()
await this.destroyCurrentInstance({
sync
})
await this.destroyCurrentInstance()
this.state.loading = true
@ -438,9 +222,8 @@ export default class Player extends Core {
playlist = playlist.slice(startIndex)
for await (const [index, _manifest] of playlist.entries()) {
let instance = new TrackInstanceClass(this, _manifest)
instance = await instance.initialize()
for (const [index, _manifest] of playlist.entries()) {
let instance = new TrackInstance(this, _manifest)
this.track_next_instances.push(instance)
@ -454,12 +237,7 @@ export default class Player extends Core {
return manifest
}
next({ sync = false } = {}) {
if (this.state.control_locked && !sync) {
//this.console.warn("Sync mode is locked, cannot do this action")
return false
}
next() {
if (this.track_next_instances.length > 0) {
// move current audio instance to history
this.track_prev_instances.push(this.track_next_instances.shift())
@ -468,7 +246,7 @@ export default class Player extends Core {
if (this.track_next_instances.length === 0) {
this.console.log(`No more tracks to play, stopping...`)
return this.stop()
return this.stopPlayback()
}
let nextIndex = 0
@ -480,12 +258,7 @@ export default class Player extends Core {
this.play(this.track_next_instances[nextIndex])
}
previous({ sync = false } = {}) {
if (this.state.control_locked && !sync) {
//this.console.warn("Sync mode is locked, cannot do this action")
return false
}
previous() {
if (this.track_prev_instances.length > 0) {
// move current audio instance to history
this.track_next_instances.unshift(this.track_prev_instances.pop())
@ -500,6 +273,9 @@ export default class Player extends Core {
}
}
//
// Playback Control
//
async togglePlayback() {
if (this.state.playback_status === "paused") {
await this.resumePlayback()
@ -509,6 +285,10 @@ export default class Player extends Core {
}
async pausePlayback() {
if (!this.state.playback_status === "paused") {
return true
}
return await new Promise((resolve, reject) => {
if (!this.track_instance) {
this.console.error("No audio instance")
@ -558,15 +338,29 @@ export default class Player extends Core {
})
}
stop() {
async playbackMode(mode) {
if (typeof mode !== "string") {
return this.state.playback_mode
}
this.state.playback_mode = mode
if (this.track_instance) {
this.track_instance.audio.loop = this.state.playback_mode === "repeat"
}
AudioPlayerStorage.set("mode", mode)
return mode
}
async stopPlayback() {
this.destroyCurrentInstance()
this.abortPreloads()
this.state.playback_status = "stopped"
this.state.track_manifest = null
this.state.livestream_mode = false
this.track_instance = null
this.track_next_instances = []
this.track_prev_instances = []
@ -574,12 +368,19 @@ export default class Player extends Core {
this.native_controls.destroy()
}
//
// Audio Control
//
mute(to) {
if (app.isMobile && typeof to !== "boolean") {
this.console.warn("Cannot mute on mobile")
return false
}
if (to === "toggle") {
to = !this.state.muted
}
if (typeof to === "boolean") {
this.state.muted = to
this.track_instance.audio.muted = to
@ -621,7 +422,7 @@ export default class Player extends Core {
return this.state.volume
}
seek(time, { sync = false } = {}) {
seek(time) {
if (!this.track_instance || !this.track_instance.audio) {
return false
}
@ -631,12 +432,6 @@ export default class Player extends Core {
return this.track_instance.audio.currentTime
}
if (this.state.control_locked && !sync) {
this.console.warn("Sync mode is locked, cannot do this action")
return false
}
// if time is provided, seek to that time
if (typeof time === "number") {
this.console.log(`Seeking to ${time} | Duration: ${this.track_instance.audio.duration}`)
@ -647,24 +442,8 @@ export default class Player extends Core {
}
}
playbackMode(mode) {
if (typeof mode !== "string") {
return this.state.playback_mode
}
this.state.playback_mode = mode
if (this.track_instance) {
this.track_instance.audio.loop = this.state.playback_mode === "repeat"
}
AudioPlayerStorage.set("mode", mode)
return mode
}
duration() {
if (!this.track_instance) {
if (!this.track_instance || !this.track_instance.audio) {
return false
}
@ -687,93 +466,7 @@ export default class Player extends Core {
}
close() {
this.stop()
this.detachPlayerComponent()
}
toggleMinimize(to) {
this.state.minimized = to ?? !this.state.minimized
if (this.state.minimized) {
app.layout.sidebar.attachBottomItem("player", BackgroundMediaPlayer, {
noContainer: true
})
} else {
app.layout.sidebar.removeBottomItem("player")
}
return this.state.minimized
}
toggleCollapse(to) {
if (typeof to !== "boolean") {
this.console.warn("Collapse must be a boolean")
return false
}
this.state.collapsed = to ?? !this.state.collapsed
return this.state.collapsed
}
toggleSyncMode(to, lock) {
if (typeof to !== "boolean") {
this.console.warn("Sync mode must be a boolean")
return false
}
this.state.syncMode = to ?? !this.state.syncMode
this.state.syncModeLocked = lock ?? false
this.console.log(`Sync mode is now ${this.state.syncMode ? "enabled" : "disabled"} | Locked: ${this.state.syncModeLocked ? "yes" : "no"}`)
return this.state.syncMode
}
toggleMute(to) {
if (typeof to !== "boolean") {
to = !this.state.muted
}
return this.mute(to)
}
async setSampleRate(to) {
// must be a integer
if (typeof to !== "number") {
this.console.error("Sample rate must be a number")
return this.audioContext.sampleRate
}
// must be a integer
if (!Number.isInteger(to)) {
this.console.error("Sample rate must be a integer")
return this.audioContext.sampleRate
}
return await new Promise((resolve, reject) => {
app.confirm({
title: "Change sample rate",
content: `To change the sample rate, the app needs to be reloaded. Do you want to continue?`,
onOk: () => {
try {
this.audioContext = new AudioContext({ sampleRate: to })
AudioPlayerStorage.set("sample_rate", to)
app.navigation.reload()
return resolve(this.audioContext.sampleRate)
} catch (error) {
app.message.error(`Failed to change sample rate, ${error.message}`)
return resolve(this.audioContext.sampleRate)
}
},
onCancel: () => {
return resolve(this.audioContext.sampleRate)
}
})
})
this.stopPlayback()
this.ui.detachPlayerComponent()
}
}

View File

@ -1,6 +1,6 @@
import { Modal } from "antd"
import ProcessorNode from "../node"
import Presets from "../../presets"
import Presets from "../../classes/Presets"
export default class CompressorProcessorNode extends ProcessorNode {
constructor(props) {

View File

@ -1,6 +1,6 @@
import { Modal } from "antd"
import ProcessorNode from "../node"
import Presets from "../../presets"
import Presets from "../../classes/Presets"
export default class EqProcessorNode extends ProcessorNode {
constructor(props) {

View File

@ -35,7 +35,7 @@ export default class GainProcessorNode extends ProcessorNode {
applyValues() {
// apply to current instance
this.processor.gain.value = app.cores.player.volume() * this.state.gain
this.processor.gain.value = app.cores.player.state.volume * this.state.gain
}
async init() {

View File

@ -1,178 +1,139 @@
import { EventBus } from "vessel"
import SessionModel from "@models/session"
export default class ChunkedUpload {
constructor(params) {
this.endpoint = params.endpoint
this.file = params.file
this.headers = params.headers || {}
this.postParams = params.postParams
this.service = params.service ?? "default"
this.retries = params.retries ?? app.cores.settings.get("uploader.retries") ?? 3
this.delayBeforeRetry = params.delayBeforeRetry || 5
const {
endpoint,
file,
headers = {},
splitChunkSize = 1024 * 1024 * 10,
maxRetries = 3,
delayBeforeRetry = 5,
} = params
if (!endpoint) {
throw new Error("Missing endpoint")
}
if (!file instanceof File) {
throw new Error("Invalid or missing file")
}
if (typeof headers !== "object") {
throw new Error("Invalid headers")
}
if (splitChunkSize <= 0) {
throw new Error("Invalid splitChunkSize")
}
this.start = 0
this.chunk = null
this.chunkCount = 0
this.splitChunkSize = params.splitChunkSize || 1024 * 1024 * 10
this.totalChunks = Math.ceil(this.file.size / this.splitChunkSize)
this.retriesCount = 0
this.offline = false
this.paused = false
this.headers["Authorization"] = `Bearer ${SessionModel.token}`
this.headers["uploader-original-name"] = encodeURIComponent(this.file.name)
this.headers["uploader-file-id"] = this.uniqid(this.file)
this.headers["uploader-chunks-total"] = this.totalChunks
this.headers["provider-type"] = this.service
this.headers["chunk-size"] = this.splitChunkSize
this.splitChunkSize = splitChunkSize
this.totalChunks = Math.ceil(file.size / splitChunkSize)
this._reader = new FileReader()
this.eventBus = new EventBus()
this.maxRetries = maxRetries
this.delayBeforeRetry = delayBeforeRetry
this.offline = this.paused = false
this.validateParams()
this.endpoint = endpoint
this.file = file
this.headers = {
...headers,
"uploader-original-name": encodeURIComponent(file.name),
"uploader-file-id": this.getFileUID(file),
"uploader-chunks-total": this.totalChunks,
"chunk-size": splitChunkSize
}
this.setupListeners()
this.nextSend()
console.debug("[Uploader] Created", {
splitChunkSize: this.splitChunkSize,
splitChunkSize: splitChunkSize,
totalChunks: this.totalChunks,
totalSize: this.file.size,
})
// restart sync when back online
// trigger events when offline/back online
window.addEventListener("online", () => {
if (!this.offline) return
this.offline = false
this.eventBus.emit("online")
this.nextSend()
})
window.addEventListener("offline", () => {
this.offline = true
this.eventBus.emit("offline")
totalSize: file.size
})
}
on(event, fn) {
this.eventBus.on(event, fn)
_reader = new FileReader()
events = new EventBus()
setupListeners() {
window.addEventListener("online", () => !this.offline && (this.offline = false, this.events.emit("online"), this.nextSend()))
window.addEventListener("offline", () => (this.offline = true, this.events.emit("offline")))
}
validateParams() {
if (!this.endpoint || !this.endpoint.length) throw new TypeError("endpoint must be defined")
if (this.file instanceof File === false) throw new TypeError("file must be a File object")
if (this.headers && typeof this.headers !== "object") throw new TypeError("headers must be null or an object")
if (this.postParams && typeof this.postParams !== "object") throw new TypeError("postParams must be null or an object")
if (this.splitChunkSize && (typeof this.splitChunkSize !== "number" || this.splitChunkSize === 0)) throw new TypeError("splitChunkSize must be a positive number")
if (this.retries && (typeof this.retries !== "number" || this.retries === 0)) throw new TypeError("retries must be a positive number")
if (this.delayBeforeRetry && (typeof this.delayBeforeRetry !== "number")) throw new TypeError("delayBeforeRetry must be a positive number")
}
uniqid(file) {
return Math.floor(Math.random() * 100000000) + Date.now() + this.file.size + "_tmp"
getFileUID(file) {
return Math.floor(Math.random() * 100000000) + Date.now() + file.size + "_tmp"
}
loadChunk() {
return new Promise((resolve) => {
const length = this.totalChunks === 1 ? this.file.size : this.splitChunkSize
const start = length * this.chunkCount
const start = this.chunkCount * this.splitChunkSize
const end = Math.min(start + this.splitChunkSize, this.file.size)
this._reader.onload = () => {
this.chunk = new Blob([this._reader.result], { type: "application/octet-stream" })
resolve()
}
this._reader.readAsArrayBuffer(this.file.slice(start, start + length))
this._reader.onload = () => resolve(new Blob([this._reader.result], { type: "application/octet-stream" }))
this._reader.readAsArrayBuffer(this.file.slice(start, end))
})
}
sendChunk() {
async sendChunk() {
const form = new FormData()
// send post fields on last request
if (this.chunkCount + 1 === this.totalChunks && this.postParams) Object.keys(this.postParams).forEach(key => form.append(key, this.postParams[key]))
form.append("file", this.chunk)
this.headers["uploader-chunk-number"] = this.chunkCount
return fetch(this.endpoint, { method: "POST", headers: this.headers, body: form })
try {
const res = await fetch(
this.endpoint,
{
method: "POST",
headers: this.headers,
body: form
})
return res
} catch (error) {
this.manageRetries()
}
}
manageRetries() {
if (this.retriesCount++ < this.retries) {
if (++this.retriesCount < this.maxRetries) {
setTimeout(() => this.nextSend(), this.delayBeforeRetry * 1000)
this.eventBus.emit("fileRetry", {
message: `An error occured uploading chunk ${this.chunkCount}. ${this.retries - this.retriesCount} retries left`,
chunk: this.chunkCount,
retriesLeft: this.retries - this.retriesCount
})
return
this.events.emit("fileRetry", { message: `Retrying chunk ${this.chunkCount}`, chunk: this.chunkCount, retriesLeft: this.retries - this.retriesCount })
} else {
this.events.emit("error", { message: `No more retries for chunk ${this.chunkCount}` })
}
this.eventBus.emit("error", {
message: `An error occured uploading chunk ${this.chunkCount}. No more retries, stopping upload`
})
}
async nextSend() {
if (this.paused || this.offline) {
return
return null
}
await this.loadChunk()
this.chunk = await this.loadChunk()
const res = await this.sendChunk()
.catch((err) => {
if (this.paused || this.offline) return
this.console.error(err)
// this type of error can happen after network disconnection on CORS setup
this.manageRetries()
})
if (res.status === 200 || res.status === 201 || res.status === 204) {
if ([200, 201, 204].includes(res.status)) {
if (++this.chunkCount < this.totalChunks) {
this.nextSend()
} else {
res.json().then((body) => {
this.eventBus.emit("finish", body)
})
res.json().then((body) => this.events.emit("finish", body))
}
const percentProgress = Math.round((100 / this.totalChunks) * this.chunkCount)
this.eventBus.emit("progress", {
percentProgress
this.events.emit("progress", {
percentProgress: Math.round((100 / this.totalChunks) * this.chunkCount)
})
}
// errors that might be temporary, wait a bit then retry
else if ([408, 502, 503, 504].includes(res.status)) {
if (this.paused || this.offline) return
} else if ([408, 502, 503, 504].includes(res.status)) {
this.manageRetries()
}
else {
if (this.paused || this.offline) return
try {
res.json().then((body) => {
this.eventBus.emit("error", {
message: `[${res.status}] ${body.error ?? body.message}`
})
})
} catch (error) {
this.eventBus.emit("error", {
message: `[${res.status}] ${res.statusText}`
})
}
} else {
res.json().then((body) => this.events.emit("error", { message: `[${res.status}] ${body.error ?? body.message}` }))
}
}
@ -180,7 +141,7 @@ export default class ChunkedUpload {
this.paused = !this.paused
if (!this.paused) {
this.nextSend()
return this.nextSend()
}
}
}
}

View File

@ -1,6 +1,7 @@
import { Core } from "vessel"
import ChunkedUpload from "./chunkedUpload"
import SessionModel from "@models/session"
export default class RemoteStorage extends Core {
static namespace = "remoteStorage"
@ -26,18 +27,24 @@ export default class RemoteStorage extends Core {
onFinish = () => { },
onError = () => { },
service = "standard",
headers = {},
} = {},
) {
return await new Promise((_resolve, _reject) => {
const fn = async () => new Promise((resolve, reject) => {
const uploader = new ChunkedUpload({
endpoint: `${app.cores.api.client().mainOrigin}/upload/chunk`,
splitChunkSize: 5 * 1024 * 1024,
splitChunkSize: 5 * 1024 * 1024,
file: file,
service: service,
headers: {
...headers,
"provider-type": service,
"Authorization": `Bearer ${SessionModel.token}`,
},
})
uploader.on("error", ({ message }) => {
uploader.events.on("error", ({ message }) => {
this.console.error("[Uploader] Error", message)
app.cores.notifications.new({
@ -55,13 +62,13 @@ export default class RemoteStorage extends Core {
_reject(message)
})
uploader.on("progress", ({ percentProgress }) => {
uploader.events.on("progress", ({ percentProgress }) => {
if (typeof onProgress === "function") {
onProgress(file, percentProgress)
}
})
uploader.on("finish", (data) => {
uploader.events.on("finish", (data) => {
this.console.debug("[Uploader] Finish", data)
app.cores.notifications.new({

View File

@ -0,0 +1,17 @@
import React from "react"
const usePageWidgets = (widgets = []) => {
React.useEffect(() => {
for (const widget of widgets) {
app.layout.tools_bar.attachRender(widget.id, widget.component, widget.props)
}
return () => {
for (const widget of widgets) {
app.layout.tools_bar.detachRender(widget.id)
}
}
})
}
export default usePageWidgets

View File

@ -120,14 +120,6 @@ const AccountButton = React.forwardRef((props, ref) => {
</div>
})
export default (props) => {
return <WithPlayerContext>
<BottomBar
{...props}
/>
</WithPlayerContext>
}
export class BottomBar extends React.Component {
static contextType = Context
@ -418,4 +410,12 @@ export class BottomBar extends React.Component {
</Motion>
</>
}
}
export default (props) => {
return <WithPlayerContext>
<BottomBar
{...props}
/>
</WithPlayerContext>
}

View File

@ -11,7 +11,7 @@ export class DraggableDrawerController extends React.Component {
this.interface = {
open: this.open,
close: this.close,
destroy: this.destroy,
actions: this.actions,
}
@ -100,9 +100,15 @@ export class DraggableDrawerController extends React.Component {
this.setState({ drawers: drawers })
app.cores.window_mng.close(drawer.winId)
app.cores.window_mng.close(drawer.id ?? id)
}
/**
* This lifecycle method is called after the component has been updated.
* It will toggle the root scale effect based on the amount of drawers.
* If there are no drawers, the root scale effect is disabled.
* If there are one or more drawers, the root scale effect is enabled.
*/
componentDidUpdate() {
if (this.state.drawers.length === 0) {
app.layout.toggleRootScaleEffect(false)

View File

@ -17,6 +17,10 @@
height: 100dvh;
height: 100vh;
&.hidden {
display: none;
}
}
.drawers-mask {

View File

@ -97,7 +97,7 @@ export default () => {
confirmOnClickContent={confirmOnClickContent}
>
{
React.createElement(render, props)
React.isValidElement(render) ? React.cloneElement(render, props) : React.createElement(render, props)
}
</Modal>)
}

View File

@ -336,7 +336,12 @@ export default class Sidebar extends React.Component {
const selectedKeyId = this.state.selectedMenuItem?.id
return <div
className="app_sidebar_wrapper"
className={classnames(
"app_sidebar_wrapper",
{
["hidden"]: !this.state.visible
}
)}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.handleMouseLeave}
>

View File

@ -26,6 +26,11 @@
gap: 10px;
padding: 10px;
&.hidden {
padding: 0;
width: 0px;
}
}
.app_sidebar {

View File

@ -78,32 +78,6 @@ export default class ToolsBar extends React.Component {
id="tools_bar"
className="tools-bar"
>
{/* <div className="card" id="trendings">
<div className="header">
<h2>
<Icons.TrendingUp />
<Translation>{(t) => t("Trendings")}</Translation>
</h2>
</div>
<HashtagTrendings />
</div>
<div className="card" id="onlineFriends">
<div className="header">
<h2>
<Icons.MdPeopleAlt />
<Translation>{(t) => t("Online Friends")}</Translation>
</h2>
</div>
<ConnectedFriends />
</div>
<FeaturedEventsAnnouncements /> */}
<WidgetsWrapper />
<div className="attached_renders">
{
this.state.renders.map((render) => {
@ -111,6 +85,8 @@ export default class ToolsBar extends React.Component {
})
}
</div>
<WidgetsWrapper />
</div>
</div>
}}

View File

@ -45,67 +45,13 @@
flex: 0;
.card {
display: flex;
flex-direction: column;
background-color: var(--background-color-primary);
border-radius: 12px;
padding: 20px;
isolation: isolate;
h1,
h2 {
width: fit-content;
margin: 0;
}
&.header {
position: relative;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 10px;
z-index: 150;
-webkit-box-shadow: 0 0 0 1px rgba(63, 63, 68, 0.05), 0 1px 3px 0 var(--shadow-color);
-moz-box-shadow: 0 0 0 1px rgba(63, 63, 68, 0.05), 0 1px 3px 0 var(--shadow-color);
box-shadow: 0 0 0 1px rgba(63, 63, 68, 0.05), 0 1px 3px 0 var(--shadow-color);
}
&.content {
position: relative;
transform: translateY(-30px);
padding-top: 35px;
z-index: 45;
}
}
.attached_renders {
position: sticky;
bottom: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
width: 100%;
height: 100%;
gap: 10px;

View File

@ -1,6 +1,6 @@
import React from "react"
import classnames from "classnames"
import { Layout, Alert } from "antd"
import { Layout } from "antd"
import Sidebar from "@layouts/components/sidebar"
import ToolsBar from "@layouts/components/toolsBar"
@ -20,6 +20,7 @@ const DesktopLayout = (props) => {
return <>
<BackgroundDecorator />
<Modals />
<DraggableDrawerController />
<Layout id="app_layout" className="app_layout">
<Sidebar />

View File

@ -6,7 +6,7 @@ import SeekBar from "@components/Player/SeekBar"
import Controls from "@components/Player/Controls"
import ExtraActions from "@components/Player/ExtraActions"
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import RGBStringToValues from "@utils/rgbToValues"
import "./index.less"
@ -29,22 +29,14 @@ const ServiceIndicator = (props) => {
}
const AudioPlayer = (props) => {
return <WithPlayerContext>
<AudioPlayerComponent
{...props}
/>
</WithPlayerContext>
}
const AudioPlayerComponent = (props) => {
const ctx = React.useContext(Context)
const playerState = usePlayerStateContext()
React.useEffect(() => {
if (app.currentDragger) {
app.currentDragger.setBackgroundColorValues(RGBStringToValues(ctx.track_manifest?.cover_analysis?.rgb))
app.currentDragger.setBackgroundColorValues(RGBStringToValues(playerState.track_manifest?.cover_analysis?.rgb))
}
}, [ctx.track_manifest?.cover_analysis])
}, [playerState.track_manifest?.cover_analysis])
const {
title,
@ -54,10 +46,10 @@ const AudioPlayerComponent = (props) => {
lyricsEnabled,
cover_analysis,
cover,
} = ctx.track_manifest ?? {}
} = playerState.track_manifest ?? {}
const playing = ctx.playback_status === "playing"
const stopped = ctx.playback_status === "stopped"
const playing = playerState.playback_status === "playing"
const stopped = playerState.playback_status === "stopped"
const titleText = (!playing && stopped) ? "Stopped" : (title ?? "Untitled")
const subtitleText = `${artist} | ${album?.title ?? album}`
@ -107,10 +99,10 @@ const AudioPlayerComponent = (props) => {
<Controls />
<SeekBar
stopped={ctx.playback_status === "stopped"}
playing={ctx.playback_status === "playing"}
streamMode={ctx.livestream_mode}
disabled={ctx.control_locked}
stopped={playerState.playback_status === "stopped"}
playing={playerState.playback_status === "playing"}
streamMode={playerState.livestream_mode}
disabled={playerState.control_locked}
/>
<ExtraActions />

View File

@ -33,8 +33,8 @@ const DroppableField = (props) => {
<div className="collapse_btn">
{
collapsed
? <Icons.ChevronDown />
: <Icons.ChevronUp />
? <Icons.FiChevronDown />
: <Icons.FiChevronUp />
}
</div>
<div className="inline_field">

View File

@ -54,7 +54,7 @@ const MainSelector = (props) => {
Create a Comty Account
</antd.Button>
<p>
<p style={{ display: "inline" }}>
<Icons.FiInfo />
Registering a new account accepts the <a onClick={() => app.location.push("/terms")}>Terms and Conditions</a> and <a onClick={() => app.location.push("/privacy")}>Privacy policy</a> for the services provided by {config.author}
</p>

View File

@ -0,0 +1,12 @@
import React from "react"
const BadgePage = (props) => {
const user_id = props.params.user_id
return <div>
Badge Page
{user_id}
</div>
}
export default BadgePage

View File

@ -8,7 +8,7 @@ import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
import { Icons } from "@components/Icons"
import Controls from "@components/Player/Controls"
import { Context } from "@contexts/WithPlayerContext"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
function isOverflown(element) {
if (!element) {
@ -47,7 +47,7 @@ const RenderAlbum = (props) => {
}
const PlayerController = React.forwardRef((props, ref) => {
const context = React.useContext(Context)
const playerState = usePlayerStateContext()
const titleRef = React.useRef()
@ -63,42 +63,42 @@ const PlayerController = React.forwardRef((props, ref) => {
async function onDragEnd(seekTime) {
setDraggingTime(false)
app.cores.player.seek(seekTime)
app.cores.player.controls.seek(seekTime)
syncPlayback()
}
async function syncPlayback() {
if (!context.track_manifest) {
if (!playerState.track_manifest) {
return false
}
const currentTrackTime = app.cores.player.seek()
const currentTrackTime = app.cores.player.controls.seek()
setCurrentTime(currentTrackTime)
}
//* Handle when playback status change
React.useEffect(() => {
if (context.playback_status === "playing") {
if (playerState.playback_status === "playing") {
setSyncInterval(setInterval(syncPlayback, 1000))
} else {
if (syncInterval) {
clearInterval(syncInterval)
}
}
}, [context.playback_status])
}, [playerState.playback_status])
React.useEffect(() => {
setTitleIsOverflown(isOverflown(titleRef.current))
setTrackDuration(app.cores.player.duration())
}, [context.track_manifest])
setTrackDuration(app.cores.player.controls.duration())
}, [playerState.track_manifest])
React.useEffect(() => {
syncPlayback()
}, [])
const isStopped = context.playback_status === "stopped"
const isStopped = playerState.playback_status === "stopped"
return <div
className={classnames(
@ -124,8 +124,8 @@ const PlayerController = React.forwardRef((props, ref) => {
)}
>
{
context.playback_status === "stopped" ? "Nothing is playing" : <>
{context.track_manifest?.title ?? "Nothing is playing"}
playerState.playback_status === "stopped" ? "Nothing is playing" : <>
{playerState.track_manifest?.title ?? "Nothing is playing"}
</>
}
</h4>
@ -143,7 +143,7 @@ const PlayerController = React.forwardRef((props, ref) => {
isStopped ?
"Nothing is playing" :
<>
{context.track_manifest?.title ?? "Untitled"}
{playerState.track_manifest?.title ?? "Untitled"}
</>
}
</h4>
@ -152,9 +152,9 @@ const PlayerController = React.forwardRef((props, ref) => {
</div>
<div className="lyrics-player-controller-info-details">
<RenderArtist artist={context.track_manifest?.artists} />
<RenderArtist artist={playerState.track_manifest?.artists} />
-
<RenderAlbum album={context.track_manifest?.album} />
<RenderAlbum album={playerState.track_manifest?.album} />
</div>
</div>
@ -189,7 +189,7 @@ const PlayerController = React.forwardRef((props, ref) => {
<div className="lyrics-player-controller-tags">
{
context.track_manifest?.metadata.lossless && <Tag
playerState.track_manifest?.metadata.lossless && <Tag
icon={<Icons.TbWaveSine />}
bordered={false}
>
@ -197,7 +197,7 @@ const PlayerController = React.forwardRef((props, ref) => {
</Tag>
}
{
context.track_manifest?.explicit && <Tag
playerState.track_manifest?.explicit && <Tag
bordered={false}
>
Explicit
@ -212,7 +212,7 @@ const PlayerController = React.forwardRef((props, ref) => {
</Tag>
}
{
props.lyrics?.available_langs && <Button
props.lyrics?.available_langs?.length > 1 && <Button
icon={<Icons.MdTranslate />}
type={props.translationEnabled ? "primary" : "default"}
onClick={() => props.toggleTranslationEnabled()}

View File

@ -2,10 +2,10 @@ import React from "react"
import classnames from "classnames"
import { Motion, spring } from "react-motion"
import { Context } from "@contexts/WithPlayerContext"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
const LyricsText = React.forwardRef((props, textRef) => {
const context = React.useContext(Context)
const playerState = usePlayerStateContext()
const { lyrics } = props
@ -18,9 +18,9 @@ const LyricsText = React.forwardRef((props, textRef) => {
return false
}
const currentTrackTime = app.cores.player.seek() * 1000
const currentTrackTime = app.cores.player.controls.seek() * 1000
const lineIndex = lyrics.lrc.findIndex((line) => {
const lineIndex = lyrics.synced_lyrics.findIndex((line) => {
return currentTrackTime >= line.startTimeMs && currentTrackTime <= line.endTimeMs
})
@ -32,7 +32,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
return false
}
const line = lyrics.lrc[lineIndex]
const line = lyrics.synced_lyrics[lineIndex]
setCurrentLineIndex(lineIndex)
@ -74,8 +74,8 @@ const LyricsText = React.forwardRef((props, textRef) => {
//* Handle when playback status change
React.useEffect(() => {
if (typeof lyrics?.lrc !== "undefined") {
if (context.playback_status === "playing") {
if (typeof lyrics?.synced_lyrics !== "undefined") {
if (playerState.playback_status === "playing") {
startSyncInterval()
} else {
if (syncInterval) {
@ -85,15 +85,15 @@ const LyricsText = React.forwardRef((props, textRef) => {
} else {
clearInterval(syncInterval)
}
}, [context.playback_status])
}, [playerState.playback_status])
//* Handle when lyrics object change
React.useEffect(() => {
clearInterval(syncInterval)
if (lyrics) {
if (typeof lyrics?.lrc !== "undefined") {
if (context.playback_status === "playing") {
if (typeof lyrics?.synced_lyrics !== "undefined") {
if (playerState.playback_status === "playing") {
startSyncInterval()
}
}
@ -104,7 +104,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
setVisible(false)
clearInterval(syncInterval)
setCurrentLineIndex(0)
}, [context.track_manifest])
}, [playerState.track_manifest])
React.useEffect(() => {
return () => {
@ -112,7 +112,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
}
}, [])
if (!lyrics?.lrc) {
if (!lyrics?.synced_lyrics) {
return null
}
@ -133,7 +133,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
}}
>
{
lyrics.lrc.map((line, index) => {
lyrics.synced_lyrics.map((line, index) => {
return <p
key={index}
id={`lyrics-line-${index}`}

View File

@ -1,30 +1,35 @@
import React from "react"
import HLS from "hls.js"
import classnames from "classnames"
import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
import { Context } from "@contexts/WithPlayerContext"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
const maxLatencyInMs = 55
const LyricsVideo = React.forwardRef((props, videoRef) => {
const context = React.useContext(Context)
const playerState = usePlayerStateContext()
const { lyrics } = props
const [syncInterval, setSyncInterval] = React.useState(null)
const [syncingVideo, setSyncingVideo] = React.useState(false)
const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0)
const hls = React.useRef(new HLS())
async function seekVideoToSyncAudio() {
if (lyrics) {
if (lyrics.video_source && typeof lyrics.sync_audio_at_ms !== "undefined") {
const currentTrackTime = app.cores.player.seek()
setSyncingVideo(true)
videoRef.current.currentTime = currentTrackTime + (lyrics.sync_audio_at_ms / 1000) + app.cores.player.gradualFadeMs / 1000
}
if (!lyrics) {
return null
}
if (!lyrics.video_source || typeof lyrics.sync_audio_at_ms === "undefined") {
return null
}
const currentTrackTime = app.cores.player.controls.seek()
setSyncingVideo(true)
videoRef.current.currentTime = currentTrackTime + (lyrics.sync_audio_at_ms / 1000) + app.cores.player.gradualFadeMs / 1000
}
async function syncPlayback() {
@ -41,7 +46,7 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
return false
}
const currentTrackTime = app.cores.player.seek()
const currentTrackTime = app.cores.player.controls.seek()
const currentVideoTime = videoRef.current.currentTime - (lyrics.sync_audio_at_ms / 1000)
//console.log(`Current track time: ${currentTrackTime}, current video time: ${currentVideoTime}`)
@ -91,8 +96,8 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
//* Handle when playback status change
React.useEffect(() => {
if (typeof lyrics?.sync_audio_at_ms !== "undefined") {
if (context.playback_status === "playing") {
if (lyrics?.video_source && typeof lyrics?.sync_audio_at_ms !== "undefined") {
if (playerState.playback_status === "playing") {
videoRef.current.play()
setSyncInterval(setInterval(syncPlayback, 500))
@ -104,42 +109,39 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
}
}
}
}, [context.playback_status])
}, [playerState.playback_status])
React.useEffect(() => {
if (context.loading === true && context.playback_status === "playing") {
if (lyrics?.video_source && playerState.loading === true && playerState.playback_status === "playing") {
videoRef.current.pause()
}
if (context.loading === false && context.playback_status === "playing") {
if (lyrics?.video_source && playerState.loading === false && playerState.playback_status === "playing") {
videoRef.current.play()
}
}, [context.loading])
}, [playerState.loading])
//* Handle when lyrics object change
React.useEffect(() => {
clearInterval(syncInterval)
setCurrentVideoLatency(0)
setSyncingVideo(false)
if (lyrics) {
clearInterval(syncInterval)
setCurrentVideoLatency(0)
setSyncingVideo(false)
if (lyrics.video_source) {
videoRef.current.src = lyrics.video_source
videoRef.current.load()
hls.current.loadSource(lyrics.video_source)
if (typeof lyrics.sync_audio_at_ms !== "undefined") {
videoRef.current.currentTime = lyrics.sync_audio_at_ms / 1000
if (context.playback_status === "playing") {
if (playerState.playback_status === "playing") {
videoRef.current.play()
startSyncInterval()
} else {
videoRef.current.pause()
}
const currentTime = app.cores.player.seek()
const currentTime = app.cores.player.controls.seek()
if (currentTime > 0) {
seekVideoToSyncAudio()
@ -148,13 +150,19 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
videoRef.current.loop = true
videoRef.current.play()
}
} else {
videoRef.current
}
} else {
videoRef.current
}
}, [lyrics])
React.useEffect(() => {
clearInterval(syncInterval)
hls.current.attachMedia(videoRef.current)
return () => {
clearInterval(syncInterval)
}
@ -165,7 +173,6 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
props.lyrics?.sync_audio_at && <div
className={classnames(
"videoDebugOverlay",
)}
>
<div>
@ -181,7 +188,12 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
}
<video
className="lyrics-video"
className={classnames(
"lyrics-video",
{
["hidden"]: !lyrics || !lyrics?.video_source
}
)}
ref={videoRef}
controls={false}
muted

View File

@ -2,7 +2,7 @@ import React from "react"
import classnames from "classnames"
import useMaxScreen from "@hooks/useMaxScreen"
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import MusicService from "@models/music"
@ -12,8 +12,18 @@ import LyricsText from "./components/text"
import "./index.less"
function getDominantColorStr(track_manifest) {
if (!track_manifest) {
return `0,0,0`
}
const values = track_manifest.cover_analysis?.value ?? [0, 0, 0]
return `${values[0]}, ${values[1]}, ${values[2]}`
}
const EnchancedLyrics = (props) => {
const context = React.useContext(Context)
const playerState = usePlayerStateContext()
const [initialized, setInitialized] = React.useState(false)
const [lyrics, setLyrics] = React.useState(null)
@ -46,18 +56,20 @@ const EnchancedLyrics = (props) => {
React.useEffect((prev) => {
if (initialized) {
loadLyrics(context.track_manifest._id)
loadLyrics(playerState.track_manifest._id)
}
}, [translationEnabled])
//* Handle when context change track_manifest
React.useEffect(() => {
setLyrics(null)
if (context.track_manifest) {
loadLyrics(context.track_manifest._id)
if (playerState.track_manifest) {
if (!lyrics || (lyrics.track_id !== playerState.track_manifest._id)) {
loadLyrics(playerState.track_manifest._id)
}
} else {
setLyrics(null)
}
}, [context.track_manifest])
}, [playerState.track_manifest])
//* Handle when lyrics data change
React.useEffect(() => {
@ -72,19 +84,27 @@ const EnchancedLyrics = (props) => {
className={classnames(
"lyrics",
{
["stopped"]: context.playback_status !== "playing",
["stopped"]: playerState.playback_status !== "playing",
}
)}
style={{
"--dominant-color": getDominantColorStr(playerState.track_manifest)
}}
>
<div
className="lyrics-background-color"
/>
{
!lyrics?.video_source && <div
playerState.track_manifest && !lyrics?.video_source && <div
className="lyrics-background-wrapper"
>
<div
className="lyrics-background-cover"
>
<img
src={context.track_manifest.cover}
src={playerState.track_manifest.cover}
/>
</div>
</div>
@ -109,12 +129,4 @@ const EnchancedLyrics = (props) => {
</div>
}
const EnchancedLyricsPage = (props) => {
return <WithPlayerContext>
<EnchancedLyrics
{...props}
/>
</WithPlayerContext>
}
export default EnchancedLyricsPage
export default EnchancedLyrics

View File

@ -15,8 +15,21 @@
}
}
.lyrics-background-wrapper {
.lyrics-background-color {
position: absolute;
z-index: 100;
width: 100%;
height: 100%;
background:
linear-gradient(0deg, rgba(var(--dominant-color), 1), rgba(0, 0, 0, 0)),
url("data:image/svg+xml,%3Csvg viewBox='0 0 284 284' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='7.59' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
}
.lyrics-background-wrapper {
z-index: 110;
position: absolute;
top: 0;
@ -25,28 +38,35 @@
width: 100vw;
height: 100vh;
backdrop-filter: blur(10px);
.lyrics-background-cover {
position: relative;
z-index: 110;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
padding: 80px;
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
width: 40vw;
height: 40vw;
object-fit: contain;
border-radius: 24px;
}
}
}
.lyrics-video {
z-index: 105;
z-index: 120;
position: absolute;
top: 0;
@ -58,10 +78,14 @@
object-fit: cover;
transition: all 150ms ease-out;
&.hidden {
opacity: 0;
}
}
.lyrics-text-wrapper {
z-index: 110;
z-index: 200;
position: fixed;
bottom: 0;
@ -103,7 +127,7 @@
.lyrics-player-controller-wrapper {
position: fixed;
z-index: 115;
z-index: 210;
bottom: 0;
right: 0;
@ -282,7 +306,7 @@
top: 20px;
right: 20px;
z-index: 115;
z-index: 300;
display: flex;

View File

@ -507,7 +507,7 @@ export default class SettingItemComponent extends React.PureComponent {
{
this.state.debouncedValue && <antd.Button
type="round"
icon={<FiSave />}
icon={<Icons.FiSave />}
onClick={async () => await this.dispatchUpdate(this.state.debouncedValue)}
>
Save

View File

@ -6,6 +6,8 @@
--fontSize: 14px;
--fontWeight: normal;
font-family: "DM Mono", sans-serif;
.editable-text-value {
display: flex;
flex-direction: row;
@ -31,8 +33,11 @@
gap: 10px;
font-family: "DM Mono", sans-serif;
.ant-input {
background-color: transparent;
font-family: "DM Mono", sans-serif;
font-size: var(--fontSize);

View File

@ -78,17 +78,14 @@ const ProfileData = (props) => {
}
async function handleEditName() {
const modal = app.modal.info({
title: "Edit name",
content: <ProfileCreator
close={() => modal.destroy()}
editValue={profile.profile_name}
onEdit={async (value) => {
app.layout.modal.open("name_editor", ProfileCreator, {
props: {
editValue: profile.profile_name,
onEdit: async (value) => {
await handleChange("profile_name", value)
app.eventBus.emit("app:profiles_updated", profile._id)
}}
/>,
footer: null
},
}
})
}
@ -118,18 +115,18 @@ const ProfileData = (props) => {
/>
}
return <div className="profile-data">
return <div className="tvstudio-profile-data">
<div
className="profile-data-header"
className="tvstudio-profile-data-header"
>
<img
className="profile-data-header-image"
className="tvstudio-profile-data-header-image"
src={profile.info?.thumbnail}
/>
<div className="profile-data-header-content">
<div className="tvstudio-profile-data-header-content">
<EditableText
value={profile.info?.title ?? "Untitled"}
className="profile-data-header-title"
className="tvstudio-profile-data-header-title"
style={{
"--fontSize": "2rem",
"--fontWeight": "800"
@ -141,7 +138,7 @@ const ProfileData = (props) => {
/>
<EditableText
value={profile.info?.description ?? "No description"}
className="profile-data-header-description"
className="tvstudio-profile-data-header-description"
style={{
"--fontSize": "1rem",
}}
@ -153,8 +150,8 @@ const ProfileData = (props) => {
</div>
</div>
<div className="profile-data-field">
<div className="profile-data-field-header">
<div className="tvstudio-profile-data-field">
<div className="tvstudio-profile-data-field-header">
<MdOutlineWifiTethering />
<span>Server</span>
</div>
@ -184,8 +181,8 @@ const ProfileData = (props) => {
</div>
</div>
<div className="profile-data-field">
<div className="profile-data-field-header">
<div className="tvstudio-profile-data-field">
<div className="tvstudio-profile-data-field-header">
<GrConfigure />
<span>Configuration</span>
</div>
@ -233,8 +230,8 @@ const ProfileData = (props) => {
</div>
{
profile.sources && <div className="profile-data-field">
<div className="profile-data-field-header">
profile.sources && <div className="tvstudio-profile-data-field">
<div className="tvstudio-profile-data-field-header">
<FiLink />
<span>Media URL</span>
</div>
@ -302,8 +299,8 @@ const ProfileData = (props) => {
</div>
}
<div className="profile-data-field">
<div className="profile-data-field-header">
<div className="tvstudio-profile-data-field">
<div className="tvstudio-profile-data-field-header">
<span>Other</span>
</div>

View File

@ -1,10 +1,10 @@
.profile-data {
.tvstudio-profile-data {
display: flex;
flex-direction: column;
gap: 20px;
.profile-data-header {
.tvstudio-profile-data-header {
position: relative;
max-height: 200px;
@ -17,7 +17,7 @@
overflow: hidden;
.profile-data-header-image {
.tvstudio-profile-data-header-image {
position: absolute;
left: 0;
@ -28,7 +28,7 @@
width: 100%;
}
.profile-data-header-content {
.tvstudio-profile-data-header-content {
position: relative;
display: flex;
@ -44,13 +44,13 @@
}
}
.profile-data-field {
.tvstudio-profile-data-field {
display: flex;
flex-direction: column;
gap: 10px;
.profile-data-field-header {
.tvstudio-profile-data-field-header {
display: flex;
flex-direction: row;

View File

@ -4,7 +4,7 @@ import * as antd from "antd"
import Streaming from "@models/spectrum"
const ProfileSelector = (props) => {
const [loading, result, error, repeat] = app.cores.api.useRequest(Streaming.getOwnProfiles)
const [loading, list, error, repeat] = app.cores.api.useRequest(Streaming.getOwnProfiles)
const [selectedProfileId, setSelectedProfileId] = React.useState(null)
function handleOnChange(value) {
@ -22,7 +22,7 @@ const ProfileSelector = (props) => {
const handleOnDeletedProfile = async (profile_id) => {
await repeat()
handleOnChange(result[0]._id)
handleOnChange(list[0]._id)
}
React.useEffect(() => {
@ -70,7 +70,7 @@ const ProfileSelector = (props) => {
className="profile-selector"
>
{
result.map((profile) => {
list.map((profile) => {
return <antd.Select.Option
key={profile._id}
value={profile._id}

View File

@ -5,26 +5,27 @@ import ProfileSelector from "./components/ProfileSelector"
import ProfileData from "./components/ProfileData"
import ProfileCreator from "./components/ProfileCreator"
import useCenteredContainer from "@hooks/useCenteredContainer"
import "./index.less"
const TVStudioPage = (props) => {
useCenteredContainer(true)
const [selectedProfileId, setSelectedProfileId] = React.useState(null)
function newProfileModal() {
const modal = app.modal.info({
title: "Create new profile",
content: <ProfileCreator
close={() => modal.destroy()}
onCreate={(id, data) => {
app.layout.modal.open("tv_profile_creator", ProfileCreator, {
props: {
onCreate: (id, data) => {
setSelectedProfileId(id)
}}
/>,
footer: null
},
}
})
}
return <div className="main-page">
<div className="main-page-actions">
return <div className="tvstudio-page">
<div className="tvstudio-page-actions">
<ProfileSelector
onChange={setSelectedProfileId}
/>
@ -44,18 +45,10 @@ const TVStudioPage = (props) => {
}
{
!selectedProfileId && <div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "70vh"
}}
>
<h3>
!selectedProfileId && <div className="tvstudio-page-selector-hint">
<h1>
Select profile or create new
</h3>
</h1>
</div>
}
</div>

View File

@ -1,17 +1,19 @@
.main-page {
.tvstudio-page {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
gap: 10px;
.main-page-actions {
.tvstudio-page-actions {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
gap: 10px;
.profile-selector {
@ -21,4 +23,15 @@
width: 100%;
}
}
.tvstudio-page-selector-hint {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
padding: 50px 0;
}
}

View File

@ -3,31 +3,52 @@ import { Translation } from "react-i18next"
import { PagePanelWithNavMenu } from "@components/PagePanels"
import usePageWidgets from "@hooks/usePageWidgets"
import Tabs from "./tabs"
export default class Home extends React.Component {
render() {
return <PagePanelWithNavMenu
tabs={Tabs}
extraItems={[
{
key: "create",
icon: "FiPlusCircle",
label: <Translation>{(t) => t("Create")}</Translation>,
props: {
type: "primary",
onClick: app.controls.openPostCreator
}
},
]}
onTabChange={() => {
app.layout.scrollTo({
top: 0,
})
}}
useSetQueryType
transition
masked
/>
}
}
const TrendingsCard = () => {
return <div className="card">
<div className="card-header">
<span>Trendings</span>
</div>
<div className="card-content">
<span>XD</span>
</div>
</div>
}
const TimelinePage = () => {
usePageWidgets([
{
id: "trendings",
component: TrendingsCard
}
])
return <PagePanelWithNavMenu
tabs={Tabs}
extraItems={[
{
key: "create",
icon: "FiPlusCircle",
label: <Translation>{(t) => t("Create")}</Translation>,
props: {
type: "primary",
onClick: app.controls.openPostCreator
}
},
]}
onTabChange={() => {
app.layout.scrollTo({
top: 0,
})
}}
useSetQueryType
transition
masked
/>
}
export default TimelinePage

View File

@ -1,8 +1,6 @@
import React from "react"
import * as antd from "antd"
// import { version as linebridgeVersion } from "linebridge/package.json"
import { Icons } from "@components/Icons"
import LatencyIndicator from "@components/PerformanceIndicators/latency"
@ -198,7 +196,7 @@ export default {
</div>
<div className="field_value">
{app.__eviteVersion ?? "Unknown"}
{app.__version ?? "Unknown"}
</div>
</div>

View File

@ -90,6 +90,10 @@ export default {
label: "Varela Round",
value: "'Varela Round', sans-serif"
},
{
label: "Manrope",
value: "'Manrope', sans-serif"
}
]
},
defaultValue: () => {

View File

@ -0,0 +1,15 @@
import React from "react"
import * as antd from "antd"
import "./index.less"
const BadgeEditor = (props) => {
return <div className="badge-card-editor">
<div className="badge-card-editor-preview">
</div>
</div>
}
export default BadgeEditor

View File

@ -1,6 +1,5 @@
import React from "react"
import * as antd from "antd"
import { Input } from "antd"
import { Icons } from "@components/Icons"
@ -8,8 +7,14 @@ import NFCModel from "comty.js/models/nfc"
import StepsContext from "../../context"
import "./index.less"
export default (props) => {
const context = React.useContext(StepsContext)
const ref = React.useRef()
const [form] = antd.Form.useForm()
const behaviorType = antd.Form.useWatch("behavior", form)
if (!context.values.serial) {
app.message.error("Serial not available.")
@ -20,17 +25,19 @@ export default (props) => {
}
const handleOnFinish = async (values) => {
console.log({ values })
context.setValue("alias", values.alias)
context.setValue("behavior", values.behavior)
const result = await NFCModel.registerTag(context.values.serial, {
alias: values.alias,
behavior: values.behavior
behavior: values.behavior,
}).catch((err) => {
console.error(err)
app.message.error("Cannot register your tag. Please try again.")
return false
})
@ -55,7 +62,10 @@ export default (props) => {
return context.next()
}
return <div className="tap-share-register_step">
return <div
className="tap-share-register_step"
ref={ref}
>
<h2>
Tag Data
</h2>
@ -68,6 +78,7 @@ export default (props) => {
alias: context.values.alias,
behavior: context.values.behavior,
}}
form={form}
>
<antd.Form.Item
name="serial"
@ -76,7 +87,7 @@ export default (props) => {
Serial
</>}
>
<Input
<antd.Input
disabled
/>
</antd.Form.Item>
@ -109,10 +120,11 @@ export default (props) => {
What will happen when someone taps your tag?
</span>
<div className="ant-form_with_selector">
<div
className={"ant-form_with_selector"}
>
<antd.Form.Item
name={["behavior", "type"]}
noStyle
size="large"
rules={[
{
@ -120,49 +132,59 @@ export default (props) => {
message: "Please select your tag behavior."
}
]}
initialValue={"url"}
noStyle
>
<antd.Select
placeholder="Options"
size="large"
>
<antd.Select.Option
value="url"
>
Custom URL
</antd.Select.Option>
<antd.Select.Option
value="profile"
>
Profile
</antd.Select.Option>
<antd.Select.Option
value="random_list"
>
Random list
</antd.Select.Option>
</antd.Select>
</antd.Form.Item>
<antd.Form.Item
name={["behavior", "value"]}
noStyle
rules={[
{
required: true,
message: "Please select your behavior value."
}
]}
>
<antd.Input
placeholder="value"
size="large"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
getPopupContainer={() => ref.current}
options={[
{
value: "url",
label: <span className="flex-row gap10">
<Icons.FiLink />
Custom URL
</span>
},
{
value: "badge",
label: <span className="flex-row gap10">
<Icons.FiTag />
Badge
</span>
},
{
value: "random_list",
label: <span className="flex-row gap10">
<Icons.FiList />
Random list
</span>
}
]}
/>
</antd.Form.Item>
{
behaviorType?.type !== "badge" && <antd.Form.Item
name={["behavior", "value"]}
noStyle
rules={[
{
required: true,
message: "Please select your behavior value."
}
]}
>
<antd.Input
placeholder="value"
size="large"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
/>
</antd.Form.Item>
}
</div>
</antd.Form.Item>

View File

@ -0,0 +1,15 @@
.ant-form_with_selector {
display: flex;
flex-direction: column !important;
align-items: flex-start !important;
justify-content: flex-start !important;
.ant-select {
width: 100% !important;
}
.ant-input {
width: 100% !important;
}
}

View File

@ -112,4 +112,17 @@
60% {
transform: translateY(-3px);
}
}
@keyframes rootScaleOut {
0% {
transform: scale(1);
}
100% {
border-radius: 24px;
transform: perspective(1000px) scale(0.99);
box-shadow: 0 0 500px 10px var(--colorPrimary);
overflow: hidden;
}
}

View File

@ -83,7 +83,19 @@
}
.ant-dropdown-menu {
background-color: var(--background-color-primary) !important;
background-color: rgba(var(--layoutBackgroundColor), 0.8) !important;
backdrop-filter: blur(3px);
.ant-dropdown-menu-item {
.ant-dropdown-menu-title-content {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
}
}
// fix buttons

View File

@ -2,6 +2,7 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Varela+Round&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap');
/* Required secondary fonts */
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap');

View File

@ -1,3 +1,4 @@
@import "@styles/layout.less";
@import "@styles/animations.less";
@import "@styles/vars.less";
@import "@styles/fonts.less";
@ -40,6 +41,10 @@ p {
font-size: calc(1em * var(--fontScale));
}
code {
user-select: text !important;
}
h1,
h2,
h3,
@ -49,6 +54,12 @@ h6,
p,
span,
a {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 5px;
margin: 0;
}
@ -147,14 +158,14 @@ html {
-webkit-overflow-scrolling: touch;
&.root-scale-effect {
background-color: black !important;
// &.root-scale-effect {
// background-color: black !important;
#root {
animation: rootScaleOut 250ms ease-in-out forwards;
animation-delay: 0;
}
}
// #root {
// animation: rootScaleOut 250ms ease-in-out forwards;
// animation-delay: 0;
// }
// }
&.centered-content {
.content_layout {
@ -206,6 +217,7 @@ svg {
}
*:not(input):not(textarea):not(a) {
user-select: none;
-webkit-user-select: none;
/* disable selection/Copy of UIWebView */
-webkit-touch-callout: none;
@ -368,15 +380,58 @@ svg {
gap: 10px;
}
@keyframes rootScaleOut {
0% {
transform: scale(1);
.key-value-field {
display: flex;
flex-direction: column;
gap: 5px;
.key-value-field-key {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
font-weight: bold;
font-size: 0.9rem;
}
100% {
border-radius: 24px;
transform: perspective(1000px) scale(0.99);
box-shadow: 0 0 500px 10px var(--colorPrimary);
overflow: hidden;
.key-value-field-description {
display: flex;
flex-direction: column;
gap: 5px;
opacity: 0.8;
}
.key-value-field-value {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 7px 5px;
border-radius: 8px;
background-color: var(--background-color-accent);
font-family: "DM Mono", monospace;
font-size: 0.7rem;
h1,
h2,
h3,
h4,
h5,
h6,
p,
span,
a {
user-select: all !important;
}
}
}

View File

@ -0,0 +1,67 @@
// FLEX
.flex-row {
display: flex;
flex-direction: row;
align-items: center;
}
.flex-column {
display: flex;
flex-direction: column;
align-items: center;
}
// ALINGMENT
.align-start {
align-items: flex-start;
}
.align-center {
align-items: center;
}
.align-end {
align-items: flex-end;
}
// JUSTIFICATION
.justify-center {
justify-content: center;
}
.justify-start {
justify-content: flex-start;
}
.justify-end {
justify-content: flex-end;
}
.justify-space-between {
justify-content: space-between;
}
.justify-space-around {
justify-content: space-around;
}
.justify-space-evenly {
justify-content: space-evenly;
}
// GAPS
.gap-10,
.gap10 {
gap: 10px;
}
.gap-5,
.gap5 {
gap: 5px;
}
// COLORS & BG
.acrylic-bg {
background-color: rgba(var(--layoutBackgroundColor), 0.8) !important;
backdrop-filter: blur(3px);
}

View File

@ -2,13 +2,13 @@
require("dotenv").config()
require("sucrase/register")
const path = require("path")
const Module = require("module")
const { Buffer } = require("buffer")
const { webcrypto: crypto } = require("crypto")
const path = require("node:path")
const Module = require("node:module")
const { Buffer } = require("node:buffer")
const { webcrypto: crypto } = require("node:crypto")
const { InfisicalClient } = require("@infisical/sdk")
const moduleAlias = require("module-alias")
const { onExit } = require("signal-exit")
// Override file execution arg
process.argv.splice(1, 1)
@ -135,6 +135,8 @@ async function Boot(main) {
throw new Error("main class is not defined")
}
console.log(`[BOOT] Booting in [${global.isProduction ? "production" : "development"}] mode...`)
if (process.env.INFISICAL_CLIENT_ID && process.env.INFISICAL_CLIENT_SECRET) {
console.log(`[BOOT] INFISICAL Credentials found, injecting env variables from INFISICAL...`)
await injectEnvFromInfisical()
@ -142,6 +144,18 @@ async function Boot(main) {
const instance = new main()
onExit((code, signal) => {
console.log(`[BOOT] Cleaning up...`)
if (typeof instance.onClose === "function") {
instance.onClose()
}
instance.engine.close()
}, {
alwaysLast: true,
})
await instance.initialize()
if (process.env.lb_service && process.send) {
@ -153,13 +167,15 @@ async function Boot(main) {
return instance
}
console.log(`[BOOT] Booting in [${global.isProduction ? "production" : "development"}] mode...`)
try {
// Apply patches
registerPatches()
// Apply patches
registerPatches()
// Apply aliases
registerAliases()
// Apply aliases
registerAliases()
// execute main
Module.runMain()
// execute main
Module.runMain()
} catch (error) {
console.error("[BOOT] ❌ Boot error: ", error)
}

View File

@ -20,39 +20,57 @@ export function checkTotalSize(
}
export function checkChunkUploadHeaders(headers) {
if (
!headers["uploader-chunk-number"] ||
!headers["uploader-chunks-total"] ||
!headers["uploader-original-name"] ||
!headers["uploader-file-id"] ||
!headers["uploader-chunks-total"].match(/^[0-9]+$/) ||
!headers["uploader-chunk-number"].match(/^[0-9]+$/)
) {
return false
const requiredHeaders = [
"uploader-chunk-number",
"uploader-chunks-total",
"uploader-original-name",
"uploader-file-id"
]
for (const header of requiredHeaders) {
if (!headers[header] || typeof headers[header] !== "string") {
return false
}
if (
(header === "uploader-chunk-number" || header === "uploader-chunks-total")
&& !/^[0-9]+$/.test(headers[header])
) {
return false
}
}
return true
}
export function createAssembleChunksPromise({
chunksPath, // chunks to assemble
filePath, // final assembled file path
chunksPath,
filePath,
maxFileSize,
}) {
return () => new Promise(async (resolve, reject) => {
let fileSize = 0
if (!fs.existsSync(chunksPath)) {
return reject(new OperationError(500,"No chunks found"))
return reject(new OperationError(500, "No chunks found"))
}
const chunks = await fs.promises.readdir(chunksPath)
let chunks = await fs.promises.readdir(chunksPath)
if (chunks.length === 0) {
throw new OperationError(500, "No chunks found")
return reject(new OperationError(500, "No chunks found"))
}
for await (const chunk of chunks) {
// Ordenar los chunks numéricamente
chunks = chunks.sort((a, b) => {
const aNum = parseInt(a, 10)
const bNum = parseInt(b, 10)
return aNum - bNum
})
for (const chunk of chunks) {
const chunkPath = path.join(chunksPath, chunk)
if (!fs.existsSync(chunkPath)) {
@ -60,18 +78,13 @@ export function createAssembleChunksPromise({
}
const data = await fs.promises.readFile(chunkPath)
fileSize += data.length
// check if final file gonna exceed max file size
// in case early estimation is wrong (due client send bad headers)
if (fileSize > maxFileSize) {
return reject(new OperationError(413, "File exceeds max total file size, aborting assembly..."))
}
await fs.promises.appendFile(filePath, data)
continue
}
return resolve({
@ -81,7 +94,15 @@ export function createAssembleChunksPromise({
})
}
export async function handleChunkFile(fileStream, { tmpDir, headers, maxFileSize, maxChunkSize }) {
export async function handleChunkFile(
fileStream,
{
tmpDir,
headers,
maxFileSize,
maxChunkSize
}
) {
return await new Promise(async (resolve, reject) => {
const chunksPath = path.join(tmpDir, headers["uploader-file-id"], "chunks")
const chunkPath = path.join(chunksPath, headers["uploader-chunk-number"])
@ -94,7 +115,7 @@ export async function handleChunkFile(fileStream, { tmpDir, headers, maxFileSize
// make sure chunk is in range
if (chunkCount < 0 || chunkCount >= totalChunks) {
throw new OperationError(500, "Chunk is out of range")
return reject(new OperationError(500, "Chunk is out of range"))
}
// if is the first chunk check if dir exists before write things
@ -162,11 +183,14 @@ export async function handleChunkFile(fileStream, { tmpDir, headers, maxFileSize
})
}
export async function uploadChunkFile(req, {
tmpDir,
maxFileSize,
maxChunkSize,
}) {
export async function uploadChunkFile(
req,
{
tmpDir,
maxFileSize,
maxChunkSize,
}
) {
return await new Promise(async (resolve, reject) => {
if (!checkChunkUploadHeaders(req.headers)) {
reject(new OperationError(400, "Missing header(s)"))

View File

@ -0,0 +1,147 @@
import fs from "node:fs"
import path from "node:path"
import { exec } from "node:child_process"
import { EventEmitter } from "node:events"
export default class MultiqualityHLSJob {
constructor({
input,
outputDir,
outputMasterName = "master.m3u8",
levels,
}) {
this.input = input
this.outputDir = outputDir
this.levels = levels
this.outputMasterName = outputMasterName
this.bin = require("ffmpeg-static")
return this
}
events = new EventEmitter()
buildCommand = () => {
const cmdStr = [
this.bin,
`-v quiet -stats`,
`-i ${this.input}`,
`-filter_complex`,
]
// set split args
let splitLevels = [
`[0:v]split=${this.levels.length}`
]
this.levels.forEach((level, i) => {
splitLevels[0] += (`[v${i + 1}]`)
})
for (const [index, level] of this.levels.entries()) {
if (level.original) {
splitLevels.push(`[v1]copy[v1out]`)
continue
}
let scaleFilter = `[v${index + 1}]scale=w=${level.width}:h=trunc(ow/a/2)*2[v${index + 1}out]`
splitLevels.push(scaleFilter)
}
cmdStr.push(`"${splitLevels.join(";")}"`)
// set levels map
for (const [index, level] of this.levels.entries()) {
let mapArgs = [
`-map "[v${index + 1}out]"`,
`-x264-params "nal-hrd=cbr:force-cfr=1"`,
`-c:v:${index} ${level.codec}`,
`-b:v:${index} ${level.bitrate}`,
`-maxrate:v:${index} ${level.bitrate}`,
`-minrate:v:${index} ${level.bitrate}`,
`-bufsize:v:${index} ${level.bitrate}`,
`-preset ${level.preset}`,
`-g 48`,
`-sc_threshold 0`,
`-keyint_min 48`,
]
cmdStr.push(...mapArgs)
}
// set output
cmdStr.push(`-f hls`)
cmdStr.push(`-hls_time 2`)
cmdStr.push(`-hls_playlist_type vod`)
cmdStr.push(`-hls_flags independent_segments`)
cmdStr.push(`-hls_segment_type mpegts`)
cmdStr.push(`-hls_segment_filename stream_%v/data%02d.ts`)
cmdStr.push(`-master_pl_name ${this.outputMasterName}`)
cmdStr.push(`-var_stream_map`)
let streamMapVar = []
for (const [index, level] of this.levels.entries()) {
streamMapVar.push(`v:${index}`)
}
cmdStr.push(`"${streamMapVar.join(" ")}"`)
cmdStr.push(`"stream_%v/stream.m3u8"`)
return cmdStr.join(" ")
}
run = () => {
const cmdStr = this.buildCommand()
console.log(cmdStr)
const cwd = `${path.dirname(this.input)}/hls`
if (!fs.existsSync(cwd)) {
fs.mkdirSync(cwd, { recursive: true })
}
console.log(`[HLS] Started multiquality transcode`, {
input: this.input,
cwd: cwd,
})
const process = exec(
cmdStr,
{
cwd: cwd,
},
(error, stdout, stderr) => {
if (error) {
console.log(`[HLS] Failed to transcode >`, error)
return this.events.emit("error", error)
}
if (stderr) {
//return this.events.emit("error", stderr)
}
console.log(`[HLS] Finished transcode >`, cwd)
return this.events.emit("end", {
filepath: path.join(cwd, this.outputMasterName),
isDirectory: true,
})
}
)
process.stdout.on("data", (data) => {
console.log(data.toString())
})
}
on = (key, cb) => {
this.events.on(key, cb)
return this
}
}

View File

@ -38,8 +38,12 @@ export class StorageClient extends Minio.Client {
this.defaultRegion = String(options.defaultRegion)
}
composeRemoteURL = (key) => {
const _path = path.join(this.defaultBucket, key)
composeRemoteURL = (key, extraKey) => {
let _path = path.join(this.defaultBucket, key)
if (typeof extraKey === "string") {
_path = path.join(_path, extraKey)
}
return `${this.protocol}//${this.host}:${this.port}/${_path}`
}

View File

@ -32,6 +32,10 @@ export default {
endpoint_url: {
type: String,
default: "https://comty.app/nfc/no_endpoint"
},
origin: {
type: String,
default: "comty.app"
}
}
}

View File

@ -0,0 +1,21 @@
export default {
name: "ActivationCode",
collection: "activation_codes",
schema: {
event: {
type: String,
required: true,
},
user_id: {
type: String,
required: true,
},
code: {
type: String,
required: true,
},
date: {
type: Date,
}
}
}

View File

@ -2,12 +2,36 @@ export default {
name: "Post",
collection: "posts",
schema: {
user_id: { type: String, required: true },
created_at: { type: String, required: true },
message: { type: String },
attachments: { type: Array, default: [] },
flags: { type: Array, default: [] },
reply_to: { type: String, default: null },
updated_at: { type: String, default: null },
user_id: {
type: String,
required: true
},
created_at: {
type: String,
required: true
},
message: {
type: String
},
attachments: {
type: Array,
default: []
},
flags: {
type: Array,
default: []
},
reply_to: {
type: String,
default: null
},
updated_at: {
type: String,
default: null
},
poll_options: {
type: Array,
default: null
}
}
}

View File

@ -38,5 +38,9 @@ export default {
type: Object,
required: true,
},
lyrics_enabled: {
type: Boolean,
default: false
}
}
}

View File

@ -8,6 +8,13 @@ export default {
},
lrc: {
type: Object,
default: {}
},
video_source: {
type: String,
},
sync_audio_at: {
type: String,
}
}
}

View File

@ -2,20 +2,72 @@ export default {
name: "User",
collection: "accounts",
schema: {
username: { type: String, required: true },
password: { type: String, required: true, select: false },
email: { type: String, required: true, select: false },
description: { type: String, default: null },
created_at: { type: String },
public_name: { type: String, default: null },
cover: { type: String, default: null },
avatar: { type: String, default: null },
roles: { type: Array, default: [] },
verified: { type: Boolean, default: false },
badges: { type: Array, default: [] },
links: { type: Array, default: [] },
location: { type: String, default: null },
birthday: { type: Date, default: null, select: false },
accept_tos: { type: Boolean, default: false },
username: {
type: String,
required: true
},
password: {
type: String,
required: true,
select: false
},
email: {
type: String,
required: true,
select: false
},
description: {
type: String,
default: null
},
created_at: {
type: String
},
public_name: {
type: String,
default: null
},
cover: {
type: String,
default: null
},
avatar: {
type:
String,
default: null
},
roles: {
type: Array,
default: []
},
verified: {
type: Boolean,
default: false
},
badges: {
type: Array,
default: []
},
links: {
type: Array,
default: []
},
location: {
type: String,
default: null
},
birthday: {
type: Date,
default: null,
select: false
},
accept_tos: {
type: Boolean,
default: false
},
activated: {
type: Boolean,
default: false,
}
}
}

View File

@ -0,0 +1,18 @@
export default {
name: "VotePoll",
collection: "votes_poll",
schema: {
user_id: {
type: String,
required: true
},
post_id: {
type: String,
required: true
},
option_id: {
type: String,
required: true
}
}
}

View File

@ -5,7 +5,7 @@ import Spinnies from "spinnies"
import { Observable } from "@gullerya/object-observer"
import { dots as DefaultSpinner } from "spinnies/spinners.json"
import EventEmitter from "@foxify/events"
import IPCRouter from "linebridge/dist/server/classes/IPCRouter"
import IPCRouter from "linebridge/dist/classes/IPCRouter"
import chokidar from "chokidar"
import { onExit } from "signal-exit"
import chalk from "chalk"
@ -76,6 +76,7 @@ export default class Gateway {
cwd,
onReload: this.serviceHandlers.onReload,
onClose: this.serviceHandlers.onClose,
onError: this.serviceHandlers.onError,
onIPCData: this.serviceHandlers.onIPCData,
})
@ -218,6 +219,7 @@ export default class Gateway {
cwd,
onReload: this.serviceHandlers.onReload,
onClose: this.serviceHandlers.onClose,
onError: this.serviceHandlers.onError,
onIPCData: this.serviceHandlers.onIPCData,
})
@ -246,11 +248,18 @@ export default class Gateway {
console.log(`[${id}] Exit with code ${code}`)
if (err) {
console.error(err)
}
// try to unregister from proxy
this.proxy.unregisterAllFromService(id)
this.serviceRegistry[id].ready = false
},
onError: (id, err) => {
console.error(`[${id}] Error`, err)
},
}
onAllServicesReload = (id) => {
@ -382,7 +391,7 @@ export default class Gateway {
process.stdout.setMaxListeners(50)
process.stderr.setMaxListeners(50)
this.services = await scanServices()
this.proxy = new Proxy()
this.ipcRouter = new IPCRouter()

View File

@ -1,5 +1,5 @@
import httpProxy from "http-proxy"
import defaults from "linebridge/dist/server/defaults"
import defaults from "linebridge/dist/defaults"
import pkg from "../package.json"

Some files were not shown because too many files have changed in this diff Show More