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", "capacitor-music-controls-plugin-v3": "^1.1.0",
"classnames": "2.3.1", "classnames": "2.3.1",
"dompurify": "^3.0.0", "dompurify": "^3.0.0",
"evite": "^0.17.0", "vessel": "^0.18.0",
"fast-average-color": "^9.2.0", "fast-average-color": "^9.2.0",
"framer-motion": "^10.12.17", "framer-motion": "^10.12.17",
"fuse.js": "6.5.3", "fuse.js": "6.5.3",

View File

@ -2,7 +2,7 @@ import "./patches"
import config from "@config" import config from "@config"
import React from "react" import React from "react"
import { EviteRuntime } from "evite" import { Runtime } from "vessel"
import { Helmet } from "react-helmet" import { Helmet } from "react-helmet"
import { Translation } from "react-i18next" import { Translation } from "react-i18next"
import * as Sentry from "@sentry/browser" import * as Sentry from "@sentry/browser"
@ -109,16 +109,12 @@ class ComtyApp extends React.Component {
}, },
openLoginForm: async (options = {}) => { openLoginForm: async (options = {}) => {
app.layout.draggable.open("login", Login, { app.layout.draggable.open("login", Login, {
defaultLocked: options.defaultLocked ?? false,
componentProps: {
sessionController: this.sessionController,
},
props: { props: {
fillEnd: true, sessionController: this.sessionController,
bodyStyle: { onDone: () => {
height: "100%", app.layout.draggable.destroy("login")
} }
} },
}) })
}, },
openAppsMenu: () => { 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 { export default class TrackInstance {
constructor(player, manifest) { constructor(player, manifest) {
if (!player) { if (!player) {
@ -14,6 +16,8 @@ export default class TrackInstance {
return this return this
} }
_initialized = false
audio = null audio = null
contextElement = null contextElement = null
@ -24,57 +28,6 @@ export default class TrackInstance {
waitUpdateTimeout = null 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 = { mediaEvents = {
"ended": () => { "ended": () => {
this.player.next() this.player.next()
@ -124,4 +77,63 @@ export default class TrackInstance {
this.player.eventBus.emit(`player.seeked`, this.audio.currentTime) 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 ImageViewer from "@components/ImageViewer"
import { Icons } from "@components/Icons" 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 { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
import "./index.less" import "./index.less"
@ -28,7 +28,7 @@ const Track = (props) => {
loading, loading,
track_manifest, track_manifest,
playback_status, playback_status,
} = React.useContext(PlayerContext) } = usePlayerStateContext()
const playlist_ctx = React.useContext(PlaylistContext) const playlist_ctx = React.useContext(PlaylistContext)
@ -186,12 +186,16 @@ const Track = (props) => {
{ {
props.track.service === "tidal" && <Icons.SiTidal /> props.track.service === "tidal" && <Icons.SiTidal />
} }
{props.track.title} {
props.track.title
}
</span> </span>
</div> </div>
<div className="music-track_artist"> <div className="music-track_artist">
<span> <span>
{props.track.artist} {
Array.isArray(props.track.artists) ? props.track.artists.join(", ") : props.track.artist
}
</span> </span>
</div> </div>
</div> </div>

View File

@ -99,7 +99,7 @@ html {
align-items: center; align-items: center;
padding: 10px; padding: 6px;
} }
.music-track_actions { .music-track_actions {
@ -182,11 +182,11 @@ html {
overflow: hidden; overflow: hidden;
width: 50px; width: 35px;
height: 50px; height: 35px;
min-width: 50px; min-width: 35px;
min-height: 50px; min-height: 35px;
img { img {
width: 100%; 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 * as antd from "antd"
import axios from "axios" import axios from "axios"
import "./index.less"
const LyricsTextView = (props) => { const LyricsTextView = (props) => {
const { lang, track } = props const { lrcURL } = props
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState(null) const [error, setError] = React.useState(null)
@ -24,19 +26,19 @@ const LyricsTextView = (props) => {
return null return null
}) })
if (data) { if (data) {
setLyrics(data.data) setLyrics(data.data.split("\n"))
} }
setLoading(false) setLoading(false)
} }
React.useEffect(() => { React.useEffect(() => {
getLyrics(lang.value) getLyrics(lrcURL)
}, [lang]) }, [lrcURL])
if (!lang) { if (!lrcURL) {
return null return null
} }
@ -52,8 +54,21 @@ const LyricsTextView = (props) => {
return <antd.Skeleton active /> return <antd.Skeleton active />
} }
return <div> if (!lyrics) {
<p>{lyrics}</p> 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> </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 React from "react"
import * as antd from "antd" import * as antd from "antd"
import { Icons } from "@components/Icons" import { Icons, createIconRender } from "@components/Icons"
import MusicModel from "@models/music" import MusicModel from "@models/music"
import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey"
import TrackManifest from "@classes/TrackManifest"
import { DefaultReleaseEditorState, ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor" import { DefaultReleaseEditorState, ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
import Tabs from "./tabs" import Tabs from "./tabs"
import "./index.less" import "./index.less"
console.log(MusicModel.deleteRelease)
const ReleaseEditor = (props) => { const ReleaseEditor = (props) => {
const { release_id } = props const { release_id } = props
const basicInfoRef = React.useRef() const basicInfoRef = React.useRef()
const [submitting, setSubmitting] = React.useState(false) const [submitting, setSubmitting] = React.useState(false)
const [loading, setLoading] = React.useState(true)
const [submitError, setSubmitError] = React.useState(null) const [submitError, setSubmitError] = React.useState(null)
const [loading, setLoading] = React.useState(true)
const [loadError, setLoadError] = React.useState(null) const [loadError, setLoadError] = React.useState(null)
const [globalState, setGlobalState] = React.useState(DefaultReleaseEditorState) const [globalState, setGlobalState] = React.useState(DefaultReleaseEditorState)
const [selectedTab, setSelectedTab] = React.useState("info")
const [customPage, setCustomPage] = React.useState(null) const [customPage, setCustomPage] = React.useState(null)
const [customPageActions, setCustomPageActions] = React.useState([])
const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
defaultKey: "info",
queryKey: "tab"
})
async function initialize() { async function initialize() {
setLoading(true) setLoading(true)
@ -33,7 +41,13 @@ const ReleaseEditor = (props) => {
if (release_id !== "new") { if (release_id !== "new") {
try { 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({ setGlobalState({
...globalState, ...globalState,
@ -47,21 +61,23 @@ const ReleaseEditor = (props) => {
setLoading(false) setLoading(false)
} }
async function renderCustomPage(page, actions) {
setCustomPage(page ?? null)
setCustomPageActions(actions ?? [])
}
async function handleSubmit() { async function handleSubmit() {
setSubmitting(true) setSubmitting(true)
setSubmitError(null) setSubmitError(null)
try { try {
// first sumbit tracks // first sumbit tracks
console.time("submit:tracks:")
const tracks = await MusicModel.putTrack({ const tracks = await MusicModel.putTrack({
list: globalState.list, list: globalState.list,
}) })
console.timeEnd("submit:tracks:")
// then submit release // then submit release
console.time("submit:release:") const result = await MusicModel.putRelease({
await MusicModel.putRelease({
_id: globalState._id, _id: globalState._id,
title: globalState.title, title: globalState.title,
description: globalState.description, description: globalState.description,
@ -71,7 +87,8 @@ const ReleaseEditor = (props) => {
type: globalState.type, type: globalState.type,
list: tracks.list, list: tracks.list,
}) })
console.timeEnd("submit:release:")
app.location.push(`/studio/music/${result._id}`)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
app.message.error(error.message) app.message.error(error.message)
@ -84,8 +101,6 @@ const ReleaseEditor = (props) => {
setSubmitting(false) setSubmitting(false)
app.message.success("Release saved") app.message.success("Release saved")
return release
} }
async function handleDelete() { async function handleDelete() {
@ -99,10 +114,6 @@ const ReleaseEditor = (props) => {
}) })
} }
async function onFinish(values) {
console.log(values)
}
async function canFinish() { async function canFinish() {
return true return true
} }
@ -125,10 +136,18 @@ const ReleaseEditor = (props) => {
const Tab = Tabs.find(({ key }) => key === selectedTab) const Tab = Tabs.find(({ key }) => key === selectedTab)
const CustomPageProps = {
close: () => {
renderCustomPage(null, null)
}
}
return <ReleaseEditorStateContext.Provider return <ReleaseEditorStateContext.Provider
value={{ value={{
...globalState, ...globalState,
setCustomPage, setGlobalState,
renderCustomPage,
setCustomPageActions,
}} }}
> >
<div className="music-studio-release-editor"> <div className="music-studio-release-editor">
@ -139,29 +158,47 @@ const ReleaseEditor = (props) => {
<div className="music-studio-release-editor-custom-page-header-title"> <div className="music-studio-release-editor-custom-page-header-title">
<antd.Button <antd.Button
icon={<Icons.IoIosArrowBack />} icon={<Icons.IoIosArrowBack />}
onClick={() => setCustomPage(null)} onClick={() => renderCustomPage(null, null)}
/> />
<h2>{customPage.header}</h2> <h2>{customPage.header}</h2>
</div> </div>
{ {
customPage.props?.onSave && <antd.Button Array.isArray(customPageActions) && customPageActions.map((action, index) => {
type="primary" return <antd.Button
icon={<Icons.FiSave />} key={index}
onClick={() => customPage.props.onSave()} type={action.type}
> icon={createIconRender(action.icon)}
Save onClick={async () => {
</antd.Button> if (typeof action.onClick === "function") {
await action.onClick()
}
if (action.fireEvent) {
app.eventBus.emit(action.fireEvent)
}
}}
disabled={action.disabled}
>
{action.label}
</antd.Button>
})
} }
</div> </div>
} }
{ {
React.cloneElement(customPage.content, { customPage.content && (React.isValidElement(customPage.content) ?
...customPage.props, React.cloneElement(customPage.content, {
close: () => setCustomPage(null), ...CustomPageProps,
}) ...customPage.props
}) :
React.createElement(customPage.content, {
...CustomPageProps,
...customPage.props
})
)
} }
</div> </div>
} }
@ -179,11 +216,11 @@ const ReleaseEditor = (props) => {
<antd.Button <antd.Button
type="primary" type="primary"
onClick={handleSubmit} onClick={handleSubmit}
icon={<Icons.FiSave />} icon={release_id !== "new" ? <Icons.FiSave /> : <Icons.MdSend />}
disabled={submitting || loading || !canFinish()} disabled={submitting || loading || !canFinish()}
loading={submitting} loading={submitting}
> >
Save {release_id !== "new" ? "Save" : "Release"}
</antd.Button> </antd.Button>
{ {
@ -208,6 +245,12 @@ const ReleaseEditor = (props) => {
</div> </div>
<div className="music-studio-release-editor-content"> <div className="music-studio-release-editor-content">
{
submitError && <antd.Alert
message={submitError.message}
type="error"
/>
}
{ {
!Tab && <antd.Result !Tab && <antd.Result
status="error" status="error"
@ -218,7 +261,6 @@ const ReleaseEditor = (props) => {
{ {
Tab && React.createElement(Tab.render, { Tab && React.createElement(Tab.render, {
release: globalState, release: globalState,
onFinish: onFinish,
state: globalState, state: globalState,
setState: setGlobalState, setState: setGlobalState,

View File

@ -20,17 +20,19 @@ const TrackListItem = (props) => {
const { track } = props const { track } = props
async function onClickEditTrack() { async function onClickEditTrack() {
context.setCustomPage({ context.renderCustomPage({
header: "Track Editor", header: "Track Editor",
content: <TrackEditor track={track} />, content: <TrackEditor />,
props: { props: {
onSave: (newTrackData) => { track: track,
console.log("Saving track", newTrackData)
},
} }
}) })
} }
async function onClickRemoveTrack() {
props.onDelete(track.uid)
}
return <Draggable return <Draggable
key={track._id} key={track._id}
draggableId={track._id} draggableId={track._id}
@ -43,12 +45,20 @@ const TrackListItem = (props) => {
"music-studio-release-editor-tracks-list-item", "music-studio-release-editor-tracks-list-item",
{ {
["loading"]: loading, ["loading"]: loading,
["failed"]: !!error ["failed"]: !!error,
["disabled"]: props.disabled,
} }
)} )}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...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"> <div className="music-studio-release-editor-tracks-list-item-index">
<span>{props.index + 1}</span> <span>{props.index + 1}</span>
</div> </div>
@ -65,10 +75,23 @@ const TrackListItem = (props) => {
<span>{track.title}</span> <span>{track.title}</span>
<div className="music-studio-release-editor-tracks-list-item-actions"> <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 <antd.Button
type="ghost" type="ghost"
icon={<Icons.FiEdit2 />} icon={<Icons.FiEdit2 />}
onClick={onClickEditTrack} onClick={onClickEditTrack}
disabled={props.disabled}
/> />
<div <div

View File

@ -14,6 +14,21 @@
background-color: var(--background-color-accent); 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 { .music-studio-release-editor-tracks-list-item-actions {
position: absolute; position: absolute;

View File

@ -2,7 +2,8 @@ import React from "react"
import * as antd from "antd" import * as antd from "antd"
import classnames from "classnames" import classnames from "classnames"
import { DragDropContext, Droppable } from "react-beautiful-dnd" 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" import { Icons } from "@components/Icons"
@ -11,124 +12,14 @@ import UploadHint from "./components/UploadHint"
import "./index.less" 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 { class TracksManager extends React.Component {
state = { state = {
list: [], list: Array.isArray(this.props.list) ? this.props.list : [],
pendingUploads: [], pendingUploads: [],
} }
componentDidMount() {
if (typeof this.props.list !== "undefined" && Array.isArray(this.props.list)) {
this.setState({
list: this.props.list
})
}
}
componentDidUpdate = (prevProps, prevState) => { 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") { if (typeof this.props.onChangeState === "function") {
this.props.onChangeState(this.state) this.props.onChangeState(this.state)
} }
@ -158,12 +49,16 @@ class TracksManager extends React.Component {
return false return false
} }
this.removeTrackUIDFromPendingUploads(uid)
this.setState({ this.setState({
list: this.state.list.filter((item) => item.uid !== uid), list: this.state.list.filter((item) => item.uid !== uid),
}) })
} }
modifyTrackByUid = (uid, track) => { modifyTrackByUid = (uid, track) => {
console.log("modifyTrackByUid", uid, track)
if (!uid || !track) { if (!uid || !track) {
return false return false
} }
@ -187,9 +82,17 @@ class TracksManager extends React.Component {
return false return false
} }
if (!this.state.pendingUploads.includes(uid)) { const pendingUpload = this.state.pendingUploads.find((item) => item.uid === uid)
if (!pendingUpload) {
this.setState({ 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({ 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) => { handleUploaderStateChange = async (change) => {
const uid = change.file.uid const uid = change.file.uid
console.log("handleUploaderStateChange", change)
switch (change.file.status) { switch (change.file.status) {
case "uploading": { case "uploading": {
this.addTrackUIDToPendingUploads(uid) this.addTrackUIDToPendingUploads(uid)
@ -214,23 +147,20 @@ class TracksManager extends React.Component {
const trackManifest = new TrackManifest({ const trackManifest = new TrackManifest({
uid: uid, uid: uid,
file: change.file, file: change.file,
onChange: this.modifyTrackByUid
}) })
this.addTrackToList(trackManifest) this.addTrackToList(trackManifest)
const trackData = await trackManifest.initialize()
this.modifyTrackByUid(uid, trackData)
break break
} }
case "done": { case "done": {
// remove pending file // remove pending file
this.removeTrackUIDFromPendingUploads(uid) 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!`) console.error(`Track with uid [${uid}] not found!`)
break break
} }
@ -240,6 +170,8 @@ class TracksManager extends React.Component {
source: change.file.response.url source: change.file.response.url
}) })
await trackManifest.initialize()
break break
} }
case "error": { case "error": {
@ -278,7 +210,7 @@ class TracksManager extends React.Component {
} }
handleTrackFileUploadProgress = async (file, progress) => { handleTrackFileUploadProgress = async (file, progress) => {
console.log(file, progress) this.updateUploadProgress(file.uid, progress)
} }
orderTrackList = (result) => { orderTrackList = (result) => {
@ -301,6 +233,7 @@ class TracksManager extends React.Component {
render() { render() {
console.log(`Tracks List >`, this.state.list) console.log(`Tracks List >`, this.state.list)
return <div className="music-studio-release-editor-tracks"> return <div className="music-studio-release-editor-tracks">
<antd.Upload <antd.Upload
className="music-studio-tracks-uploader" className="music-studio-tracks-uploader"
@ -341,9 +274,15 @@ class TracksManager extends React.Component {
} }
{ {
this.state.list.map((track, index) => { this.state.list.map((track, index) => {
const progress = this.getUploadProgress(track.uid)
return <TrackListItem return <TrackListItem
index={index} index={index}
track={track} 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 CoverEditor from "@components/CoverEditor"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
import EnhancedLyricsEditor from "@components/MusicStudio/EnhancedLyricsEditor"
import LyricsEditor from "@components/MusicStudio/LyricsEditor"
import VideoEditor from "@components/MusicStudio/VideoEditor"
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor" import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
@ -24,43 +22,48 @@ const TrackEditor = (props) => {
}) })
} }
async function openLyricsEditor() { async function openEnhancedLyricsEditor() {
context.setCustomPage({ context.renderCustomPage({
header: "Lyrics Editor", header: "Enhanced Lyrics",
content: <LyricsEditor track={track} />, content: EnhancedLyricsEditor,
props: { props: {
onSave: () => { track: track,
console.log("Saved lyrics")
},
} }
}) })
} }
async function openVideoEditor() { async function handleOnSave() {
context.setCustomPage({ setTrack((prev) => {
header: "Video Editor", const listData = [...context.list]
content: <VideoEditor track={track} />,
props: { const trackIndex = listData.findIndex((item) => item.uid === prev.uid)
onSave: () => {
console.log("Saved video") if (trackIndex === -1) {
}, return prev
} }
listData[trackIndex] = prev
context.setGlobalState({
...context,
list: listData
})
return prev
}) })
} }
async function onClose() { React.useEffect(() => {
if (typeof props.close === "function") { context.setCustomPageActions([
props.close() {
} label: "Save",
} icon: "FiSave",
type: "primary",
async function onSave() { onClick: handleOnSave,
await props.onSave(track) disabled: props.track === track,
},
if (typeof props.close === "function") { ])
props.close() }, [track])
}
}
return <div className="track-editor"> return <div className="track-editor">
<div className="track-editor-field"> <div className="track-editor-field">
@ -131,49 +134,32 @@ const TrackEditor = (props) => {
/> />
</div> </div>
<antd.Divider
style={{
margin: "5px 0",
}}
/>
<div className="track-editor-field"> <div className="track-editor-field">
<div className="track-editor-field-header"> <div className="track-editor-field-header">
<Icons.TbMovie /> <Icons.MdLyrics />
<span>Edit Video</span> <span>Enhanced Lyrics</span>
<antd.Switch
checked={track.lyrics_enabled}
onChange={(value) => handleChange("lyrics_enabled", value)}
disabled={!track.params._id}
/>
</div> </div>
<antd.Button <div className="track-editor-field-actions">
onClick={openVideoEditor} <antd.Button
> disabled={!track.params._id}
Edit onClick={openEnhancedLyricsEditor}
</antd.Button> >
</div> Edit
</antd.Button>
<div className="track-editor-field"> {
<div className="track-editor-field-header"> !track.params._id && <span>
<Icons.MdTextFormat /> You cannot edit Video and Lyrics without release first
<span>Edit Lyrics</span> </span>
}
</div> </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>
</div> </div>
} }

View File

@ -35,6 +35,8 @@
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: 7px;
width: 100%; width: 100%;
h3 { h3 {
@ -46,8 +48,8 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start;
align-items: center; align-items: center;
justify-content: center;
gap: 10px; 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 AudioVolume from "@components/Player/AudioVolume"
import AudioPlayerChangeModeButton from "@components/Player/ChangeModeButton" import AudioPlayerChangeModeButton from "@components/Player/ChangeModeButton"
import { Context } from "@contexts/WithPlayerContext" import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import "./index.less" import "./index.less"
@ -17,9 +17,6 @@ const EventsHandlers = {
"playback": () => { "playback": () => {
return app.cores.player.playback.toggle() return app.cores.player.playback.toggle()
}, },
"like": async (ctx) => {
await app.cores.player.toggleCurrentTrackLike(!ctx.track_manifest?.liked)
},
"previous": () => { "previous": () => {
return app.cores.player.playback.previous() return app.cores.player.playback.previous()
}, },
@ -27,98 +24,96 @@ const EventsHandlers = {
return app.cores.player.playback.next() return app.cores.player.playback.next()
}, },
"volume": (ctx, value) => { "volume": (ctx, value) => {
return app.cores.player.volume(value) return app.cores.player.controls.volume(value)
}, },
"mute": () => { "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) => { const Controls = (props) => {
try { const playerState = usePlayerStateContext()
const ctx = React.useContext(Context)
const handleAction = (event, ...args) => { const handleAction = (event, ...args) => {
if (typeof EventsHandlers[event] !== "function") { if (typeof EventsHandlers[event] !== "function") {
throw new Error(`Unknown event "${event}"`) throw new Error(`Unknown event "${event}"`)
}
return EventsHandlers[event](ctx, ...args)
} }
return <div return EventsHandlers[event](playerState, ...args)
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 <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 export default Controls

View File

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

View File

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

View File

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

View File

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

View File

@ -41,6 +41,7 @@ export default (props) => {
handleOnStart(req.file.uid, req.file) handleOnStart(req.file.uid, req.file)
await app.cores.remoteStorage.uploadFile(req.file, { await app.cores.remoteStorage.uploadFile(req.file, {
headers: props.headers,
onProgress: (file, progress) => { onProgress: (file, progress) => {
setProgess(progress) setProgess(progress)
handleOnProgress(file.uid, 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" import React from "react"
export const DefaultContextValues = { function deepUnproxy(obj) {
loading: false, // Verificar si es un array y hacer una copia en consecuencia
minimized: false, if (Array.isArray(obj)) {
obj = [...obj];
} else {
obj = Object.assign({}, obj);
}
muted: false, for (let key in obj) {
volume: 1, if (obj[key] && typeof obj[key] === "object") {
obj[key] = deepUnproxy(obj[key]); // Recursión para profundizar en objetos y arrays
}
}
sync_mode: false, return obj;
livestream_mode: false,
control_locked: false,
track_cover_analysis: null,
track_metadata: null,
playback_mode: "repeat",
playback_status: null,
} }
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 { export class WithPlayerContext extends React.Component {
state = { state = app.cores.player.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"],
}
events = { events = {
"player.state.update": (state) => { "player.state.update": (state) => {
@ -44,17 +54,15 @@ export class WithPlayerContext extends React.Component {
}, },
} }
eventBus = app.cores.player.eventBus
componentDidMount() { componentDidMount() {
for (const [event, handler] of Object.entries(this.events)) { for (const [event, handler] of Object.entries(this.events)) {
this.eventBus.on(event, handler) app.cores.player.eventBus().on(event, handler)
} }
} }
componentWillUnmount() { componentWillUnmount() {
for (const [event, handler] of Object.entries(this.events)) { 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; padding: 4px;
font-weight: 600; font-weight: 450;
font-family: var(--fontFamily); font-family: var(--fontFamily);
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-color); 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 { export default class Presets {
constructor({ 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 { Core } from "vessel"
import { Observable } from "object-observer"
import { FastAverageColor } from "fast-average-color"
import ToolBarPlayer from "@components/Player/ToolBarPlayer" import TrackInstance from "@classes/TrackInstance"
import BackgroundMediaPlayer from "@components/Player/BackgroundMediaPlayer" 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 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 { export default class Player extends Core {
// core config
static dependencies = [ static dependencies = [
"api", "api",
"settings" "settings"
] ]
static namespace = "player" static namespace = "player"
static bgColor = "aquamarine" static bgColor = "aquamarine"
static textColor = "black" static textColor = "black"
// player config
static defaultSampleRate = 48000 static defaultSampleRate = 48000
static gradualFadeMs = 150 static gradualFadeMs = 150
// buffer & precomputation
static maxManifestPrecompute = 3 static maxManifestPrecompute = 3
state = new PlayerState(this)
ui = new PlayerUI(this)
service_providers = new ServiceProviders() service_providers = new ServiceProviders()
native_controls = new MediaSession() native_controls = new MediaSession()
currentDomWindow = null
audioContext = new AudioContext({ audioContext = new AudioContext({
sampleRate: AudioPlayerStorage.get("sample_rate") ?? Player.defaultSampleRate, sampleRate: AudioPlayerStorage.get("sample_rate") ?? Player.defaultSampleRate,
latencyHint: "playback" latencyHint: "playback"
}) })
audioProcessors = [] audioProcessors = new PlayerProcessors(this)
eventBus = new EventBus()
fac = new FastAverageColor()
track_prev_instances = [] track_prev_instances = []
track_instance = null track_instance = null
track_next_instances = [] 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 = { public = {
audioContext: this.audioContext, start: this.start,
setSampleRate: this.setSampleRate, close: this.close,
start: this.start.bind(this), playback: this.bindableReadOnlyProxy({
close: this.close.bind(this), toggle: this.togglePlayback,
playback: { play: this.resumePlayback,
mode: this.playbackMode.bind(this), pause: this.pausePlayback,
stop: this.stop.bind(this), stop: this.stopPlayback,
toggle: this.togglePlayback.bind(this), previous: this.previous,
pause: this.pausePlayback.bind(this), next: this.next,
play: this.resumePlayback.bind(this), mode: this.playbackMode,
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, { controls: this.bindableReadOnlyProxy({
get: (target, prop) => { duration: this.duration,
return target[prop] volume: this.volume,
}, mute: this.mute,
set: (target, prop, value) => { seek: this.seek,
return false setSampleRate: setSampleRate,
}
}), }),
gradualFadeMs: Player.gradualFadeMs, track: () => {
trackInstance: () => {
return this.track_instance return this.track_instance
} },
eventBus: () => {
return this.eventBus
},
state: this.state,
ui: this.ui.public,
audioContext: this.audioContext,
gradualFadeMs: Player.gradualFadeMs,
} }
internalEvents = { async initializeAfterCoresInit() {
"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) { if (app.isMobile) {
this.state.audioVolume = 1 this.state.volume = 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) { await this.native_controls.initialize()
this.audioProcessors.push(new defaultProccessor(this)) await this.audioProcessors.initialize()
}
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
} }
// //
@ -265,22 +106,6 @@ export default class Player extends Core {
return false 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) { if (isIndex) {
this.track_next_instances[index] = instance this.track_next_instances[index] = instance
} }
@ -288,7 +113,7 @@ export default class Player extends Core {
return instance return instance
} }
async destroyCurrentInstance({ sync = false } = {}) { async destroyCurrentInstance() {
if (!this.track_instance) { if (!this.track_instance) {
return false return false
} }
@ -300,36 +125,6 @@ export default class Player extends Core {
// reset track_instance // reset track_instance
this.track_instance = null 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() this.destroyCurrentInstance()
} }
// attach processors // chage current track instance with provided
instance = await this.attachProcessorsToInstance(instance) this.track_instance = instance
// now set the current instance // initialize instance if is not
this.track_instance = await this.preloadAudioInstance(instance) 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 // reconstruct audio src if is not set
if (this.track_instance.audio.src !== instance.manifest.source) { if (this.track_instance.audio.src !== this.track_instance.manifest.source) {
this.track_instance.audio.src = instance.manifest.source this.track_instance.audio.src = this.track_instance.manifest.source
} }
// set time to 0 // set time to provided time, if not, set to 0
this.track_instance.audio.currentTime = 0 this.track_instance.audio.currentTime = params.time ?? 0
if (params.time >= 0) {
this.track_instance.audio.currentTime = params.time
}
this.track_instance.audio.muted = this.state.muted this.track_instance.audio.muted = this.state.muted
this.track_instance.audio.loop = this.state.playback_mode === "repeat" this.track_instance.audio.loop = this.state.playback_mode === "repeat"
this.track_instance.gainNode.gain.value = this.state.volume 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 // play
await this.track_instance.audio.play() await this.track_instance.audio.play()
this.console.debug(`Playing track >`, this.track_instance) this.console.debug(`Playing track >`, this.track_instance)
// update manifest // update native controls
this.state.track_manifest = instance.manifest this.native_controls.update(this.track_instance.manifest)
this.native_controls.update(instance.manifest)
return this.track_instance return this.track_instance
} }
async start(manifest, { sync = false, time, startIndex = 0 } = {}) { async start(manifest, { time, startIndex = 0 } = {}) {
if (this.state.control_locked && !sync) { this.ui.attachPlayerComponent()
this.console.warn("Controls are locked, cannot do this action")
return false
}
this.attachPlayerComponent()
// !IMPORTANT: abort preloads before destroying current instance // !IMPORTANT: abort preloads before destroying current instance
await this.abortPreloads() await this.abortPreloads()
await this.destroyCurrentInstance({ await this.destroyCurrentInstance()
sync
})
this.state.loading = true this.state.loading = true
@ -438,9 +222,8 @@ export default class Player extends Core {
playlist = playlist.slice(startIndex) playlist = playlist.slice(startIndex)
for await (const [index, _manifest] of playlist.entries()) { for (const [index, _manifest] of playlist.entries()) {
let instance = new TrackInstanceClass(this, _manifest) let instance = new TrackInstance(this, _manifest)
instance = await instance.initialize()
this.track_next_instances.push(instance) this.track_next_instances.push(instance)
@ -454,12 +237,7 @@ export default class Player extends Core {
return manifest return manifest
} }
next({ sync = false } = {}) { next() {
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) { if (this.track_next_instances.length > 0) {
// move current audio instance to history // move current audio instance to history
this.track_prev_instances.push(this.track_next_instances.shift()) 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) { if (this.track_next_instances.length === 0) {
this.console.log(`No more tracks to play, stopping...`) this.console.log(`No more tracks to play, stopping...`)
return this.stop() return this.stopPlayback()
} }
let nextIndex = 0 let nextIndex = 0
@ -480,12 +258,7 @@ export default class Player extends Core {
this.play(this.track_next_instances[nextIndex]) this.play(this.track_next_instances[nextIndex])
} }
previous({ sync = false } = {}) { previous() {
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) { if (this.track_prev_instances.length > 0) {
// move current audio instance to history // move current audio instance to history
this.track_next_instances.unshift(this.track_prev_instances.pop()) this.track_next_instances.unshift(this.track_prev_instances.pop())
@ -500,6 +273,9 @@ export default class Player extends Core {
} }
} }
//
// Playback Control
//
async togglePlayback() { async togglePlayback() {
if (this.state.playback_status === "paused") { if (this.state.playback_status === "paused") {
await this.resumePlayback() await this.resumePlayback()
@ -509,6 +285,10 @@ export default class Player extends Core {
} }
async pausePlayback() { async pausePlayback() {
if (!this.state.playback_status === "paused") {
return true
}
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
if (!this.track_instance) { if (!this.track_instance) {
this.console.error("No audio 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.destroyCurrentInstance()
this.abortPreloads() this.abortPreloads()
this.state.playback_status = "stopped" this.state.playback_status = "stopped"
this.state.track_manifest = null this.state.track_manifest = null
this.state.livestream_mode = false
this.track_instance = null this.track_instance = null
this.track_next_instances = [] this.track_next_instances = []
this.track_prev_instances = [] this.track_prev_instances = []
@ -574,12 +368,19 @@ export default class Player extends Core {
this.native_controls.destroy() this.native_controls.destroy()
} }
//
// Audio Control
//
mute(to) { mute(to) {
if (app.isMobile && typeof to !== "boolean") { if (app.isMobile && typeof to !== "boolean") {
this.console.warn("Cannot mute on mobile") this.console.warn("Cannot mute on mobile")
return false return false
} }
if (to === "toggle") {
to = !this.state.muted
}
if (typeof to === "boolean") { if (typeof to === "boolean") {
this.state.muted = to this.state.muted = to
this.track_instance.audio.muted = to this.track_instance.audio.muted = to
@ -621,7 +422,7 @@ export default class Player extends Core {
return this.state.volume return this.state.volume
} }
seek(time, { sync = false } = {}) { seek(time) {
if (!this.track_instance || !this.track_instance.audio) { if (!this.track_instance || !this.track_instance.audio) {
return false return false
} }
@ -631,12 +432,6 @@ export default class Player extends Core {
return this.track_instance.audio.currentTime 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 time is provided, seek to that time
if (typeof time === "number") { if (typeof time === "number") {
this.console.log(`Seeking to ${time} | Duration: ${this.track_instance.audio.duration}`) 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() { duration() {
if (!this.track_instance) { if (!this.track_instance || !this.track_instance.audio) {
return false return false
} }
@ -687,93 +466,7 @@ export default class Player extends Core {
} }
close() { close() {
this.stop() this.stopPlayback()
this.detachPlayerComponent() this.ui.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)
}
})
})
} }
} }

View File

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

View File

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

View File

@ -35,7 +35,7 @@ export default class GainProcessorNode extends ProcessorNode {
applyValues() { applyValues() {
// apply to current instance // 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() { async init() {

View File

@ -1,178 +1,139 @@
import { EventBus } from "vessel" import { EventBus } from "vessel"
import SessionModel from "@models/session"
export default class ChunkedUpload { export default class ChunkedUpload {
constructor(params) { constructor(params) {
this.endpoint = params.endpoint const {
this.file = params.file endpoint,
this.headers = params.headers || {} file,
this.postParams = params.postParams headers = {},
this.service = params.service ?? "default" splitChunkSize = 1024 * 1024 * 10,
this.retries = params.retries ?? app.cores.settings.get("uploader.retries") ?? 3 maxRetries = 3,
this.delayBeforeRetry = params.delayBeforeRetry || 5 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.chunkCount = 0
this.splitChunkSize = params.splitChunkSize || 1024 * 1024 * 10
this.totalChunks = Math.ceil(this.file.size / this.splitChunkSize)
this.retriesCount = 0 this.retriesCount = 0
this.offline = false
this.paused = false
this.headers["Authorization"] = `Bearer ${SessionModel.token}` this.splitChunkSize = splitChunkSize
this.headers["uploader-original-name"] = encodeURIComponent(this.file.name) this.totalChunks = Math.ceil(file.size / splitChunkSize)
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._reader = new FileReader() this.maxRetries = maxRetries
this.eventBus = new EventBus() 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() this.nextSend()
console.debug("[Uploader] Created", { console.debug("[Uploader] Created", {
splitChunkSize: this.splitChunkSize, splitChunkSize: splitChunkSize,
totalChunks: this.totalChunks, totalChunks: this.totalChunks,
totalSize: this.file.size, totalSize: 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")
}) })
} }
on(event, fn) { _reader = new FileReader()
this.eventBus.on(event, fn) 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() { getFileUID(file) {
if (!this.endpoint || !this.endpoint.length) throw new TypeError("endpoint must be defined") return Math.floor(Math.random() * 100000000) + Date.now() + file.size + "_tmp"
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"
} }
loadChunk() { loadChunk() {
return new Promise((resolve) => { return new Promise((resolve) => {
const length = this.totalChunks === 1 ? this.file.size : this.splitChunkSize const start = this.chunkCount * this.splitChunkSize
const start = length * this.chunkCount const end = Math.min(start + this.splitChunkSize, this.file.size)
this._reader.onload = () => { this._reader.onload = () => resolve(new Blob([this._reader.result], { type: "application/octet-stream" }))
this.chunk = new Blob([this._reader.result], { type: "application/octet-stream" }) this._reader.readAsArrayBuffer(this.file.slice(start, end))
resolve()
}
this._reader.readAsArrayBuffer(this.file.slice(start, start + length))
}) })
} }
sendChunk() { async sendChunk() {
const form = new FormData() 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) form.append("file", this.chunk)
this.headers["uploader-chunk-number"] = this.chunkCount 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() { manageRetries() {
if (this.retriesCount++ < this.retries) { if (++this.retriesCount < this.maxRetries) {
setTimeout(() => this.nextSend(), this.delayBeforeRetry * 1000) setTimeout(() => this.nextSend(), this.delayBeforeRetry * 1000)
this.eventBus.emit("fileRetry", { this.events.emit("fileRetry", { message: `Retrying chunk ${this.chunkCount}`, chunk: this.chunkCount, retriesLeft: this.retries - this.retriesCount })
message: `An error occured uploading chunk ${this.chunkCount}. ${this.retries - this.retriesCount} retries left`, } else {
chunk: this.chunkCount, this.events.emit("error", { message: `No more retries for chunk ${this.chunkCount}` })
retriesLeft: this.retries - this.retriesCount
})
return
} }
this.eventBus.emit("error", {
message: `An error occured uploading chunk ${this.chunkCount}. No more retries, stopping upload`
})
} }
async nextSend() { async nextSend() {
if (this.paused || this.offline) { if (this.paused || this.offline) {
return return null
} }
await this.loadChunk() this.chunk = await this.loadChunk()
const res = await this.sendChunk() const res = await this.sendChunk()
.catch((err) => {
if (this.paused || this.offline) return
this.console.error(err) if ([200, 201, 204].includes(res.status)) {
// 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 (++this.chunkCount < this.totalChunks) { if (++this.chunkCount < this.totalChunks) {
this.nextSend() this.nextSend()
} else { } else {
res.json().then((body) => { res.json().then((body) => this.events.emit("finish", body))
this.eventBus.emit("finish", body)
})
} }
const percentProgress = Math.round((100 / this.totalChunks) * this.chunkCount) this.events.emit("progress", {
percentProgress: Math.round((100 / this.totalChunks) * this.chunkCount)
this.eventBus.emit("progress", {
percentProgress
}) })
} } else if ([408, 502, 503, 504].includes(res.status)) {
// 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
this.manageRetries() this.manageRetries()
} } else {
res.json().then((body) => this.events.emit("error", { message: `[${res.status}] ${body.error ?? body.message}` }))
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}`
})
}
} }
} }
@ -180,7 +141,7 @@ export default class ChunkedUpload {
this.paused = !this.paused this.paused = !this.paused
if (!this.paused) { if (!this.paused) {
this.nextSend() return this.nextSend()
} }
} }
} }

View File

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

View File

@ -11,7 +11,7 @@ export class DraggableDrawerController extends React.Component {
this.interface = { this.interface = {
open: this.open, open: this.open,
close: this.close, destroy: this.destroy,
actions: this.actions, actions: this.actions,
} }
@ -100,9 +100,15 @@ export class DraggableDrawerController extends React.Component {
this.setState({ drawers: drawers }) 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() { componentDidUpdate() {
if (this.state.drawers.length === 0) { if (this.state.drawers.length === 0) {
app.layout.toggleRootScaleEffect(false) app.layout.toggleRootScaleEffect(false)

View File

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

View File

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

View File

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

View File

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

View File

@ -78,32 +78,6 @@ export default class ToolsBar extends React.Component {
id="tools_bar" id="tools_bar"
className="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"> <div className="attached_renders">
{ {
this.state.renders.map((render) => { this.state.renders.map((render) => {
@ -111,6 +85,8 @@ export default class ToolsBar extends React.Component {
}) })
} }
</div> </div>
<WidgetsWrapper />
</div> </div>
</div> </div>
}} }}

View File

@ -45,67 +45,13 @@
flex: 0; 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 { .attached_renders {
position: sticky;
bottom: 0;
right: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: flex-end;
width: 100%; width: 100%;
height: 100%; height: 100%;
gap: 10px; gap: 10px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,8 +15,21 @@
} }
} }
.lyrics-background-wrapper { .lyrics-background-color {
position: absolute;
z-index: 100; 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; position: absolute;
top: 0; top: 0;
@ -25,28 +38,35 @@
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
backdrop-filter: blur(10px);
.lyrics-background-cover { .lyrics-background-cover {
position: relative;
z-index: 110;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%;
height: 100%;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 80px; width: 100%;
height: 100%;
img { img {
width: 100%; width: 40vw;
height: 100%; height: 40vw;
object-fit: contain; object-fit: contain;
border-radius: 24px;
} }
} }
} }
.lyrics-video { .lyrics-video {
z-index: 105; z-index: 120;
position: absolute; position: absolute;
top: 0; top: 0;
@ -58,10 +78,14 @@
object-fit: cover; object-fit: cover;
transition: all 150ms ease-out; transition: all 150ms ease-out;
&.hidden {
opacity: 0;
}
} }
.lyrics-text-wrapper { .lyrics-text-wrapper {
z-index: 110; z-index: 200;
position: fixed; position: fixed;
bottom: 0; bottom: 0;
@ -103,7 +127,7 @@
.lyrics-player-controller-wrapper { .lyrics-player-controller-wrapper {
position: fixed; position: fixed;
z-index: 115; z-index: 210;
bottom: 0; bottom: 0;
right: 0; right: 0;
@ -282,7 +306,7 @@
top: 20px; top: 20px;
right: 20px; right: 20px;
z-index: 115; z-index: 300;
display: flex; display: flex;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,19 @@
.main-page { .tvstudio-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; width: 100%;
gap: 10px; gap: 10px;
.main-page-actions { .tvstudio-page-actions {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
width: 100%;
gap: 10px; gap: 10px;
.profile-selector { .profile-selector {
@ -21,4 +23,15 @@
width: 100%; 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 { PagePanelWithNavMenu } from "@components/PagePanels"
import usePageWidgets from "@hooks/usePageWidgets"
import Tabs from "./tabs" import Tabs from "./tabs"
export default class Home extends React.Component { const TrendingsCard = () => {
render() { return <div className="card">
return <PagePanelWithNavMenu <div className="card-header">
tabs={Tabs} <span>Trendings</span>
extraItems={[ </div>
{
key: "create", <div className="card-content">
icon: "FiPlusCircle", <span>XD</span>
label: <Translation>{(t) => t("Create")}</Translation>, </div>
props: { </div>
type: "primary", }
onClick: app.controls.openPostCreator
} const TimelinePage = () => {
}, usePageWidgets([
]} {
onTabChange={() => { id: "trendings",
app.layout.scrollTo({ component: TrendingsCard
top: 0, }
}) ])
}}
useSetQueryType return <PagePanelWithNavMenu
transition tabs={Tabs}
masked 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 React from "react"
import * as antd from "antd" import * as antd from "antd"
// import { version as linebridgeVersion } from "linebridge/package.json"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
import LatencyIndicator from "@components/PerformanceIndicators/latency" import LatencyIndicator from "@components/PerformanceIndicators/latency"
@ -198,7 +196,7 @@ export default {
</div> </div>
<div className="field_value"> <div className="field_value">
{app.__eviteVersion ?? "Unknown"} {app.__version ?? "Unknown"}
</div> </div>
</div> </div>

View File

@ -90,6 +90,10 @@ export default {
label: "Varela Round", label: "Varela Round",
value: "'Varela Round', sans-serif" value: "'Varela Round', sans-serif"
}, },
{
label: "Manrope",
value: "'Manrope', sans-serif"
}
] ]
}, },
defaultValue: () => { 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 React from "react"
import * as antd from "antd" import * as antd from "antd"
import { Input } from "antd"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
@ -8,8 +7,14 @@ import NFCModel from "comty.js/models/nfc"
import StepsContext from "../../context" import StepsContext from "../../context"
import "./index.less"
export default (props) => { export default (props) => {
const context = React.useContext(StepsContext) 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) { if (!context.values.serial) {
app.message.error("Serial not available.") app.message.error("Serial not available.")
@ -20,17 +25,19 @@ export default (props) => {
} }
const handleOnFinish = async (values) => { const handleOnFinish = async (values) => {
console.log({ values })
context.setValue("alias", values.alias) context.setValue("alias", values.alias)
context.setValue("behavior", values.behavior) context.setValue("behavior", values.behavior)
const result = await NFCModel.registerTag(context.values.serial, { const result = await NFCModel.registerTag(context.values.serial, {
alias: values.alias, alias: values.alias,
behavior: values.behavior behavior: values.behavior,
}).catch((err) => { }).catch((err) => {
console.error(err) console.error(err)
app.message.error("Cannot register your tag. Please try again.") app.message.error("Cannot register your tag. Please try again.")
return false return false
}) })
@ -55,7 +62,10 @@ export default (props) => {
return context.next() return context.next()
} }
return <div className="tap-share-register_step"> return <div
className="tap-share-register_step"
ref={ref}
>
<h2> <h2>
Tag Data Tag Data
</h2> </h2>
@ -68,6 +78,7 @@ export default (props) => {
alias: context.values.alias, alias: context.values.alias,
behavior: context.values.behavior, behavior: context.values.behavior,
}} }}
form={form}
> >
<antd.Form.Item <antd.Form.Item
name="serial" name="serial"
@ -76,7 +87,7 @@ export default (props) => {
Serial Serial
</>} </>}
> >
<Input <antd.Input
disabled disabled
/> />
</antd.Form.Item> </antd.Form.Item>
@ -109,10 +120,11 @@ export default (props) => {
What will happen when someone taps your tag? What will happen when someone taps your tag?
</span> </span>
<div className="ant-form_with_selector"> <div
className={"ant-form_with_selector"}
>
<antd.Form.Item <antd.Form.Item
name={["behavior", "type"]} name={["behavior", "type"]}
noStyle
size="large" size="large"
rules={[ rules={[
{ {
@ -120,49 +132,59 @@ export default (props) => {
message: "Please select your tag behavior." message: "Please select your tag behavior."
} }
]} ]}
initialValue={"url"}
noStyle
> >
<antd.Select <antd.Select
placeholder="Options" placeholder="Options"
size="large" size="large"
> getPopupContainer={() => ref.current}
<antd.Select.Option options={[
value="url" {
> value: "url",
Custom URL label: <span className="flex-row gap10">
</antd.Select.Option> <Icons.FiLink />
Custom URL
<antd.Select.Option </span>
value="profile" },
> {
Profile value: "badge",
</antd.Select.Option> label: <span className="flex-row gap10">
<Icons.FiTag />
<antd.Select.Option Badge
value="random_list" </span>
> },
Random list {
</antd.Select.Option> value: "random_list",
</antd.Select> label: <span className="flex-row gap10">
</antd.Form.Item> <Icons.FiList />
Random list
<antd.Form.Item </span>
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> </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> </div>
</antd.Form.Item> </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% { 60% {
transform: translateY(-3px); 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 { .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 // 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=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=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=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 */ /* 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'); @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/animations.less";
@import "@styles/vars.less"; @import "@styles/vars.less";
@import "@styles/fonts.less"; @import "@styles/fonts.less";
@ -40,6 +41,10 @@ p {
font-size: calc(1em * var(--fontScale)); font-size: calc(1em * var(--fontScale));
} }
code {
user-select: text !important;
}
h1, h1,
h2, h2,
h3, h3,
@ -49,6 +54,12 @@ h6,
p, p,
span, span,
a { a {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 5px;
margin: 0; margin: 0;
} }
@ -147,14 +158,14 @@ html {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
&.root-scale-effect { // &.root-scale-effect {
background-color: black !important; // background-color: black !important;
#root { // #root {
animation: rootScaleOut 250ms ease-in-out forwards; // animation: rootScaleOut 250ms ease-in-out forwards;
animation-delay: 0; // animation-delay: 0;
} // }
} // }
&.centered-content { &.centered-content {
.content_layout { .content_layout {
@ -206,6 +217,7 @@ svg {
} }
*:not(input):not(textarea):not(a) { *:not(input):not(textarea):not(a) {
user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
/* disable selection/Copy of UIWebView */ /* disable selection/Copy of UIWebView */
-webkit-touch-callout: none; -webkit-touch-callout: none;
@ -368,15 +380,58 @@ svg {
gap: 10px; gap: 10px;
} }
@keyframes rootScaleOut { .key-value-field {
0% { display: flex;
transform: scale(1); 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% { .key-value-field-description {
border-radius: 24px; display: flex;
transform: perspective(1000px) scale(0.99); flex-direction: column;
box-shadow: 0 0 500px 10px var(--colorPrimary);
overflow: hidden; 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("dotenv").config()
require("sucrase/register") require("sucrase/register")
const path = require("path") const path = require("node:path")
const Module = require("module") const Module = require("node:module")
const { Buffer } = require("buffer") const { Buffer } = require("node:buffer")
const { webcrypto: crypto } = require("crypto") const { webcrypto: crypto } = require("node:crypto")
const { InfisicalClient } = require("@infisical/sdk") const { InfisicalClient } = require("@infisical/sdk")
const moduleAlias = require("module-alias") const moduleAlias = require("module-alias")
const { onExit } = require("signal-exit")
// Override file execution arg // Override file execution arg
process.argv.splice(1, 1) process.argv.splice(1, 1)
@ -135,6 +135,8 @@ async function Boot(main) {
throw new Error("main class is not defined") 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) { if (process.env.INFISICAL_CLIENT_ID && process.env.INFISICAL_CLIENT_SECRET) {
console.log(`[BOOT] INFISICAL Credentials found, injecting env variables from INFISICAL...`) console.log(`[BOOT] INFISICAL Credentials found, injecting env variables from INFISICAL...`)
await injectEnvFromInfisical() await injectEnvFromInfisical()
@ -142,6 +144,18 @@ async function Boot(main) {
const instance = new 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() await instance.initialize()
if (process.env.lb_service && process.send) { if (process.env.lb_service && process.send) {
@ -153,13 +167,15 @@ async function Boot(main) {
return instance return instance
} }
console.log(`[BOOT] Booting in [${global.isProduction ? "production" : "development"}] mode...`) try {
// Apply patches
registerPatches()
// Apply patches // Apply aliases
registerPatches() registerAliases()
// Apply aliases // execute main
registerAliases() Module.runMain()
} catch (error) {
// execute main console.error("[BOOT] ❌ Boot error: ", error)
Module.runMain() }

View File

@ -20,39 +20,57 @@ export function checkTotalSize(
} }
export function checkChunkUploadHeaders(headers) { export function checkChunkUploadHeaders(headers) {
if ( const requiredHeaders = [
!headers["uploader-chunk-number"] || "uploader-chunk-number",
!headers["uploader-chunks-total"] || "uploader-chunks-total",
!headers["uploader-original-name"] || "uploader-original-name",
!headers["uploader-file-id"] || "uploader-file-id"
!headers["uploader-chunks-total"].match(/^[0-9]+$/) || ]
!headers["uploader-chunk-number"].match(/^[0-9]+$/)
) { for (const header of requiredHeaders) {
return false 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 return true
} }
export function createAssembleChunksPromise({ export function createAssembleChunksPromise({
chunksPath, // chunks to assemble chunksPath,
filePath, // final assembled file path filePath,
maxFileSize, maxFileSize,
}) { }) {
return () => new Promise(async (resolve, reject) => { return () => new Promise(async (resolve, reject) => {
let fileSize = 0 let fileSize = 0
if (!fs.existsSync(chunksPath)) { 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) { 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) const chunkPath = path.join(chunksPath, chunk)
if (!fs.existsSync(chunkPath)) { if (!fs.existsSync(chunkPath)) {
@ -60,18 +78,13 @@ export function createAssembleChunksPromise({
} }
const data = await fs.promises.readFile(chunkPath) const data = await fs.promises.readFile(chunkPath)
fileSize += data.length 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) { if (fileSize > maxFileSize) {
return reject(new OperationError(413, "File exceeds max total file size, aborting assembly...")) return reject(new OperationError(413, "File exceeds max total file size, aborting assembly..."))
} }
await fs.promises.appendFile(filePath, data) await fs.promises.appendFile(filePath, data)
continue
} }
return resolve({ 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) => { return await new Promise(async (resolve, reject) => {
const chunksPath = path.join(tmpDir, headers["uploader-file-id"], "chunks") const chunksPath = path.join(tmpDir, headers["uploader-file-id"], "chunks")
const chunkPath = path.join(chunksPath, headers["uploader-chunk-number"]) 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 // make sure chunk is in range
if (chunkCount < 0 || chunkCount >= totalChunks) { 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 // 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, { export async function uploadChunkFile(
tmpDir, req,
maxFileSize, {
maxChunkSize, tmpDir,
}) { maxFileSize,
maxChunkSize,
}
) {
return await new Promise(async (resolve, reject) => { return await new Promise(async (resolve, reject) => {
if (!checkChunkUploadHeaders(req.headers)) { if (!checkChunkUploadHeaders(req.headers)) {
reject(new OperationError(400, "Missing header(s)")) 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) this.defaultRegion = String(options.defaultRegion)
} }
composeRemoteURL = (key) => { composeRemoteURL = (key, extraKey) => {
const _path = path.join(this.defaultBucket, key) let _path = path.join(this.defaultBucket, key)
if (typeof extraKey === "string") {
_path = path.join(_path, extraKey)
}
return `${this.protocol}//${this.host}:${this.port}/${_path}` return `${this.protocol}//${this.host}:${this.port}/${_path}`
} }

View File

@ -32,6 +32,10 @@ export default {
endpoint_url: { endpoint_url: {
type: String, type: String,
default: "https://comty.app/nfc/no_endpoint" 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", name: "Post",
collection: "posts", collection: "posts",
schema: { schema: {
user_id: { type: String, required: true }, user_id: {
created_at: { type: String, required: true }, type: String,
message: { type: String }, required: true
attachments: { type: Array, default: [] }, },
flags: { type: Array, default: [] }, created_at: {
reply_to: { type: String, default: null }, type: String,
updated_at: { type: String, default: null }, 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, type: Object,
required: true, required: true,
}, },
lyrics_enabled: {
type: Boolean,
default: false
}
} }
} }

View File

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

View File

@ -2,20 +2,72 @@ export default {
name: "User", name: "User",
collection: "accounts", collection: "accounts",
schema: { schema: {
username: { type: String, required: true }, username: {
password: { type: String, required: true, select: false }, type: String,
email: { type: String, required: true, select: false }, required: true
description: { type: String, default: null }, },
created_at: { type: String }, password: {
public_name: { type: String, default: null }, type: String,
cover: { type: String, default: null }, required: true,
avatar: { type: String, default: null }, select: false
roles: { type: Array, default: [] }, },
verified: { type: Boolean, default: false }, email: {
badges: { type: Array, default: [] }, type: String,
links: { type: Array, default: [] }, required: true,
location: { type: String, default: null }, select: false
birthday: { type: Date, default: null, select: false }, },
accept_tos: { type: Boolean, default: 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 { Observable } from "@gullerya/object-observer"
import { dots as DefaultSpinner } from "spinnies/spinners.json" import { dots as DefaultSpinner } from "spinnies/spinners.json"
import EventEmitter from "@foxify/events" 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 chokidar from "chokidar"
import { onExit } from "signal-exit" import { onExit } from "signal-exit"
import chalk from "chalk" import chalk from "chalk"
@ -76,6 +76,7 @@ export default class Gateway {
cwd, cwd,
onReload: this.serviceHandlers.onReload, onReload: this.serviceHandlers.onReload,
onClose: this.serviceHandlers.onClose, onClose: this.serviceHandlers.onClose,
onError: this.serviceHandlers.onError,
onIPCData: this.serviceHandlers.onIPCData, onIPCData: this.serviceHandlers.onIPCData,
}) })
@ -218,6 +219,7 @@ export default class Gateway {
cwd, cwd,
onReload: this.serviceHandlers.onReload, onReload: this.serviceHandlers.onReload,
onClose: this.serviceHandlers.onClose, onClose: this.serviceHandlers.onClose,
onError: this.serviceHandlers.onError,
onIPCData: this.serviceHandlers.onIPCData, onIPCData: this.serviceHandlers.onIPCData,
}) })
@ -246,11 +248,18 @@ export default class Gateway {
console.log(`[${id}] Exit with code ${code}`) console.log(`[${id}] Exit with code ${code}`)
if (err) {
console.error(err)
}
// try to unregister from proxy // try to unregister from proxy
this.proxy.unregisterAllFromService(id) this.proxy.unregisterAllFromService(id)
this.serviceRegistry[id].ready = false this.serviceRegistry[id].ready = false
}, },
onError: (id, err) => {
console.error(`[${id}] Error`, err)
},
} }
onAllServicesReload = (id) => { onAllServicesReload = (id) => {
@ -382,7 +391,7 @@ export default class Gateway {
process.stdout.setMaxListeners(50) process.stdout.setMaxListeners(50)
process.stderr.setMaxListeners(50) process.stderr.setMaxListeners(50)
this.services = await scanServices() this.services = await scanServices()
this.proxy = new Proxy() this.proxy = new Proxy()
this.ipcRouter = new IPCRouter() this.ipcRouter = new IPCRouter()

View File

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

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