mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 10:34:17 +00:00
merge from local
This commit is contained in:
parent
5ef95ac39a
commit
8c3e9a504b
@ -46,7 +46,7 @@
|
||||
"capacitor-music-controls-plugin-v3": "^1.1.0",
|
||||
"classnames": "2.3.1",
|
||||
"dompurify": "^3.0.0",
|
||||
"evite": "^0.17.0",
|
||||
"vessel": "^0.18.0",
|
||||
"fast-average-color": "^9.2.0",
|
||||
"framer-motion": "^10.12.17",
|
||||
"fuse.js": "6.5.3",
|
||||
|
@ -2,7 +2,7 @@ import "./patches"
|
||||
import config from "@config"
|
||||
|
||||
import React from "react"
|
||||
import { EviteRuntime } from "evite"
|
||||
import { Runtime } from "vessel"
|
||||
import { Helmet } from "react-helmet"
|
||||
import { Translation } from "react-i18next"
|
||||
import * as Sentry from "@sentry/browser"
|
||||
@ -109,16 +109,12 @@ class ComtyApp extends React.Component {
|
||||
},
|
||||
openLoginForm: async (options = {}) => {
|
||||
app.layout.draggable.open("login", Login, {
|
||||
defaultLocked: options.defaultLocked ?? false,
|
||||
componentProps: {
|
||||
sessionController: this.sessionController,
|
||||
},
|
||||
props: {
|
||||
fillEnd: true,
|
||||
bodyStyle: {
|
||||
height: "100%",
|
||||
}
|
||||
sessionController: this.sessionController,
|
||||
onDone: () => {
|
||||
app.layout.draggable.destroy("login")
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
openAppsMenu: () => {
|
||||
@ -464,4 +460,4 @@ class ComtyApp extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default new EviteRuntime(ComtyApp)
|
||||
export default new Runtime(ComtyApp)
|
@ -1,3 +1,5 @@
|
||||
import TrackManifest from "../TrackManifest"
|
||||
|
||||
export default class TrackInstance {
|
||||
constructor(player, manifest) {
|
||||
if (!player) {
|
||||
@ -14,6 +16,8 @@ export default class TrackInstance {
|
||||
return this
|
||||
}
|
||||
|
||||
_initialized = false
|
||||
|
||||
audio = null
|
||||
|
||||
contextElement = null
|
||||
@ -24,57 +28,6 @@ export default class TrackInstance {
|
||||
|
||||
waitUpdateTimeout = null
|
||||
|
||||
resolveManifest = async () => {
|
||||
if (typeof this.manifest === "string") {
|
||||
this.manifest = {
|
||||
src: this.manifest,
|
||||
}
|
||||
}
|
||||
|
||||
if (this.manifest.service) {
|
||||
if (!this.player.service_providers.has(manifest.service)) {
|
||||
throw new Error(`Service ${manifest.service} is not supported`)
|
||||
}
|
||||
|
||||
// try to resolve source file
|
||||
if (this.manifest.service !== "inherit" && !this.manifest.source) {
|
||||
this.manifest = await this.player.service_providers.resolve(this.manifest.service, this.manifest)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.manifest.source) {
|
||||
throw new Error("Manifest `source` is required")
|
||||
}
|
||||
|
||||
if (!this.manifest.metadata) {
|
||||
this.manifest.metadata = {}
|
||||
}
|
||||
|
||||
if (!this.manifest.metadata.title) {
|
||||
this.manifest.metadata.title = this.manifest.source.split("/").pop()
|
||||
}
|
||||
|
||||
return this.manifest
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
this.manifest = await this.resolveManifest()
|
||||
|
||||
this.audio = new Audio(this.manifest.source)
|
||||
|
||||
this.audio.signal = this.abortController.signal
|
||||
this.audio.crossOrigin = "anonymous"
|
||||
this.audio.preload = "metadata"
|
||||
|
||||
for (const [key, value] of Object.entries(this.mediaEvents)) {
|
||||
this.audio.addEventListener(key, value)
|
||||
}
|
||||
|
||||
this.contextElement = this.player.audioContext.createMediaElementSource(this.audio)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
mediaEvents = {
|
||||
"ended": () => {
|
||||
this.player.next()
|
||||
@ -124,4 +77,63 @@ export default class TrackInstance {
|
||||
this.player.eventBus.emit(`player.seeked`, this.audio.currentTime)
|
||||
},
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
this.manifest = await this.resolveManifest()
|
||||
|
||||
this.audio = new Audio(this.manifest.source)
|
||||
|
||||
this.audio.signal = this.abortController.signal
|
||||
this.audio.crossOrigin = "anonymous"
|
||||
this.audio.preload = "metadata"
|
||||
|
||||
for (const [key, value] of Object.entries(this.mediaEvents)) {
|
||||
this.audio.addEventListener(key, value)
|
||||
}
|
||||
|
||||
this.contextElement = this.player.audioContext.createMediaElementSource(this.audio)
|
||||
|
||||
this._initialized = true
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
resolveManifest = async () => {
|
||||
if (typeof this.manifest === "string") {
|
||||
this.manifest = {
|
||||
src: this.manifest,
|
||||
}
|
||||
}
|
||||
|
||||
this.manifest = new TrackManifest(this.manifest)
|
||||
|
||||
this.manifest = await this.manifest.analyzeCoverColor()
|
||||
|
||||
if (this.manifest.service) {
|
||||
if (!this.player.service_providers.has(manifest.service)) {
|
||||
throw new Error(`Service ${manifest.service} is not supported`)
|
||||
}
|
||||
|
||||
// try to resolve source file
|
||||
if (this.manifest.service !== "inherit" && !this.manifest.source) {
|
||||
this.manifest = await this.player.service_providers.resolve(this.manifest.service, this.manifest)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.manifest.source) {
|
||||
throw new Error("Manifest `source` is required")
|
||||
}
|
||||
|
||||
// set empty metadata if not provided
|
||||
if (!this.manifest.metadata) {
|
||||
this.manifest.metadata = {}
|
||||
}
|
||||
|
||||
// auto name if a title is not provided
|
||||
if (!this.manifest.metadata.title) {
|
||||
this.manifest.metadata.title = this.manifest.source.split("/").pop()
|
||||
}
|
||||
|
||||
return this.manifest
|
||||
}
|
||||
}
|
150
packages/app/src/classes/TrackManifest/index.js
Normal file
150
packages/app/src/classes/TrackManifest/index.js
Normal 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
|
||||
}
|
||||
}
|
@ -7,7 +7,7 @@ import RGBStringToValues from "@utils/rgbToValues"
|
||||
import ImageViewer from "@components/ImageViewer"
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
import { Context as PlayerContext } from "@contexts/WithPlayerContext"
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
|
||||
|
||||
import "./index.less"
|
||||
@ -28,7 +28,7 @@ const Track = (props) => {
|
||||
loading,
|
||||
track_manifest,
|
||||
playback_status,
|
||||
} = React.useContext(PlayerContext)
|
||||
} = usePlayerStateContext()
|
||||
|
||||
const playlist_ctx = React.useContext(PlaylistContext)
|
||||
|
||||
@ -186,12 +186,16 @@ const Track = (props) => {
|
||||
{
|
||||
props.track.service === "tidal" && <Icons.SiTidal />
|
||||
}
|
||||
{props.track.title}
|
||||
{
|
||||
props.track.title
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="music-track_artist">
|
||||
<span>
|
||||
{props.track.artist}
|
||||
{
|
||||
Array.isArray(props.track.artists) ? props.track.artists.join(", ") : props.track.artist
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -99,7 +99,7 @@ html {
|
||||
|
||||
align-items: center;
|
||||
|
||||
padding: 10px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.music-track_actions {
|
||||
@ -182,11 +182,11 @@ html {
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
|
||||
min-width: 50px;
|
||||
min-height: 50px;
|
||||
min-width: 35px;
|
||||
min-height: 35px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
|
@ -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
|
@ -0,0 +1,11 @@
|
||||
.lyrics-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 20px;
|
||||
padding: 15px;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
}
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
@ -0,0 +1,6 @@
|
||||
.enhanced_lyrics_editor-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 20px;
|
||||
}
|
@ -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
|
@ -2,8 +2,10 @@ import React from "react"
|
||||
import * as antd from "antd"
|
||||
import axios from "axios"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const LyricsTextView = (props) => {
|
||||
const { lang, track } = props
|
||||
const { lrcURL } = props
|
||||
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState(null)
|
||||
@ -26,17 +28,17 @@ const LyricsTextView = (props) => {
|
||||
})
|
||||
|
||||
if (data) {
|
||||
setLyrics(data.data)
|
||||
setLyrics(data.data.split("\n"))
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
getLyrics(lang.value)
|
||||
}, [lang])
|
||||
getLyrics(lrcURL)
|
||||
}, [lrcURL])
|
||||
|
||||
if (!lang) {
|
||||
if (!lrcURL) {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -52,8 +54,21 @@ const LyricsTextView = (props) => {
|
||||
return <antd.Skeleton active />
|
||||
}
|
||||
|
||||
return <div>
|
||||
<p>{lyrics}</p>
|
||||
if (!lyrics) {
|
||||
return <p>No lyrics provided</p>
|
||||
}
|
||||
|
||||
return <div className="lyrics-text-view">
|
||||
{
|
||||
lyrics?.map((line, index) => {
|
||||
return <div
|
||||
key={index}
|
||||
className="lyrics-text-view-line"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,31 +1,39 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
import { Icons, createIconRender } from "@components/Icons"
|
||||
|
||||
import MusicModel from "@models/music"
|
||||
|
||||
import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey"
|
||||
|
||||
import TrackManifest from "@classes/TrackManifest"
|
||||
|
||||
import { DefaultReleaseEditorState, ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
||||
|
||||
import Tabs from "./tabs"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
console.log(MusicModel.deleteRelease)
|
||||
|
||||
const ReleaseEditor = (props) => {
|
||||
const { release_id } = props
|
||||
|
||||
const basicInfoRef = React.useRef()
|
||||
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [submitError, setSubmitError] = React.useState(null)
|
||||
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [loadError, setLoadError] = React.useState(null)
|
||||
const [globalState, setGlobalState] = React.useState(DefaultReleaseEditorState)
|
||||
const [selectedTab, setSelectedTab] = React.useState("info")
|
||||
|
||||
const [customPage, setCustomPage] = React.useState(null)
|
||||
const [customPageActions, setCustomPageActions] = React.useState([])
|
||||
|
||||
const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
|
||||
defaultKey: "info",
|
||||
queryKey: "tab"
|
||||
})
|
||||
|
||||
async function initialize() {
|
||||
setLoading(true)
|
||||
@ -33,7 +41,13 @@ const ReleaseEditor = (props) => {
|
||||
|
||||
if (release_id !== "new") {
|
||||
try {
|
||||
const releaseData = await MusicModel.getReleaseData(release_id)
|
||||
let releaseData = await MusicModel.getReleaseData(release_id)
|
||||
|
||||
if (Array.isArray(releaseData.list)) {
|
||||
releaseData.list = releaseData.list.map((item) => {
|
||||
return new TrackManifest(item)
|
||||
})
|
||||
}
|
||||
|
||||
setGlobalState({
|
||||
...globalState,
|
||||
@ -47,21 +61,23 @@ const ReleaseEditor = (props) => {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function renderCustomPage(page, actions) {
|
||||
setCustomPage(page ?? null)
|
||||
setCustomPageActions(actions ?? [])
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true)
|
||||
setSubmitError(null)
|
||||
|
||||
try {
|
||||
// first sumbit tracks
|
||||
console.time("submit:tracks:")
|
||||
const tracks = await MusicModel.putTrack({
|
||||
list: globalState.list,
|
||||
})
|
||||
console.timeEnd("submit:tracks:")
|
||||
|
||||
// then submit release
|
||||
console.time("submit:release:")
|
||||
await MusicModel.putRelease({
|
||||
const result = await MusicModel.putRelease({
|
||||
_id: globalState._id,
|
||||
title: globalState.title,
|
||||
description: globalState.description,
|
||||
@ -71,7 +87,8 @@ const ReleaseEditor = (props) => {
|
||||
type: globalState.type,
|
||||
list: tracks.list,
|
||||
})
|
||||
console.timeEnd("submit:release:")
|
||||
|
||||
app.location.push(`/studio/music/${result._id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
app.message.error(error.message)
|
||||
@ -84,8 +101,6 @@ const ReleaseEditor = (props) => {
|
||||
|
||||
setSubmitting(false)
|
||||
app.message.success("Release saved")
|
||||
|
||||
return release
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
@ -99,10 +114,6 @@ const ReleaseEditor = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
async function onFinish(values) {
|
||||
console.log(values)
|
||||
}
|
||||
|
||||
async function canFinish() {
|
||||
return true
|
||||
}
|
||||
@ -125,10 +136,18 @@ const ReleaseEditor = (props) => {
|
||||
|
||||
const Tab = Tabs.find(({ key }) => key === selectedTab)
|
||||
|
||||
const CustomPageProps = {
|
||||
close: () => {
|
||||
renderCustomPage(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
return <ReleaseEditorStateContext.Provider
|
||||
value={{
|
||||
...globalState,
|
||||
setCustomPage,
|
||||
setGlobalState,
|
||||
renderCustomPage,
|
||||
setCustomPageActions,
|
||||
}}
|
||||
>
|
||||
<div className="music-studio-release-editor">
|
||||
@ -139,29 +158,47 @@ const ReleaseEditor = (props) => {
|
||||
<div className="music-studio-release-editor-custom-page-header-title">
|
||||
<antd.Button
|
||||
icon={<Icons.IoIosArrowBack />}
|
||||
onClick={() => setCustomPage(null)}
|
||||
onClick={() => renderCustomPage(null, null)}
|
||||
/>
|
||||
|
||||
<h2>{customPage.header}</h2>
|
||||
</div>
|
||||
|
||||
{
|
||||
customPage.props?.onSave && <antd.Button
|
||||
type="primary"
|
||||
icon={<Icons.FiSave />}
|
||||
onClick={() => customPage.props.onSave()}
|
||||
Array.isArray(customPageActions) && customPageActions.map((action, index) => {
|
||||
return <antd.Button
|
||||
key={index}
|
||||
type={action.type}
|
||||
icon={createIconRender(action.icon)}
|
||||
onClick={async () => {
|
||||
if (typeof action.onClick === "function") {
|
||||
await action.onClick()
|
||||
}
|
||||
|
||||
if (action.fireEvent) {
|
||||
app.eventBus.emit(action.fireEvent)
|
||||
}
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
Save
|
||||
{action.label}
|
||||
</antd.Button>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
customPage.content && (React.isValidElement(customPage.content) ?
|
||||
React.cloneElement(customPage.content, {
|
||||
...customPage.props,
|
||||
close: () => setCustomPage(null),
|
||||
...CustomPageProps,
|
||||
...customPage.props
|
||||
}) :
|
||||
React.createElement(customPage.content, {
|
||||
...CustomPageProps,
|
||||
...customPage.props
|
||||
})
|
||||
)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@ -179,11 +216,11 @@ const ReleaseEditor = (props) => {
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
icon={<Icons.FiSave />}
|
||||
icon={release_id !== "new" ? <Icons.FiSave /> : <Icons.MdSend />}
|
||||
disabled={submitting || loading || !canFinish()}
|
||||
loading={submitting}
|
||||
>
|
||||
Save
|
||||
{release_id !== "new" ? "Save" : "Release"}
|
||||
</antd.Button>
|
||||
|
||||
{
|
||||
@ -208,6 +245,12 @@ const ReleaseEditor = (props) => {
|
||||
</div>
|
||||
|
||||
<div className="music-studio-release-editor-content">
|
||||
{
|
||||
submitError && <antd.Alert
|
||||
message={submitError.message}
|
||||
type="error"
|
||||
/>
|
||||
}
|
||||
{
|
||||
!Tab && <antd.Result
|
||||
status="error"
|
||||
@ -218,7 +261,6 @@ const ReleaseEditor = (props) => {
|
||||
{
|
||||
Tab && React.createElement(Tab.render, {
|
||||
release: globalState,
|
||||
onFinish: onFinish,
|
||||
|
||||
state: globalState,
|
||||
setState: setGlobalState,
|
||||
|
@ -20,17 +20,19 @@ const TrackListItem = (props) => {
|
||||
const { track } = props
|
||||
|
||||
async function onClickEditTrack() {
|
||||
context.setCustomPage({
|
||||
context.renderCustomPage({
|
||||
header: "Track Editor",
|
||||
content: <TrackEditor track={track} />,
|
||||
content: <TrackEditor />,
|
||||
props: {
|
||||
onSave: (newTrackData) => {
|
||||
console.log("Saving track", newTrackData)
|
||||
},
|
||||
track: track,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function onClickRemoveTrack() {
|
||||
props.onDelete(track.uid)
|
||||
}
|
||||
|
||||
return <Draggable
|
||||
key={track._id}
|
||||
draggableId={track._id}
|
||||
@ -43,12 +45,20 @@ const TrackListItem = (props) => {
|
||||
"music-studio-release-editor-tracks-list-item",
|
||||
{
|
||||
["loading"]: loading,
|
||||
["failed"]: !!error
|
||||
["failed"]: !!error,
|
||||
["disabled"]: props.disabled,
|
||||
}
|
||||
)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div
|
||||
className="music-studio-release-editor-tracks-list-item-progress"
|
||||
style={{
|
||||
"--upload-progress": `${props.progress}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="music-studio-release-editor-tracks-list-item-index">
|
||||
<span>{props.index + 1}</span>
|
||||
</div>
|
||||
@ -65,10 +75,23 @@ const TrackListItem = (props) => {
|
||||
<span>{track.title}</span>
|
||||
|
||||
<div className="music-studio-release-editor-tracks-list-item-actions">
|
||||
<antd.Popconfirm
|
||||
title="Are you sure you want to delete this track?"
|
||||
onConfirm={onClickRemoveTrack}
|
||||
okText="Yes"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<antd.Button
|
||||
type="ghost"
|
||||
icon={<Icons.FiTrash2 />}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</antd.Popconfirm>
|
||||
<antd.Button
|
||||
type="ghost"
|
||||
icon={<Icons.FiEdit2 />}
|
||||
onClick={onClickEditTrack}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
@ -14,6 +14,21 @@
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
.music-studio-release-editor-tracks-list-item-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
width: var(--upload-progress);
|
||||
height: 2px;
|
||||
|
||||
background-color: var(--colorPrimary);
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
|
||||
.music-studio-release-editor-tracks-list-item-actions {
|
||||
position: absolute;
|
||||
|
||||
|
@ -2,7 +2,8 @@ import React from "react"
|
||||
import * as antd from "antd"
|
||||
import classnames from "classnames"
|
||||
import { DragDropContext, Droppable } from "react-beautiful-dnd"
|
||||
import jsmediatags from "jsmediatags/dist/jsmediatags.min.js"
|
||||
|
||||
import TrackManifest from "@classes/TrackManifest"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
@ -11,124 +12,14 @@ import UploadHint from "./components/UploadHint"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
async function uploadBinaryArrayToStorage(bin, args) {
|
||||
const { format, data } = bin
|
||||
|
||||
const filenameExt = format.split("/")[1]
|
||||
const filename = `cover.${filenameExt}`
|
||||
|
||||
const byteArray = new Uint8Array(data)
|
||||
const blob = new Blob([byteArray], { type: data.type })
|
||||
|
||||
// create a file object
|
||||
const file = new File([blob], filename, {
|
||||
type: format,
|
||||
})
|
||||
|
||||
return await app.cores.remoteStorage.uploadFile(file, args)
|
||||
}
|
||||
|
||||
class TrackManifest {
|
||||
constructor(params) {
|
||||
this.params = params
|
||||
|
||||
if (params.uid) {
|
||||
this.uid = params.uid
|
||||
}
|
||||
|
||||
if (params.cover) {
|
||||
this.cover = params.cover
|
||||
}
|
||||
|
||||
if (params.title) {
|
||||
this.title = params.title
|
||||
}
|
||||
|
||||
if (params.album) {
|
||||
this.album = params.album
|
||||
}
|
||||
|
||||
if (params.artist) {
|
||||
this.artist = params.artist
|
||||
}
|
||||
|
||||
if (params.source) {
|
||||
this.source = params.source
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
uid = null
|
||||
|
||||
cover = "https://storage.ragestudio.net/comty-static-assets/default_song.png"
|
||||
|
||||
title = "Untitled"
|
||||
|
||||
album = "Unknown"
|
||||
|
||||
artist = "Unknown"
|
||||
|
||||
source = null
|
||||
|
||||
async initialize() {
|
||||
const metadata = await this.analyzeMetadata(this.params.file.originFileObj)
|
||||
|
||||
console.log(metadata)
|
||||
|
||||
if (metadata.tags) {
|
||||
if (metadata.tags.title) {
|
||||
this.title = metadata.tags.title
|
||||
}
|
||||
|
||||
if (metadata.tags.artist) {
|
||||
this.artist = metadata.tags.artist
|
||||
}
|
||||
|
||||
if (metadata.tags.album) {
|
||||
this.album = metadata.tags.album
|
||||
}
|
||||
|
||||
if (metadata.tags.picture) {
|
||||
const coverUpload = await uploadBinaryArrayToStorage(metadata.tags.picture)
|
||||
|
||||
this.cover = coverUpload.url
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
analyzeMetadata = async (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
jsmediatags.read(file, {
|
||||
onSuccess: (data) => {
|
||||
return resolve(data)
|
||||
},
|
||||
onError: (error) => {
|
||||
return reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class TracksManager extends React.Component {
|
||||
state = {
|
||||
list: [],
|
||||
list: Array.isArray(this.props.list) ? this.props.list : [],
|
||||
pendingUploads: [],
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (typeof this.props.list !== "undefined" && Array.isArray(this.props.list)) {
|
||||
this.setState({
|
||||
list: this.props.list
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate = (prevProps, prevState) => {
|
||||
if (prevState.list !== this.state.list || prevState.pendingUploads !== this.state.pendingUploads) {
|
||||
if (prevState.list !== this.state.list) {
|
||||
if (typeof this.props.onChangeState === "function") {
|
||||
this.props.onChangeState(this.state)
|
||||
}
|
||||
@ -158,12 +49,16 @@ class TracksManager extends React.Component {
|
||||
return false
|
||||
}
|
||||
|
||||
this.removeTrackUIDFromPendingUploads(uid)
|
||||
|
||||
this.setState({
|
||||
list: this.state.list.filter((item) => item.uid !== uid),
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
modifyTrackByUid = (uid, track) => {
|
||||
console.log("modifyTrackByUid", uid, track)
|
||||
if (!uid || !track) {
|
||||
return false
|
||||
}
|
||||
@ -187,9 +82,17 @@ class TracksManager extends React.Component {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this.state.pendingUploads.includes(uid)) {
|
||||
const pendingUpload = this.state.pendingUploads.find((item) => item.uid === uid)
|
||||
|
||||
if (!pendingUpload) {
|
||||
this.setState({
|
||||
pendingUploads: [...this.state.pendingUploads, uid],
|
||||
pendingUploads: [
|
||||
...this.state.pendingUploads,
|
||||
{
|
||||
uid: uid,
|
||||
progress: 0
|
||||
}
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -200,13 +103,43 @@ class TracksManager extends React.Component {
|
||||
}
|
||||
|
||||
this.setState({
|
||||
pendingUploads: this.state.pendingUploads.filter((item) => item !== uid),
|
||||
pendingUploads: this.state.pendingUploads.filter((item) => item.uid !== uid),
|
||||
})
|
||||
}
|
||||
|
||||
getUploadProgress = (uid) => {
|
||||
const uploadProgressIndex = this.state.pendingUploads.findIndex((item) => item.uid === uid)
|
||||
|
||||
if (uploadProgressIndex === -1) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return this.state.pendingUploads[uploadProgressIndex].progress
|
||||
}
|
||||
|
||||
updateUploadProgress = (uid, progress) => {
|
||||
const uploadProgressIndex = this.state.pendingUploads.findIndex((item) => item.uid === uid)
|
||||
|
||||
if (uploadProgressIndex === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const newData = [...this.state.pendingUploads]
|
||||
|
||||
newData[uploadProgressIndex].progress = progress
|
||||
|
||||
console.log(`Updating progress for [${uid}] to [${progress}]`)
|
||||
|
||||
this.setState({
|
||||
pendingUploads: newData,
|
||||
})
|
||||
}
|
||||
|
||||
handleUploaderStateChange = async (change) => {
|
||||
const uid = change.file.uid
|
||||
|
||||
console.log("handleUploaderStateChange", change)
|
||||
|
||||
switch (change.file.status) {
|
||||
case "uploading": {
|
||||
this.addTrackUIDToPendingUploads(uid)
|
||||
@ -214,23 +147,20 @@ class TracksManager extends React.Component {
|
||||
const trackManifest = new TrackManifest({
|
||||
uid: uid,
|
||||
file: change.file,
|
||||
onChange: this.modifyTrackByUid
|
||||
})
|
||||
|
||||
this.addTrackToList(trackManifest)
|
||||
|
||||
const trackData = await trackManifest.initialize()
|
||||
|
||||
this.modifyTrackByUid(uid, trackData)
|
||||
|
||||
break
|
||||
}
|
||||
case "done": {
|
||||
// remove pending file
|
||||
this.removeTrackUIDFromPendingUploads(uid)
|
||||
|
||||
const trackIndex = this.state.list.findIndex((item) => item.uid === uid)
|
||||
const trackManifest = this.state.list.find((item) => item.uid === uid)
|
||||
|
||||
if (trackIndex === -1) {
|
||||
if (!trackManifest) {
|
||||
console.error(`Track with uid [${uid}] not found!`)
|
||||
break
|
||||
}
|
||||
@ -240,6 +170,8 @@ class TracksManager extends React.Component {
|
||||
source: change.file.response.url
|
||||
})
|
||||
|
||||
await trackManifest.initialize()
|
||||
|
||||
break
|
||||
}
|
||||
case "error": {
|
||||
@ -278,7 +210,7 @@ class TracksManager extends React.Component {
|
||||
}
|
||||
|
||||
handleTrackFileUploadProgress = async (file, progress) => {
|
||||
console.log(file, progress)
|
||||
this.updateUploadProgress(file.uid, progress)
|
||||
}
|
||||
|
||||
orderTrackList = (result) => {
|
||||
@ -301,6 +233,7 @@ class TracksManager extends React.Component {
|
||||
|
||||
render() {
|
||||
console.log(`Tracks List >`, this.state.list)
|
||||
|
||||
return <div className="music-studio-release-editor-tracks">
|
||||
<antd.Upload
|
||||
className="music-studio-tracks-uploader"
|
||||
@ -341,9 +274,15 @@ class TracksManager extends React.Component {
|
||||
}
|
||||
{
|
||||
this.state.list.map((track, index) => {
|
||||
const progress = this.getUploadProgress(track.uid)
|
||||
|
||||
return <TrackListItem
|
||||
index={index}
|
||||
track={track}
|
||||
onEdit={this.modifyTrackByUid}
|
||||
onDelete={this.removeTrackByUid}
|
||||
progress={progress}
|
||||
disabled={progress > 0}
|
||||
/>
|
||||
})
|
||||
}
|
||||
|
@ -3,9 +3,7 @@ import * as antd from "antd"
|
||||
|
||||
import CoverEditor from "@components/CoverEditor"
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
import LyricsEditor from "@components/MusicStudio/LyricsEditor"
|
||||
import VideoEditor from "@components/MusicStudio/VideoEditor"
|
||||
import EnhancedLyricsEditor from "@components/MusicStudio/EnhancedLyricsEditor"
|
||||
|
||||
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
||||
|
||||
@ -24,43 +22,48 @@ const TrackEditor = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
async function openLyricsEditor() {
|
||||
context.setCustomPage({
|
||||
header: "Lyrics Editor",
|
||||
content: <LyricsEditor track={track} />,
|
||||
async function openEnhancedLyricsEditor() {
|
||||
context.renderCustomPage({
|
||||
header: "Enhanced Lyrics",
|
||||
content: EnhancedLyricsEditor,
|
||||
props: {
|
||||
onSave: () => {
|
||||
console.log("Saved lyrics")
|
||||
},
|
||||
track: track,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function openVideoEditor() {
|
||||
context.setCustomPage({
|
||||
header: "Video Editor",
|
||||
content: <VideoEditor track={track} />,
|
||||
props: {
|
||||
onSave: () => {
|
||||
console.log("Saved video")
|
||||
},
|
||||
async function handleOnSave() {
|
||||
setTrack((prev) => {
|
||||
const listData = [...context.list]
|
||||
|
||||
const trackIndex = listData.findIndex((item) => item.uid === prev.uid)
|
||||
|
||||
if (trackIndex === -1) {
|
||||
return prev
|
||||
}
|
||||
|
||||
listData[trackIndex] = prev
|
||||
|
||||
context.setGlobalState({
|
||||
...context,
|
||||
list: listData
|
||||
})
|
||||
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
async function onClose() {
|
||||
if (typeof props.close === "function") {
|
||||
props.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
await props.onSave(track)
|
||||
|
||||
if (typeof props.close === "function") {
|
||||
props.close()
|
||||
}
|
||||
}
|
||||
React.useEffect(() => {
|
||||
context.setCustomPageActions([
|
||||
{
|
||||
label: "Save",
|
||||
icon: "FiSave",
|
||||
type: "primary",
|
||||
onClick: handleOnSave,
|
||||
disabled: props.track === track,
|
||||
},
|
||||
])
|
||||
}, [track])
|
||||
|
||||
return <div className="track-editor">
|
||||
<div className="track-editor-field">
|
||||
@ -131,49 +134,32 @@ const TrackEditor = (props) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<antd.Divider
|
||||
style={{
|
||||
margin: "5px 0",
|
||||
}}
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdLyrics />
|
||||
<span>Enhanced Lyrics</span>
|
||||
|
||||
<antd.Switch
|
||||
checked={track.lyrics_enabled}
|
||||
onChange={(value) => handleChange("lyrics_enabled", value)}
|
||||
disabled={!track.params._id}
|
||||
/>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.TbMovie />
|
||||
<span>Edit Video</span>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field-actions">
|
||||
<antd.Button
|
||||
onClick={openVideoEditor}
|
||||
disabled={!track.params._id}
|
||||
onClick={openEnhancedLyricsEditor}
|
||||
>
|
||||
Edit
|
||||
</antd.Button>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdTextFormat />
|
||||
<span>Edit Lyrics</span>
|
||||
{
|
||||
!track.params._id && <span>
|
||||
You cannot edit Video and Lyrics without release first
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<antd.Button
|
||||
onClick={openLyricsEditor}
|
||||
>
|
||||
Edit
|
||||
</antd.Button>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdTimeline />
|
||||
<span>Timestamps</span>
|
||||
</div>
|
||||
|
||||
<antd.Button
|
||||
disabled
|
||||
>
|
||||
Edit
|
||||
</antd.Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
@ -35,6 +35,8 @@
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
gap: 7px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
h3 {
|
||||
@ -46,8 +48,8 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
|
@ -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
|
@ -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>
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -9,7 +9,7 @@ import LikeButton from "@components/LikeButton"
|
||||
import AudioVolume from "@components/Player/AudioVolume"
|
||||
import AudioPlayerChangeModeButton from "@components/Player/ChangeModeButton"
|
||||
|
||||
import { Context } from "@contexts/WithPlayerContext"
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
@ -17,9 +17,6 @@ const EventsHandlers = {
|
||||
"playback": () => {
|
||||
return app.cores.player.playback.toggle()
|
||||
},
|
||||
"like": async (ctx) => {
|
||||
await app.cores.player.toggleCurrentTrackLike(!ctx.track_manifest?.liked)
|
||||
},
|
||||
"previous": () => {
|
||||
return app.cores.player.playback.previous()
|
||||
},
|
||||
@ -27,23 +24,25 @@ const EventsHandlers = {
|
||||
return app.cores.player.playback.next()
|
||||
},
|
||||
"volume": (ctx, value) => {
|
||||
return app.cores.player.volume(value)
|
||||
return app.cores.player.controls.volume(value)
|
||||
},
|
||||
"mute": () => {
|
||||
return app.cores.player.toggleMute()
|
||||
}
|
||||
return app.cores.player.controls.mute("toggle")
|
||||
},
|
||||
"like": async (ctx) => {
|
||||
await app.cores.player.toggleCurrentTrackLike(!ctx.track_manifest?.liked)
|
||||
},
|
||||
}
|
||||
|
||||
const Controls = (props) => {
|
||||
try {
|
||||
const ctx = React.useContext(Context)
|
||||
const playerState = usePlayerStateContext()
|
||||
|
||||
const handleAction = (event, ...args) => {
|
||||
if (typeof EventsHandlers[event] !== "function") {
|
||||
throw new Error(`Unknown event "${event}"`)
|
||||
}
|
||||
|
||||
return EventsHandlers[event](ctx, ...args)
|
||||
return EventsHandlers[event](playerState, ...args)
|
||||
}
|
||||
|
||||
return <div
|
||||
@ -52,25 +51,25 @@ const Controls = (props) => {
|
||||
}
|
||||
>
|
||||
<AudioPlayerChangeModeButton
|
||||
disabled={ctx.control_locked}
|
||||
disabled={playerState.control_locked}
|
||||
/>
|
||||
<antd.Button
|
||||
type="ghost"
|
||||
shape="round"
|
||||
icon={<Icons.FiChevronLeft />}
|
||||
onClick={() => handleAction("previous")}
|
||||
disabled={ctx.control_locked}
|
||||
disabled={playerState.control_locked}
|
||||
/>
|
||||
<antd.Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={ctx.livestream_mode ? <Icons.MdStop /> : ctx.playback_status === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
|
||||
icon={playerState.livestream_mode ? <Icons.MdStop /> : playerState.playback_status === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
|
||||
onClick={() => handleAction("playback")}
|
||||
className="playButton"
|
||||
disabled={ctx.control_locked}
|
||||
disabled={playerState.control_locked}
|
||||
>
|
||||
{
|
||||
ctx.loading && <div className="loadCircle">
|
||||
playerState.loading && <div className="loadCircle">
|
||||
<UseAnimations
|
||||
animation={LoadingAnimation}
|
||||
size="100%"
|
||||
@ -83,12 +82,12 @@ const Controls = (props) => {
|
||||
shape="round"
|
||||
icon={<Icons.FiChevronRight />}
|
||||
onClick={() => handleAction("next")}
|
||||
disabled={ctx.control_locked}
|
||||
disabled={playerState.control_locked}
|
||||
/>
|
||||
{
|
||||
app.isMobile && <LikeButton
|
||||
onClick={() => handleAction("like")}
|
||||
liked={ctx.track_manifest?.liked}
|
||||
liked={playerState.track_manifest?.liked}
|
||||
/>
|
||||
}
|
||||
{
|
||||
@ -97,7 +96,7 @@ const Controls = (props) => {
|
||||
AudioVolume,
|
||||
{
|
||||
onChange: (value) => handleAction("volume", value),
|
||||
defaultValue: ctx.volume
|
||||
defaultValue: playerState.volume
|
||||
}
|
||||
)}
|
||||
trigger="hover"
|
||||
@ -107,7 +106,7 @@ const Controls = (props) => {
|
||||
onClick={() => handleAction("mute")}
|
||||
>
|
||||
{
|
||||
ctx.muted
|
||||
playerState.muted
|
||||
? <Icons.FiVolumeX />
|
||||
: <Icons.FiVolume2 />
|
||||
}
|
||||
@ -115,10 +114,6 @@ const Controls = (props) => {
|
||||
</antd.Popover>
|
||||
}
|
||||
</div>
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default Controls
|
@ -4,13 +4,13 @@ import { Button } from "antd"
|
||||
import { Icons } from "@components/Icons"
|
||||
import LikeButton from "@components/LikeButton"
|
||||
|
||||
import { Context } from "@contexts/WithPlayerContext"
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
|
||||
const ExtraActions = (props) => {
|
||||
const ctx = React.useContext(Context)
|
||||
const playerState = usePlayerStateContext()
|
||||
|
||||
const handleClickLike = async () => {
|
||||
await app.cores.player.toggleCurrentTrackLike(!ctx.track_manifest?.liked)
|
||||
await app.cores.player.toggleCurrentTrackLike(!playerState.track_manifest?.liked)
|
||||
}
|
||||
|
||||
return <div className="extra_actions">
|
||||
@ -18,12 +18,12 @@ const ExtraActions = (props) => {
|
||||
app.isMobile && <Button
|
||||
type="ghost"
|
||||
icon={<Icons.MdAbc />}
|
||||
disabled={!ctx.track_manifest?.lyricsEnabled}
|
||||
disabled={!playerState.track_manifest?.lyrics_enabled}
|
||||
/>
|
||||
}
|
||||
{
|
||||
!app.isMobile && <LikeButton
|
||||
liked={ctx.track_manifest?.liked ?? false}
|
||||
liked={playerState.track_manifest?.liked ?? false}
|
||||
onClick={handleClickLike}
|
||||
/>
|
||||
}
|
||||
|
@ -19,20 +19,20 @@ export default class SeekBar extends React.Component {
|
||||
handleSeek = (value) => {
|
||||
if (value > 0) {
|
||||
// calculate the duration of the audio
|
||||
const duration = app.cores.player.duration()
|
||||
const duration = app.cores.player.controls.duration()
|
||||
|
||||
// calculate the seek of the audio
|
||||
const seek = (value / 100) * duration
|
||||
|
||||
app.cores.player.seek(seek)
|
||||
app.cores.player.controls.seek(seek)
|
||||
} else {
|
||||
app.cores.player.seek(0)
|
||||
app.cores.player.controls.seek(0)
|
||||
}
|
||||
}
|
||||
|
||||
calculateDuration = (preCalculatedDuration) => {
|
||||
// get current audio duration
|
||||
const audioDuration = preCalculatedDuration ?? app.cores.player.duration()
|
||||
const audioDuration = preCalculatedDuration ?? app.cores.player.controls.duration()
|
||||
|
||||
if (isNaN(audioDuration)) {
|
||||
return
|
||||
@ -46,7 +46,7 @@ export default class SeekBar extends React.Component {
|
||||
|
||||
calculateTime = () => {
|
||||
// get current audio seek
|
||||
const seek = app.cores.player.seek()
|
||||
const seek = app.cores.player.controls.seek()
|
||||
|
||||
// set time
|
||||
this.setState({
|
||||
@ -59,8 +59,8 @@ export default class SeekBar extends React.Component {
|
||||
return
|
||||
}
|
||||
|
||||
const seek = app.cores.player.seek()
|
||||
const duration = app.cores.player.duration()
|
||||
const seek = app.cores.player.controls.seek()
|
||||
const duration = app.cores.player.controls.duration()
|
||||
|
||||
const percent = (seek / duration) * 100
|
||||
|
||||
@ -74,8 +74,6 @@ export default class SeekBar extends React.Component {
|
||||
this.updateProgressBar()
|
||||
}
|
||||
|
||||
eventBus = app.cores.player.eventBus
|
||||
|
||||
events = {
|
||||
// handle when player changes playback status
|
||||
"player.state.update:playback_status": (status) => {
|
||||
@ -140,13 +138,13 @@ export default class SeekBar extends React.Component {
|
||||
this.tick()
|
||||
|
||||
for (const [event, callback] of Object.entries(this.events)) {
|
||||
this.eventBus.on(event, callback)
|
||||
app.cores.player.eventBus().on(event, callback)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
for (const [event, callback] of Object.entries(this.events)) {
|
||||
this.eventBus.off(event, callback)
|
||||
app.cores.player.eventBus().off(event, callback)
|
||||
}
|
||||
}
|
||||
|
||||
@ -199,7 +197,7 @@ export default class SeekBar extends React.Component {
|
||||
}}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => {
|
||||
return seekToTimeLabel((value / 100) * app.cores.player.duration())
|
||||
return seekToTimeLabel((value / 100) * app.cores.player.controls.duration())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -4,7 +4,7 @@ import Marquee from "react-fast-marquee"
|
||||
import classnames from "classnames"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
import SeekBar from "@components/Player/SeekBar"
|
||||
import Controls from "@components/Player/Controls"
|
||||
|
||||
@ -43,7 +43,7 @@ const ServiceIndicator = (props) => {
|
||||
}
|
||||
|
||||
const Player = (props) => {
|
||||
const ctx = React.useContext(Context)
|
||||
const playerState = usePlayerStateContext()
|
||||
|
||||
const contentRef = React.useRef()
|
||||
const titleRef = React.useRef()
|
||||
@ -64,16 +64,16 @@ const Player = (props) => {
|
||||
const {
|
||||
title,
|
||||
album,
|
||||
artist,
|
||||
artistStr,
|
||||
liked,
|
||||
service,
|
||||
lyricsEnabled,
|
||||
lyrics_enabled,
|
||||
cover_analysis,
|
||||
cover,
|
||||
} = ctx.track_manifest ?? {}
|
||||
} = playerState.track_manifest ?? {}
|
||||
|
||||
const playing = ctx.playback_status === "playing"
|
||||
const stopped = ctx.playback_status === "stopped"
|
||||
const playing = playerState.playback_status === "playing"
|
||||
const stopped = playerState.playback_status === "stopped"
|
||||
|
||||
const titleText = (!playing && stopped) ? "Stopped" : (title ?? "Untitled")
|
||||
const subtitleText = ""
|
||||
@ -89,7 +89,7 @@ const Player = (props) => {
|
||||
"toolbar_player_wrapper",
|
||||
{
|
||||
"hover": topActionsVisible,
|
||||
"minimized": ctx.minimized,
|
||||
"minimized": playerState.minimized,
|
||||
"cover_light": cover_analysis?.isLight,
|
||||
}
|
||||
)}
|
||||
@ -106,7 +106,7 @@ const Player = (props) => {
|
||||
)}
|
||||
>
|
||||
{
|
||||
!ctx.control_locked && <antd.Button
|
||||
!playerState.control_locked && <antd.Button
|
||||
icon={<Icons.MdCast />}
|
||||
shape="circle"
|
||||
|
||||
@ -114,7 +114,7 @@ const Player = (props) => {
|
||||
}
|
||||
|
||||
{
|
||||
lyricsEnabled && <antd.Button
|
||||
lyrics_enabled && <antd.Button
|
||||
icon={<Icons.MdLyrics />}
|
||||
shape="circle"
|
||||
onClick={() => app.location.push("/lyrics")}
|
||||
@ -170,7 +170,7 @@ const Player = (props) => {
|
||||
titleOverflown && <Marquee
|
||||
gradientColor={RGBStringToValues(cover_analysis?.rgb)}
|
||||
gradientWidth={20}
|
||||
play={ctx.playback_status !== "stopped"}
|
||||
play={playerState.playback_status !== "stopped"}
|
||||
>
|
||||
<h1
|
||||
className="toolbar_player_info_title"
|
||||
@ -185,7 +185,7 @@ const Player = (props) => {
|
||||
}
|
||||
|
||||
<p className="toolbar_player_info_subtitle">
|
||||
{artist ?? ""}
|
||||
{artistStr ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -193,10 +193,10 @@ const Player = (props) => {
|
||||
<Controls />
|
||||
|
||||
<SeekBar
|
||||
stopped={ctx.playback_status === "stopped"}
|
||||
playing={ctx.playback_status === "playing"}
|
||||
streamMode={ctx.livestream_mode}
|
||||
disabled={ctx.control_locked}
|
||||
stopped={playerState.playback_status === "stopped"}
|
||||
playing={playerState.playback_status === "playing"}
|
||||
streamMode={playerState.livestream_mode}
|
||||
disabled={playerState.control_locked}
|
||||
/>
|
||||
|
||||
<ExtraActions />
|
||||
@ -206,10 +206,4 @@ const Player = (props) => {
|
||||
</div>
|
||||
}
|
||||
|
||||
const PlayerContextHandler = () => {
|
||||
return <WithPlayerContext>
|
||||
<Player />
|
||||
</WithPlayerContext>
|
||||
}
|
||||
|
||||
export default PlayerContextHandler
|
||||
export default Player
|
@ -57,7 +57,7 @@ const MoreActionsItems = [
|
||||
},
|
||||
]
|
||||
|
||||
export default (props) => {
|
||||
const PostActions = (props) => {
|
||||
const [isSelf, setIsSelf] = React.useState(false)
|
||||
|
||||
const {
|
||||
@ -129,3 +129,5 @@ export default (props) => {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default PostActions
|
@ -41,6 +41,7 @@ export default (props) => {
|
||||
handleOnStart(req.file.uid, req.file)
|
||||
|
||||
await app.cores.remoteStorage.uploadFile(req.file, {
|
||||
headers: props.headers,
|
||||
onProgress: (file, progress) => {
|
||||
setProgess(progress)
|
||||
handleOnProgress(file.uid, progress)
|
||||
|
79
packages/app/src/components/VideoPlayer/index.jsx
Normal file
79
packages/app/src/components/VideoPlayer/index.jsx
Normal 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
|
16
packages/app/src/components/VideoPlayer/index.less
Normal file
16
packages/app/src/components/VideoPlayer/index.less
Normal 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%;
|
||||
}
|
||||
}
|
@ -1,42 +1,52 @@
|
||||
import React from "react"
|
||||
|
||||
export const DefaultContextValues = {
|
||||
loading: false,
|
||||
minimized: false,
|
||||
function deepUnproxy(obj) {
|
||||
// Verificar si es un array y hacer una copia en consecuencia
|
||||
if (Array.isArray(obj)) {
|
||||
obj = [...obj];
|
||||
} else {
|
||||
obj = Object.assign({}, obj);
|
||||
}
|
||||
|
||||
muted: false,
|
||||
volume: 1,
|
||||
for (let key in obj) {
|
||||
if (obj[key] && typeof obj[key] === "object") {
|
||||
obj[key] = deepUnproxy(obj[key]); // Recursión para profundizar en objetos y arrays
|
||||
}
|
||||
}
|
||||
|
||||
sync_mode: false,
|
||||
livestream_mode: false,
|
||||
control_locked: false,
|
||||
|
||||
track_cover_analysis: null,
|
||||
track_metadata: null,
|
||||
|
||||
playback_mode: "repeat",
|
||||
playback_status: null,
|
||||
return obj;
|
||||
}
|
||||
|
||||
export const Context = React.createContext(DefaultContextValues)
|
||||
export const usePlayerStateContext = (updater) => {
|
||||
const [state, setState] = React.useState({ ...app.cores.player.state })
|
||||
|
||||
function handleStateChange(newState) {
|
||||
newState = deepUnproxy(newState)
|
||||
|
||||
setState(newState)
|
||||
|
||||
if (typeof updater === "function") {
|
||||
updater(newState)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
handleStateChange(app.cores.player.state)
|
||||
|
||||
app.cores.player.eventBus().on("player.state.update", handleStateChange)
|
||||
|
||||
return () => {
|
||||
app.cores.player.eventBus().off("player.state.update", handleStateChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
export const Context = React.createContext({})
|
||||
|
||||
export class WithPlayerContext extends React.Component {
|
||||
state = {
|
||||
loading: app.cores.player.state["loading"],
|
||||
minimized: app.cores.player.state["minimized"],
|
||||
|
||||
muted: app.cores.player.state["muted"],
|
||||
volume: app.cores.player.state["volume"],
|
||||
|
||||
sync_mode: app.cores.player.state["sync_mode"],
|
||||
livestream_mode: app.cores.player.state["livestream_mode"],
|
||||
control_locked: app.cores.player.state["control_locked"],
|
||||
|
||||
track_manifest: app.cores.player.state["track_manifest"],
|
||||
|
||||
playback_mode: app.cores.player.state["playback_mode"],
|
||||
playback_status: app.cores.player.state["playback_status"],
|
||||
}
|
||||
state = app.cores.player.state
|
||||
|
||||
events = {
|
||||
"player.state.update": (state) => {
|
||||
@ -44,17 +54,15 @@ export class WithPlayerContext extends React.Component {
|
||||
},
|
||||
}
|
||||
|
||||
eventBus = app.cores.player.eventBus
|
||||
|
||||
componentDidMount() {
|
||||
for (const [event, handler] of Object.entries(this.events)) {
|
||||
this.eventBus.on(event, handler)
|
||||
app.cores.player.eventBus().on(event, handler)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
for (const [event, handler] of Object.entries(this.events)) {
|
||||
this.eventBus.off(event, handler)
|
||||
app.cores.player.eventBus().off(event, handler)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -26,7 +26,7 @@
|
||||
|
||||
padding: 4px;
|
||||
|
||||
font-weight: 600;
|
||||
font-weight: 450;
|
||||
font-family: var(--fontFamily);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color);
|
||||
|
85
packages/app/src/cores/player/classes/PlayerProcessors.js
Normal file
85
packages/app/src/cores/player/classes/PlayerProcessors.js
Normal 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
|
||||
}
|
||||
}
|
37
packages/app/src/cores/player/classes/PlayerState.js
Normal file
37
packages/app/src/cores/player/classes/PlayerState.js
Normal 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
|
||||
}
|
||||
}
|
40
packages/app/src/cores/player/classes/PlayerUI.js
Normal file
40
packages/app/src/cores/player/classes/PlayerUI.js
Normal 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
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import AudioPlayerStorage from "./player.storage"
|
||||
import AudioPlayerStorage from "../player.storage"
|
||||
|
||||
export default class Presets {
|
||||
constructor({
|
37
packages/app/src/cores/player/helpers/setSampleRate.js
Normal file
37
packages/app/src/cores/player/helpers/setSampleRate.js
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
@ -1,243 +1,84 @@
|
||||
import { Core, EventBus } from "vessel"
|
||||
import { Observable } from "object-observer"
|
||||
import { FastAverageColor } from "fast-average-color"
|
||||
import { Core } from "vessel"
|
||||
|
||||
import ToolBarPlayer from "@components/Player/ToolBarPlayer"
|
||||
import BackgroundMediaPlayer from "@components/Player/BackgroundMediaPlayer"
|
||||
import TrackInstance from "@classes/TrackInstance"
|
||||
import MediaSession from "./classes/MediaSession"
|
||||
import ServiceProviders from "./classes/Services"
|
||||
import PlayerState from "./classes/PlayerState"
|
||||
import PlayerUI from "./classes/PlayerUI"
|
||||
import PlayerProcessors from "./classes/PlayerProcessors"
|
||||
|
||||
import setSampleRate from "./helpers/setSampleRate"
|
||||
|
||||
import AudioPlayerStorage from "./player.storage"
|
||||
|
||||
import TrackInstanceClass from "./classes/TrackInstance"
|
||||
import defaultAudioProccessors from "./processors"
|
||||
|
||||
import MediaSession from "./mediaSession"
|
||||
import ServiceProviders from "./services"
|
||||
|
||||
export default class Player extends Core {
|
||||
// core config
|
||||
static dependencies = [
|
||||
"api",
|
||||
"settings"
|
||||
]
|
||||
|
||||
static namespace = "player"
|
||||
|
||||
static bgColor = "aquamarine"
|
||||
static textColor = "black"
|
||||
|
||||
// player config
|
||||
static defaultSampleRate = 48000
|
||||
|
||||
static gradualFadeMs = 150
|
||||
|
||||
// buffer & precomputation
|
||||
static maxManifestPrecompute = 3
|
||||
|
||||
state = new PlayerState(this)
|
||||
ui = new PlayerUI(this)
|
||||
service_providers = new ServiceProviders()
|
||||
|
||||
native_controls = new MediaSession()
|
||||
|
||||
currentDomWindow = null
|
||||
|
||||
audioContext = new AudioContext({
|
||||
sampleRate: AudioPlayerStorage.get("sample_rate") ?? Player.defaultSampleRate,
|
||||
latencyHint: "playback"
|
||||
})
|
||||
|
||||
audioProcessors = []
|
||||
|
||||
eventBus = new EventBus()
|
||||
|
||||
fac = new FastAverageColor()
|
||||
audioProcessors = new PlayerProcessors(this)
|
||||
|
||||
track_prev_instances = []
|
||||
track_instance = null
|
||||
track_next_instances = []
|
||||
|
||||
state = Observable.from({
|
||||
loading: false,
|
||||
minimized: false,
|
||||
|
||||
muted: app.isMobile ? false : (AudioPlayerStorage.get("mute") ?? false),
|
||||
volume: app.isMobile ? 1 : (AudioPlayerStorage.get("volume") ?? 0.3),
|
||||
|
||||
sync_mode: false,
|
||||
livestream_mode: false,
|
||||
control_locked: false,
|
||||
|
||||
track_manifest: null,
|
||||
|
||||
playback_mode: AudioPlayerStorage.get("mode") ?? "normal",
|
||||
playback_status: "stopped",
|
||||
})
|
||||
|
||||
public = {
|
||||
audioContext: this.audioContext,
|
||||
setSampleRate: this.setSampleRate,
|
||||
start: this.start.bind(this),
|
||||
close: this.close.bind(this),
|
||||
playback: {
|
||||
mode: this.playbackMode.bind(this),
|
||||
stop: this.stop.bind(this),
|
||||
toggle: this.togglePlayback.bind(this),
|
||||
pause: this.pausePlayback.bind(this),
|
||||
play: this.resumePlayback.bind(this),
|
||||
next: this.next.bind(this),
|
||||
previous: this.previous.bind(this),
|
||||
seek: this.seek.bind(this),
|
||||
},
|
||||
_setLoading: function (to) {
|
||||
this.state.loading = !!to
|
||||
}.bind(this),
|
||||
duration: this.duration.bind(this),
|
||||
volume: this.volume.bind(this),
|
||||
mute: this.mute.bind(this),
|
||||
toggleMute: this.toggleMute.bind(this),
|
||||
seek: this.seek.bind(this),
|
||||
minimize: this.toggleMinimize.bind(this),
|
||||
collapse: this.toggleCollapse.bind(this),
|
||||
state: new Proxy(this.state, {
|
||||
get: (target, prop) => {
|
||||
return target[prop]
|
||||
},
|
||||
set: (target, prop, value) => {
|
||||
return false
|
||||
}
|
||||
start: this.start,
|
||||
close: this.close,
|
||||
playback: this.bindableReadOnlyProxy({
|
||||
toggle: this.togglePlayback,
|
||||
play: this.resumePlayback,
|
||||
pause: this.pausePlayback,
|
||||
stop: this.stopPlayback,
|
||||
previous: this.previous,
|
||||
next: this.next,
|
||||
mode: this.playbackMode,
|
||||
}),
|
||||
eventBus: new Proxy(this.eventBus, {
|
||||
get: (target, prop) => {
|
||||
return target[prop]
|
||||
},
|
||||
set: (target, prop, value) => {
|
||||
return false
|
||||
}
|
||||
controls: this.bindableReadOnlyProxy({
|
||||
duration: this.duration,
|
||||
volume: this.volume,
|
||||
mute: this.mute,
|
||||
seek: this.seek,
|
||||
setSampleRate: setSampleRate,
|
||||
}),
|
||||
gradualFadeMs: Player.gradualFadeMs,
|
||||
trackInstance: () => {
|
||||
track: () => {
|
||||
return this.track_instance
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
eventBus: () => {
|
||||
return this.eventBus
|
||||
},
|
||||
state: this.state,
|
||||
ui: this.ui.public,
|
||||
audioContext: this.audioContext,
|
||||
gradualFadeMs: Player.gradualFadeMs,
|
||||
}
|
||||
|
||||
async onInitialize() {
|
||||
this.native_controls.initialize()
|
||||
|
||||
this.initializeAudioProcessors()
|
||||
|
||||
for (const [eventName, eventHandler] of Object.entries(this.internalEvents)) {
|
||||
this.eventBus.on(eventName, eventHandler)
|
||||
}
|
||||
|
||||
Observable.observe(this.state, async (changes) => {
|
||||
try {
|
||||
changes.forEach((change) => {
|
||||
if (change.type === "update") {
|
||||
const stateKey = change.path[0]
|
||||
|
||||
this.eventBus.emit(`player.state.update:${stateKey}`, change.object[stateKey])
|
||||
this.eventBus.emit("player.state.update", change.object)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
this.console.error(`Failed to dispatch state updater >`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async initializeBeforeRuntimeInitialize() {
|
||||
for (const [eventName, eventHandler] of Object.entries(this.wsEvents)) {
|
||||
app.cores.api.listenEvent(eventName, eventHandler, Player.websocketListen)
|
||||
}
|
||||
|
||||
async initializeAfterCoresInit() {
|
||||
if (app.isMobile) {
|
||||
this.state.audioVolume = 1
|
||||
}
|
||||
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) {
|
||||
this.audioProcessors.push(new defaultProccessor(this))
|
||||
}
|
||||
|
||||
for await (const processor of this.audioProcessors) {
|
||||
if (typeof processor._init === "function") {
|
||||
try {
|
||||
await processor._init(this.audioContext)
|
||||
} catch (error) {
|
||||
this.console.error(`Failed to initialize audio processor ${processor.constructor.name} >`, error)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// check if processor has exposed public methods
|
||||
if (processor.exposeToPublic) {
|
||||
Object.entries(processor.exposeToPublic).forEach(([key, value]) => {
|
||||
const refName = processor.constructor.refName
|
||||
|
||||
if (typeof this.public[refName] === "undefined") {
|
||||
// by default create a empty object
|
||||
this.public[refName] = {}
|
||||
}
|
||||
|
||||
this.public[refName][key] = value
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// UI Methods
|
||||
//
|
||||
|
||||
attachPlayerComponent() {
|
||||
if (this.currentDomWindow) {
|
||||
this.console.warn("EmbbededMediaPlayer already attached")
|
||||
return false
|
||||
}
|
||||
|
||||
if (app.layout.tools_bar) {
|
||||
this.currentDomWindow = app.layout.tools_bar.attachRender("mediaPlayer", ToolBarPlayer)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
detachPlayerComponent() {
|
||||
if (!this.currentDomWindow) {
|
||||
this.console.warn("EmbbededMediaPlayer not attached")
|
||||
return false
|
||||
}
|
||||
|
||||
if (!app.layout.tools_bar) {
|
||||
this.console.error("Tools bar not found")
|
||||
return false
|
||||
}
|
||||
|
||||
app.layout.tools_bar.detachRender("mediaPlayer")
|
||||
|
||||
this.currentDomWindow = null
|
||||
await this.native_controls.initialize()
|
||||
await this.audioProcessors.initialize()
|
||||
}
|
||||
|
||||
//
|
||||
@ -265,22 +106,6 @@ export default class Player extends Core {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!instance.manifest.cover_analysis) {
|
||||
const cover_analysis = await this.fac.getColorAsync(`https://corsproxy.io/?${encodeURIComponent(instance.manifest.cover ?? instance.manifest.thumbnail)}`)
|
||||
.catch((err) => {
|
||||
this.console.error(err)
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
instance.manifest.cover_analysis = cover_analysis
|
||||
}
|
||||
|
||||
if (!instance._preloaded) {
|
||||
instance.audio.preload = "metadata"
|
||||
instance._preloaded = true
|
||||
}
|
||||
|
||||
if (isIndex) {
|
||||
this.track_next_instances[index] = instance
|
||||
}
|
||||
@ -288,7 +113,7 @@ export default class Player extends Core {
|
||||
return instance
|
||||
}
|
||||
|
||||
async destroyCurrentInstance({ sync = false } = {}) {
|
||||
async destroyCurrentInstance() {
|
||||
if (!this.track_instance) {
|
||||
return false
|
||||
}
|
||||
@ -300,36 +125,6 @@ export default class Player extends Core {
|
||||
|
||||
// reset track_instance
|
||||
this.track_instance = null
|
||||
|
||||
// reset livestream mode
|
||||
this.state.livestream_mode = false
|
||||
}
|
||||
|
||||
async attachProcessorsToInstance(instance) {
|
||||
for await (const [index, processor] of this.audioProcessors.entries()) {
|
||||
if (processor.constructor.node_bypass === true) {
|
||||
instance.contextElement.connect(processor.processor)
|
||||
|
||||
processor.processor.connect(this.audioContext.destination)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof processor._attach !== "function") {
|
||||
this.console.error(`Processor ${processor.constructor.refName} not support attach`)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
instance = await processor._attach(instance, index)
|
||||
}
|
||||
|
||||
const lastProcessor = instance.attachedProcessors[instance.attachedProcessors.length - 1].processor
|
||||
|
||||
// now attach to destination
|
||||
lastProcessor.connect(this.audioContext.destination)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
//
|
||||
@ -364,61 +159,50 @@ export default class Player extends Core {
|
||||
this.destroyCurrentInstance()
|
||||
}
|
||||
|
||||
// attach processors
|
||||
instance = await this.attachProcessorsToInstance(instance)
|
||||
// chage current track instance with provided
|
||||
this.track_instance = instance
|
||||
|
||||
// now set the current instance
|
||||
this.track_instance = await this.preloadAudioInstance(instance)
|
||||
// initialize instance if is not
|
||||
if (this.track_instance._initialized === false) {
|
||||
this.track_instance = await instance.initialize()
|
||||
}
|
||||
|
||||
// update manifest
|
||||
this.state.track_manifest = this.track_instance.manifest
|
||||
|
||||
// attach processors
|
||||
this.track_instance = await this.audioProcessors.attachProcessorsToInstance(this.track_instance)
|
||||
|
||||
// reconstruct audio src if is not set
|
||||
if (this.track_instance.audio.src !== instance.manifest.source) {
|
||||
this.track_instance.audio.src = instance.manifest.source
|
||||
if (this.track_instance.audio.src !== this.track_instance.manifest.source) {
|
||||
this.track_instance.audio.src = this.track_instance.manifest.source
|
||||
}
|
||||
|
||||
// set time to 0
|
||||
this.track_instance.audio.currentTime = 0
|
||||
|
||||
if (params.time >= 0) {
|
||||
this.track_instance.audio.currentTime = params.time
|
||||
}
|
||||
// set time to provided time, if not, set to 0
|
||||
this.track_instance.audio.currentTime = params.time ?? 0
|
||||
|
||||
this.track_instance.audio.muted = this.state.muted
|
||||
this.track_instance.audio.loop = this.state.playback_mode === "repeat"
|
||||
|
||||
this.track_instance.gainNode.gain.value = this.state.volume
|
||||
|
||||
// try to preload next audio
|
||||
// TODO: Use a better way to preload queues
|
||||
if (this.track_next_instances.length > 0) {
|
||||
this.preloadAudioInstance(1)
|
||||
}
|
||||
|
||||
// play
|
||||
await this.track_instance.audio.play()
|
||||
|
||||
this.console.debug(`Playing track >`, this.track_instance)
|
||||
|
||||
// update manifest
|
||||
this.state.track_manifest = instance.manifest
|
||||
|
||||
this.native_controls.update(instance.manifest)
|
||||
// update native controls
|
||||
this.native_controls.update(this.track_instance.manifest)
|
||||
|
||||
return this.track_instance
|
||||
}
|
||||
|
||||
async start(manifest, { sync = false, time, startIndex = 0 } = {}) {
|
||||
if (this.state.control_locked && !sync) {
|
||||
this.console.warn("Controls are locked, cannot do this action")
|
||||
return false
|
||||
}
|
||||
|
||||
this.attachPlayerComponent()
|
||||
async start(manifest, { time, startIndex = 0 } = {}) {
|
||||
this.ui.attachPlayerComponent()
|
||||
|
||||
// !IMPORTANT: abort preloads before destroying current instance
|
||||
await this.abortPreloads()
|
||||
await this.destroyCurrentInstance({
|
||||
sync
|
||||
})
|
||||
await this.destroyCurrentInstance()
|
||||
|
||||
this.state.loading = true
|
||||
|
||||
@ -438,9 +222,8 @@ export default class Player extends Core {
|
||||
|
||||
playlist = playlist.slice(startIndex)
|
||||
|
||||
for await (const [index, _manifest] of playlist.entries()) {
|
||||
let instance = new TrackInstanceClass(this, _manifest)
|
||||
instance = await instance.initialize()
|
||||
for (const [index, _manifest] of playlist.entries()) {
|
||||
let instance = new TrackInstance(this, _manifest)
|
||||
|
||||
this.track_next_instances.push(instance)
|
||||
|
||||
@ -454,12 +237,7 @@ export default class Player extends Core {
|
||||
return manifest
|
||||
}
|
||||
|
||||
next({ sync = false } = {}) {
|
||||
if (this.state.control_locked && !sync) {
|
||||
//this.console.warn("Sync mode is locked, cannot do this action")
|
||||
return false
|
||||
}
|
||||
|
||||
next() {
|
||||
if (this.track_next_instances.length > 0) {
|
||||
// move current audio instance to history
|
||||
this.track_prev_instances.push(this.track_next_instances.shift())
|
||||
@ -468,7 +246,7 @@ export default class Player extends Core {
|
||||
if (this.track_next_instances.length === 0) {
|
||||
this.console.log(`No more tracks to play, stopping...`)
|
||||
|
||||
return this.stop()
|
||||
return this.stopPlayback()
|
||||
}
|
||||
|
||||
let nextIndex = 0
|
||||
@ -480,12 +258,7 @@ export default class Player extends Core {
|
||||
this.play(this.track_next_instances[nextIndex])
|
||||
}
|
||||
|
||||
previous({ sync = false } = {}) {
|
||||
if (this.state.control_locked && !sync) {
|
||||
//this.console.warn("Sync mode is locked, cannot do this action")
|
||||
return false
|
||||
}
|
||||
|
||||
previous() {
|
||||
if (this.track_prev_instances.length > 0) {
|
||||
// move current audio instance to history
|
||||
this.track_next_instances.unshift(this.track_prev_instances.pop())
|
||||
@ -500,6 +273,9 @@ export default class Player extends Core {
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Playback Control
|
||||
//
|
||||
async togglePlayback() {
|
||||
if (this.state.playback_status === "paused") {
|
||||
await this.resumePlayback()
|
||||
@ -509,6 +285,10 @@ export default class Player extends Core {
|
||||
}
|
||||
|
||||
async pausePlayback() {
|
||||
if (!this.state.playback_status === "paused") {
|
||||
return true
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
if (!this.track_instance) {
|
||||
this.console.error("No audio instance")
|
||||
@ -558,15 +338,29 @@ export default class Player extends Core {
|
||||
})
|
||||
}
|
||||
|
||||
stop() {
|
||||
async playbackMode(mode) {
|
||||
if (typeof mode !== "string") {
|
||||
return this.state.playback_mode
|
||||
}
|
||||
|
||||
this.state.playback_mode = mode
|
||||
|
||||
if (this.track_instance) {
|
||||
this.track_instance.audio.loop = this.state.playback_mode === "repeat"
|
||||
}
|
||||
|
||||
AudioPlayerStorage.set("mode", mode)
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
async stopPlayback() {
|
||||
this.destroyCurrentInstance()
|
||||
this.abortPreloads()
|
||||
|
||||
this.state.playback_status = "stopped"
|
||||
this.state.track_manifest = null
|
||||
|
||||
this.state.livestream_mode = false
|
||||
|
||||
this.track_instance = null
|
||||
this.track_next_instances = []
|
||||
this.track_prev_instances = []
|
||||
@ -574,12 +368,19 @@ export default class Player extends Core {
|
||||
this.native_controls.destroy()
|
||||
}
|
||||
|
||||
//
|
||||
// Audio Control
|
||||
//
|
||||
mute(to) {
|
||||
if (app.isMobile && typeof to !== "boolean") {
|
||||
this.console.warn("Cannot mute on mobile")
|
||||
return false
|
||||
}
|
||||
|
||||
if (to === "toggle") {
|
||||
to = !this.state.muted
|
||||
}
|
||||
|
||||
if (typeof to === "boolean") {
|
||||
this.state.muted = to
|
||||
this.track_instance.audio.muted = to
|
||||
@ -621,7 +422,7 @@ export default class Player extends Core {
|
||||
return this.state.volume
|
||||
}
|
||||
|
||||
seek(time, { sync = false } = {}) {
|
||||
seek(time) {
|
||||
if (!this.track_instance || !this.track_instance.audio) {
|
||||
return false
|
||||
}
|
||||
@ -631,12 +432,6 @@ export default class Player extends Core {
|
||||
return this.track_instance.audio.currentTime
|
||||
}
|
||||
|
||||
if (this.state.control_locked && !sync) {
|
||||
this.console.warn("Sync mode is locked, cannot do this action")
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// if time is provided, seek to that time
|
||||
if (typeof time === "number") {
|
||||
this.console.log(`Seeking to ${time} | Duration: ${this.track_instance.audio.duration}`)
|
||||
@ -647,24 +442,8 @@ export default class Player extends Core {
|
||||
}
|
||||
}
|
||||
|
||||
playbackMode(mode) {
|
||||
if (typeof mode !== "string") {
|
||||
return this.state.playback_mode
|
||||
}
|
||||
|
||||
this.state.playback_mode = mode
|
||||
|
||||
if (this.track_instance) {
|
||||
this.track_instance.audio.loop = this.state.playback_mode === "repeat"
|
||||
}
|
||||
|
||||
AudioPlayerStorage.set("mode", mode)
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
duration() {
|
||||
if (!this.track_instance) {
|
||||
if (!this.track_instance || !this.track_instance.audio) {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -687,93 +466,7 @@ export default class Player extends Core {
|
||||
}
|
||||
|
||||
close() {
|
||||
this.stop()
|
||||
this.detachPlayerComponent()
|
||||
}
|
||||
|
||||
toggleMinimize(to) {
|
||||
this.state.minimized = to ?? !this.state.minimized
|
||||
|
||||
if (this.state.minimized) {
|
||||
app.layout.sidebar.attachBottomItem("player", BackgroundMediaPlayer, {
|
||||
noContainer: true
|
||||
})
|
||||
} else {
|
||||
app.layout.sidebar.removeBottomItem("player")
|
||||
}
|
||||
|
||||
return this.state.minimized
|
||||
}
|
||||
|
||||
toggleCollapse(to) {
|
||||
if (typeof to !== "boolean") {
|
||||
this.console.warn("Collapse must be a boolean")
|
||||
return false
|
||||
}
|
||||
|
||||
this.state.collapsed = to ?? !this.state.collapsed
|
||||
|
||||
return this.state.collapsed
|
||||
}
|
||||
|
||||
toggleSyncMode(to, lock) {
|
||||
if (typeof to !== "boolean") {
|
||||
this.console.warn("Sync mode must be a boolean")
|
||||
return false
|
||||
}
|
||||
|
||||
this.state.syncMode = to ?? !this.state.syncMode
|
||||
|
||||
this.state.syncModeLocked = lock ?? false
|
||||
|
||||
this.console.log(`Sync mode is now ${this.state.syncMode ? "enabled" : "disabled"} | Locked: ${this.state.syncModeLocked ? "yes" : "no"}`)
|
||||
|
||||
return this.state.syncMode
|
||||
}
|
||||
|
||||
toggleMute(to) {
|
||||
if (typeof to !== "boolean") {
|
||||
to = !this.state.muted
|
||||
}
|
||||
|
||||
return this.mute(to)
|
||||
}
|
||||
|
||||
async setSampleRate(to) {
|
||||
// must be a integer
|
||||
if (typeof to !== "number") {
|
||||
this.console.error("Sample rate must be a number")
|
||||
return this.audioContext.sampleRate
|
||||
}
|
||||
|
||||
// must be a integer
|
||||
if (!Number.isInteger(to)) {
|
||||
this.console.error("Sample rate must be a integer")
|
||||
return this.audioContext.sampleRate
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
app.confirm({
|
||||
title: "Change sample rate",
|
||||
content: `To change the sample rate, the app needs to be reloaded. Do you want to continue?`,
|
||||
onOk: () => {
|
||||
try {
|
||||
this.audioContext = new AudioContext({ sampleRate: to })
|
||||
|
||||
AudioPlayerStorage.set("sample_rate", to)
|
||||
|
||||
app.navigation.reload()
|
||||
|
||||
return resolve(this.audioContext.sampleRate)
|
||||
} catch (error) {
|
||||
app.message.error(`Failed to change sample rate, ${error.message}`)
|
||||
return resolve(this.audioContext.sampleRate)
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
return resolve(this.audioContext.sampleRate)
|
||||
}
|
||||
})
|
||||
})
|
||||
this.stopPlayback()
|
||||
this.ui.detachPlayerComponent()
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { Modal } from "antd"
|
||||
import ProcessorNode from "../node"
|
||||
import Presets from "../../presets"
|
||||
import Presets from "../../classes/Presets"
|
||||
|
||||
export default class CompressorProcessorNode extends ProcessorNode {
|
||||
constructor(props) {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Modal } from "antd"
|
||||
import ProcessorNode from "../node"
|
||||
import Presets from "../../presets"
|
||||
import Presets from "../../classes/Presets"
|
||||
|
||||
export default class EqProcessorNode extends ProcessorNode {
|
||||
constructor(props) {
|
||||
|
@ -35,7 +35,7 @@ export default class GainProcessorNode extends ProcessorNode {
|
||||
|
||||
applyValues() {
|
||||
// apply to current instance
|
||||
this.processor.gain.value = app.cores.player.volume() * this.state.gain
|
||||
this.processor.gain.value = app.cores.player.state.volume * this.state.gain
|
||||
}
|
||||
|
||||
async init() {
|
||||
|
@ -1,178 +1,139 @@
|
||||
import { EventBus } from "vessel"
|
||||
import SessionModel from "@models/session"
|
||||
|
||||
export default class ChunkedUpload {
|
||||
constructor(params) {
|
||||
this.endpoint = params.endpoint
|
||||
this.file = params.file
|
||||
this.headers = params.headers || {}
|
||||
this.postParams = params.postParams
|
||||
this.service = params.service ?? "default"
|
||||
this.retries = params.retries ?? app.cores.settings.get("uploader.retries") ?? 3
|
||||
this.delayBeforeRetry = params.delayBeforeRetry || 5
|
||||
const {
|
||||
endpoint,
|
||||
file,
|
||||
headers = {},
|
||||
splitChunkSize = 1024 * 1024 * 10,
|
||||
maxRetries = 3,
|
||||
delayBeforeRetry = 5,
|
||||
} = params
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error("Missing endpoint")
|
||||
}
|
||||
|
||||
if (!file instanceof File) {
|
||||
throw new Error("Invalid or missing file")
|
||||
}
|
||||
|
||||
if (typeof headers !== "object") {
|
||||
throw new Error("Invalid headers")
|
||||
}
|
||||
|
||||
if (splitChunkSize <= 0) {
|
||||
throw new Error("Invalid splitChunkSize")
|
||||
}
|
||||
|
||||
this.start = 0
|
||||
this.chunk = null
|
||||
this.chunkCount = 0
|
||||
|
||||
this.splitChunkSize = params.splitChunkSize || 1024 * 1024 * 10
|
||||
this.totalChunks = Math.ceil(this.file.size / this.splitChunkSize)
|
||||
|
||||
this.retriesCount = 0
|
||||
this.offline = false
|
||||
this.paused = false
|
||||
|
||||
this.headers["Authorization"] = `Bearer ${SessionModel.token}`
|
||||
this.headers["uploader-original-name"] = encodeURIComponent(this.file.name)
|
||||
this.headers["uploader-file-id"] = this.uniqid(this.file)
|
||||
this.headers["uploader-chunks-total"] = this.totalChunks
|
||||
this.headers["provider-type"] = this.service
|
||||
this.headers["chunk-size"] = this.splitChunkSize
|
||||
this.splitChunkSize = splitChunkSize
|
||||
this.totalChunks = Math.ceil(file.size / splitChunkSize)
|
||||
|
||||
this._reader = new FileReader()
|
||||
this.eventBus = new EventBus()
|
||||
this.maxRetries = maxRetries
|
||||
this.delayBeforeRetry = delayBeforeRetry
|
||||
this.offline = this.paused = false
|
||||
|
||||
this.validateParams()
|
||||
this.endpoint = endpoint
|
||||
this.file = file
|
||||
this.headers = {
|
||||
...headers,
|
||||
"uploader-original-name": encodeURIComponent(file.name),
|
||||
"uploader-file-id": this.getFileUID(file),
|
||||
"uploader-chunks-total": this.totalChunks,
|
||||
"chunk-size": splitChunkSize
|
||||
}
|
||||
|
||||
this.setupListeners()
|
||||
this.nextSend()
|
||||
|
||||
console.debug("[Uploader] Created", {
|
||||
splitChunkSize: this.splitChunkSize,
|
||||
splitChunkSize: splitChunkSize,
|
||||
totalChunks: this.totalChunks,
|
||||
totalSize: this.file.size,
|
||||
})
|
||||
|
||||
// restart sync when back online
|
||||
// trigger events when offline/back online
|
||||
window.addEventListener("online", () => {
|
||||
if (!this.offline) return
|
||||
|
||||
this.offline = false
|
||||
this.eventBus.emit("online")
|
||||
this.nextSend()
|
||||
})
|
||||
|
||||
window.addEventListener("offline", () => {
|
||||
this.offline = true
|
||||
this.eventBus.emit("offline")
|
||||
totalSize: file.size
|
||||
})
|
||||
}
|
||||
|
||||
on(event, fn) {
|
||||
this.eventBus.on(event, fn)
|
||||
_reader = new FileReader()
|
||||
events = new EventBus()
|
||||
|
||||
setupListeners() {
|
||||
window.addEventListener("online", () => !this.offline && (this.offline = false, this.events.emit("online"), this.nextSend()))
|
||||
window.addEventListener("offline", () => (this.offline = true, this.events.emit("offline")))
|
||||
}
|
||||
|
||||
validateParams() {
|
||||
if (!this.endpoint || !this.endpoint.length) throw new TypeError("endpoint must be defined")
|
||||
if (this.file instanceof File === false) throw new TypeError("file must be a File object")
|
||||
if (this.headers && typeof this.headers !== "object") throw new TypeError("headers must be null or an object")
|
||||
if (this.postParams && typeof this.postParams !== "object") throw new TypeError("postParams must be null or an object")
|
||||
if (this.splitChunkSize && (typeof this.splitChunkSize !== "number" || this.splitChunkSize === 0)) throw new TypeError("splitChunkSize must be a positive number")
|
||||
if (this.retries && (typeof this.retries !== "number" || this.retries === 0)) throw new TypeError("retries must be a positive number")
|
||||
if (this.delayBeforeRetry && (typeof this.delayBeforeRetry !== "number")) throw new TypeError("delayBeforeRetry must be a positive number")
|
||||
}
|
||||
|
||||
uniqid(file) {
|
||||
return Math.floor(Math.random() * 100000000) + Date.now() + this.file.size + "_tmp"
|
||||
getFileUID(file) {
|
||||
return Math.floor(Math.random() * 100000000) + Date.now() + file.size + "_tmp"
|
||||
}
|
||||
|
||||
loadChunk() {
|
||||
return new Promise((resolve) => {
|
||||
const length = this.totalChunks === 1 ? this.file.size : this.splitChunkSize
|
||||
const start = length * this.chunkCount
|
||||
const start = this.chunkCount * this.splitChunkSize
|
||||
const end = Math.min(start + this.splitChunkSize, this.file.size)
|
||||
|
||||
this._reader.onload = () => {
|
||||
this.chunk = new Blob([this._reader.result], { type: "application/octet-stream" })
|
||||
resolve()
|
||||
}
|
||||
|
||||
this._reader.readAsArrayBuffer(this.file.slice(start, start + length))
|
||||
this._reader.onload = () => resolve(new Blob([this._reader.result], { type: "application/octet-stream" }))
|
||||
this._reader.readAsArrayBuffer(this.file.slice(start, end))
|
||||
})
|
||||
}
|
||||
|
||||
sendChunk() {
|
||||
async sendChunk() {
|
||||
const form = new FormData()
|
||||
|
||||
// send post fields on last request
|
||||
if (this.chunkCount + 1 === this.totalChunks && this.postParams) Object.keys(this.postParams).forEach(key => form.append(key, this.postParams[key]))
|
||||
|
||||
form.append("file", this.chunk)
|
||||
|
||||
this.headers["uploader-chunk-number"] = this.chunkCount
|
||||
|
||||
return fetch(this.endpoint, { method: "POST", headers: this.headers, body: form })
|
||||
try {
|
||||
const res = await fetch(
|
||||
this.endpoint,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
body: form
|
||||
})
|
||||
|
||||
return res
|
||||
} catch (error) {
|
||||
this.manageRetries()
|
||||
}
|
||||
}
|
||||
|
||||
manageRetries() {
|
||||
if (this.retriesCount++ < this.retries) {
|
||||
if (++this.retriesCount < this.maxRetries) {
|
||||
setTimeout(() => this.nextSend(), this.delayBeforeRetry * 1000)
|
||||
|
||||
this.eventBus.emit("fileRetry", {
|
||||
message: `An error occured uploading chunk ${this.chunkCount}. ${this.retries - this.retriesCount} retries left`,
|
||||
chunk: this.chunkCount,
|
||||
retriesLeft: this.retries - this.retriesCount
|
||||
})
|
||||
|
||||
return
|
||||
this.events.emit("fileRetry", { message: `Retrying chunk ${this.chunkCount}`, chunk: this.chunkCount, retriesLeft: this.retries - this.retriesCount })
|
||||
} else {
|
||||
this.events.emit("error", { message: `No more retries for chunk ${this.chunkCount}` })
|
||||
}
|
||||
|
||||
this.eventBus.emit("error", {
|
||||
message: `An error occured uploading chunk ${this.chunkCount}. No more retries, stopping upload`
|
||||
})
|
||||
}
|
||||
|
||||
async nextSend() {
|
||||
if (this.paused || this.offline) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
await this.loadChunk()
|
||||
this.chunk = await this.loadChunk()
|
||||
|
||||
const res = await this.sendChunk()
|
||||
.catch((err) => {
|
||||
if (this.paused || this.offline) return
|
||||
|
||||
this.console.error(err)
|
||||
|
||||
// this type of error can happen after network disconnection on CORS setup
|
||||
this.manageRetries()
|
||||
})
|
||||
|
||||
if (res.status === 200 || res.status === 201 || res.status === 204) {
|
||||
if ([200, 201, 204].includes(res.status)) {
|
||||
if (++this.chunkCount < this.totalChunks) {
|
||||
this.nextSend()
|
||||
} else {
|
||||
res.json().then((body) => {
|
||||
this.eventBus.emit("finish", body)
|
||||
})
|
||||
res.json().then((body) => this.events.emit("finish", body))
|
||||
}
|
||||
|
||||
const percentProgress = Math.round((100 / this.totalChunks) * this.chunkCount)
|
||||
|
||||
this.eventBus.emit("progress", {
|
||||
percentProgress
|
||||
this.events.emit("progress", {
|
||||
percentProgress: Math.round((100 / this.totalChunks) * this.chunkCount)
|
||||
})
|
||||
}
|
||||
|
||||
// errors that might be temporary, wait a bit then retry
|
||||
else if ([408, 502, 503, 504].includes(res.status)) {
|
||||
if (this.paused || this.offline) return
|
||||
|
||||
} else if ([408, 502, 503, 504].includes(res.status)) {
|
||||
this.manageRetries()
|
||||
}
|
||||
|
||||
else {
|
||||
if (this.paused || this.offline) return
|
||||
|
||||
try {
|
||||
res.json().then((body) => {
|
||||
this.eventBus.emit("error", {
|
||||
message: `[${res.status}] ${body.error ?? body.message}`
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
this.eventBus.emit("error", {
|
||||
message: `[${res.status}] ${res.statusText}`
|
||||
})
|
||||
}
|
||||
} else {
|
||||
res.json().then((body) => this.events.emit("error", { message: `[${res.status}] ${body.error ?? body.message}` }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -180,7 +141,7 @@ export default class ChunkedUpload {
|
||||
this.paused = !this.paused
|
||||
|
||||
if (!this.paused) {
|
||||
this.nextSend()
|
||||
return this.nextSend()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { Core } from "vessel"
|
||||
|
||||
import ChunkedUpload from "./chunkedUpload"
|
||||
import SessionModel from "@models/session"
|
||||
|
||||
export default class RemoteStorage extends Core {
|
||||
static namespace = "remoteStorage"
|
||||
@ -26,6 +27,7 @@ export default class RemoteStorage extends Core {
|
||||
onFinish = () => { },
|
||||
onError = () => { },
|
||||
service = "standard",
|
||||
headers = {},
|
||||
} = {},
|
||||
) {
|
||||
return await new Promise((_resolve, _reject) => {
|
||||
@ -35,9 +37,14 @@ export default class RemoteStorage extends Core {
|
||||
splitChunkSize: 5 * 1024 * 1024,
|
||||
file: file,
|
||||
service: service,
|
||||
headers: {
|
||||
...headers,
|
||||
"provider-type": service,
|
||||
"Authorization": `Bearer ${SessionModel.token}`,
|
||||
},
|
||||
})
|
||||
|
||||
uploader.on("error", ({ message }) => {
|
||||
uploader.events.on("error", ({ message }) => {
|
||||
this.console.error("[Uploader] Error", message)
|
||||
|
||||
app.cores.notifications.new({
|
||||
@ -55,13 +62,13 @@ export default class RemoteStorage extends Core {
|
||||
_reject(message)
|
||||
})
|
||||
|
||||
uploader.on("progress", ({ percentProgress }) => {
|
||||
uploader.events.on("progress", ({ percentProgress }) => {
|
||||
if (typeof onProgress === "function") {
|
||||
onProgress(file, percentProgress)
|
||||
}
|
||||
})
|
||||
|
||||
uploader.on("finish", (data) => {
|
||||
uploader.events.on("finish", (data) => {
|
||||
this.console.debug("[Uploader] Finish", data)
|
||||
|
||||
app.cores.notifications.new({
|
||||
|
17
packages/app/src/hooks/usePageWidgets/index.js
Normal file
17
packages/app/src/hooks/usePageWidgets/index.js
Normal 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
|
@ -120,14 +120,6 @@ const AccountButton = React.forwardRef((props, ref) => {
|
||||
</div>
|
||||
})
|
||||
|
||||
export default (props) => {
|
||||
return <WithPlayerContext>
|
||||
<BottomBar
|
||||
{...props}
|
||||
/>
|
||||
</WithPlayerContext>
|
||||
}
|
||||
|
||||
export class BottomBar extends React.Component {
|
||||
static contextType = Context
|
||||
|
||||
@ -419,3 +411,11 @@ export class BottomBar extends React.Component {
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
export default (props) => {
|
||||
return <WithPlayerContext>
|
||||
<BottomBar
|
||||
{...props}
|
||||
/>
|
||||
</WithPlayerContext>
|
||||
}
|
@ -11,7 +11,7 @@ export class DraggableDrawerController extends React.Component {
|
||||
|
||||
this.interface = {
|
||||
open: this.open,
|
||||
close: this.close,
|
||||
destroy: this.destroy,
|
||||
actions: this.actions,
|
||||
}
|
||||
|
||||
@ -100,9 +100,15 @@ export class DraggableDrawerController extends React.Component {
|
||||
|
||||
this.setState({ drawers: drawers })
|
||||
|
||||
app.cores.window_mng.close(drawer.winId)
|
||||
app.cores.window_mng.close(drawer.id ?? id)
|
||||
}
|
||||
|
||||
/**
|
||||
* This lifecycle method is called after the component has been updated.
|
||||
* It will toggle the root scale effect based on the amount of drawers.
|
||||
* If there are no drawers, the root scale effect is disabled.
|
||||
* If there are one or more drawers, the root scale effect is enabled.
|
||||
*/
|
||||
componentDidUpdate() {
|
||||
if (this.state.drawers.length === 0) {
|
||||
app.layout.toggleRootScaleEffect(false)
|
||||
|
@ -17,6 +17,10 @@
|
||||
|
||||
height: 100dvh;
|
||||
height: 100vh;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.drawers-mask {
|
||||
|
@ -97,7 +97,7 @@ export default () => {
|
||||
confirmOnClickContent={confirmOnClickContent}
|
||||
>
|
||||
{
|
||||
React.createElement(render, props)
|
||||
React.isValidElement(render) ? React.cloneElement(render, props) : React.createElement(render, props)
|
||||
}
|
||||
</Modal>)
|
||||
}
|
||||
|
@ -336,7 +336,12 @@ export default class Sidebar extends React.Component {
|
||||
const selectedKeyId = this.state.selectedMenuItem?.id
|
||||
|
||||
return <div
|
||||
className="app_sidebar_wrapper"
|
||||
className={classnames(
|
||||
"app_sidebar_wrapper",
|
||||
{
|
||||
["hidden"]: !this.state.visible
|
||||
}
|
||||
)}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
>
|
||||
|
@ -26,6 +26,11 @@
|
||||
gap: 10px;
|
||||
|
||||
padding: 10px;
|
||||
|
||||
&.hidden {
|
||||
padding: 0;
|
||||
width: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.app_sidebar {
|
||||
|
@ -78,32 +78,6 @@ export default class ToolsBar extends React.Component {
|
||||
id="tools_bar"
|
||||
className="tools-bar"
|
||||
>
|
||||
{/* <div className="card" id="trendings">
|
||||
<div className="header">
|
||||
<h2>
|
||||
<Icons.TrendingUp />
|
||||
<Translation>{(t) => t("Trendings")}</Translation>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<HashtagTrendings />
|
||||
</div>
|
||||
|
||||
<div className="card" id="onlineFriends">
|
||||
<div className="header">
|
||||
<h2>
|
||||
<Icons.MdPeopleAlt />
|
||||
<Translation>{(t) => t("Online Friends")}</Translation>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<ConnectedFriends />
|
||||
</div>
|
||||
|
||||
<FeaturedEventsAnnouncements /> */}
|
||||
|
||||
<WidgetsWrapper />
|
||||
|
||||
<div className="attached_renders">
|
||||
{
|
||||
this.state.renders.map((render) => {
|
||||
@ -111,6 +85,8 @@ export default class ToolsBar extends React.Component {
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<WidgetsWrapper />
|
||||
</div>
|
||||
</div>
|
||||
}}
|
||||
|
@ -45,67 +45,13 @@
|
||||
|
||||
flex: 0;
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
background-color: var(--background-color-primary);
|
||||
border-radius: 12px;
|
||||
|
||||
padding: 20px;
|
||||
|
||||
isolation: isolate;
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
width: fit-content;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.header {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
|
||||
margin-bottom: 10px;
|
||||
|
||||
z-index: 150;
|
||||
|
||||
-webkit-box-shadow: 0 0 0 1px rgba(63, 63, 68, 0.05), 0 1px 3px 0 var(--shadow-color);
|
||||
-moz-box-shadow: 0 0 0 1px rgba(63, 63, 68, 0.05), 0 1px 3px 0 var(--shadow-color);
|
||||
box-shadow: 0 0 0 1px rgba(63, 63, 68, 0.05), 0 1px 3px 0 var(--shadow-color);
|
||||
}
|
||||
|
||||
&.content {
|
||||
position: relative;
|
||||
|
||||
transform: translateY(-30px);
|
||||
padding-top: 35px;
|
||||
|
||||
z-index: 45;
|
||||
}
|
||||
}
|
||||
|
||||
.attached_renders {
|
||||
position: sticky;
|
||||
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
width: 100%;
|
||||
|
||||
height: 100%;
|
||||
|
||||
gap: 10px;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from "react"
|
||||
import classnames from "classnames"
|
||||
import { Layout, Alert } from "antd"
|
||||
import { Layout } from "antd"
|
||||
|
||||
import Sidebar from "@layouts/components/sidebar"
|
||||
import ToolsBar from "@layouts/components/toolsBar"
|
||||
@ -20,6 +20,7 @@ const DesktopLayout = (props) => {
|
||||
return <>
|
||||
<BackgroundDecorator />
|
||||
<Modals />
|
||||
<DraggableDrawerController />
|
||||
|
||||
<Layout id="app_layout" className="app_layout">
|
||||
<Sidebar />
|
||||
|
@ -6,7 +6,7 @@ import SeekBar from "@components/Player/SeekBar"
|
||||
import Controls from "@components/Player/Controls"
|
||||
import ExtraActions from "@components/Player/ExtraActions"
|
||||
|
||||
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
import RGBStringToValues from "@utils/rgbToValues"
|
||||
|
||||
import "./index.less"
|
||||
@ -29,22 +29,14 @@ const ServiceIndicator = (props) => {
|
||||
}
|
||||
|
||||
const AudioPlayer = (props) => {
|
||||
return <WithPlayerContext>
|
||||
<AudioPlayerComponent
|
||||
{...props}
|
||||
/>
|
||||
</WithPlayerContext>
|
||||
}
|
||||
|
||||
const AudioPlayerComponent = (props) => {
|
||||
const ctx = React.useContext(Context)
|
||||
const playerState = usePlayerStateContext()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (app.currentDragger) {
|
||||
app.currentDragger.setBackgroundColorValues(RGBStringToValues(ctx.track_manifest?.cover_analysis?.rgb))
|
||||
app.currentDragger.setBackgroundColorValues(RGBStringToValues(playerState.track_manifest?.cover_analysis?.rgb))
|
||||
}
|
||||
|
||||
}, [ctx.track_manifest?.cover_analysis])
|
||||
}, [playerState.track_manifest?.cover_analysis])
|
||||
|
||||
const {
|
||||
title,
|
||||
@ -54,10 +46,10 @@ const AudioPlayerComponent = (props) => {
|
||||
lyricsEnabled,
|
||||
cover_analysis,
|
||||
cover,
|
||||
} = ctx.track_manifest ?? {}
|
||||
} = playerState.track_manifest ?? {}
|
||||
|
||||
const playing = ctx.playback_status === "playing"
|
||||
const stopped = ctx.playback_status === "stopped"
|
||||
const playing = playerState.playback_status === "playing"
|
||||
const stopped = playerState.playback_status === "stopped"
|
||||
|
||||
const titleText = (!playing && stopped) ? "Stopped" : (title ?? "Untitled")
|
||||
const subtitleText = `${artist} | ${album?.title ?? album}`
|
||||
@ -107,10 +99,10 @@ const AudioPlayerComponent = (props) => {
|
||||
<Controls />
|
||||
|
||||
<SeekBar
|
||||
stopped={ctx.playback_status === "stopped"}
|
||||
playing={ctx.playback_status === "playing"}
|
||||
streamMode={ctx.livestream_mode}
|
||||
disabled={ctx.control_locked}
|
||||
stopped={playerState.playback_status === "stopped"}
|
||||
playing={playerState.playback_status === "playing"}
|
||||
streamMode={playerState.livestream_mode}
|
||||
disabled={playerState.control_locked}
|
||||
/>
|
||||
|
||||
<ExtraActions />
|
||||
|
@ -33,8 +33,8 @@ const DroppableField = (props) => {
|
||||
<div className="collapse_btn">
|
||||
{
|
||||
collapsed
|
||||
? <Icons.ChevronDown />
|
||||
: <Icons.ChevronUp />
|
||||
? <Icons.FiChevronDown />
|
||||
: <Icons.FiChevronUp />
|
||||
}
|
||||
</div>
|
||||
<div className="inline_field">
|
||||
|
@ -54,7 +54,7 @@ const MainSelector = (props) => {
|
||||
Create a Comty™ Account
|
||||
</antd.Button>
|
||||
|
||||
<p>
|
||||
<p style={{ display: "inline" }}>
|
||||
<Icons.FiInfo />
|
||||
Registering a new account accepts the <a onClick={() => app.location.push("/terms")}>Terms and Conditions</a> and <a onClick={() => app.location.push("/privacy")}>Privacy policy</a> for the services provided by {config.author}
|
||||
</p>
|
||||
|
12
packages/app/src/pages/badge/[user_id]/index.jsx
Normal file
12
packages/app/src/pages/badge/[user_id]/index.jsx
Normal 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
|
@ -8,7 +8,7 @@ import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
|
||||
import { Icons } from "@components/Icons"
|
||||
import Controls from "@components/Player/Controls"
|
||||
|
||||
import { Context } from "@contexts/WithPlayerContext"
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
|
||||
function isOverflown(element) {
|
||||
if (!element) {
|
||||
@ -47,7 +47,7 @@ const RenderAlbum = (props) => {
|
||||
}
|
||||
|
||||
const PlayerController = React.forwardRef((props, ref) => {
|
||||
const context = React.useContext(Context)
|
||||
const playerState = usePlayerStateContext()
|
||||
|
||||
const titleRef = React.useRef()
|
||||
|
||||
@ -63,42 +63,42 @@ const PlayerController = React.forwardRef((props, ref) => {
|
||||
async function onDragEnd(seekTime) {
|
||||
setDraggingTime(false)
|
||||
|
||||
app.cores.player.seek(seekTime)
|
||||
app.cores.player.controls.seek(seekTime)
|
||||
|
||||
syncPlayback()
|
||||
}
|
||||
|
||||
async function syncPlayback() {
|
||||
if (!context.track_manifest) {
|
||||
if (!playerState.track_manifest) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentTrackTime = app.cores.player.seek()
|
||||
const currentTrackTime = app.cores.player.controls.seek()
|
||||
|
||||
setCurrentTime(currentTrackTime)
|
||||
}
|
||||
|
||||
//* Handle when playback status change
|
||||
React.useEffect(() => {
|
||||
if (context.playback_status === "playing") {
|
||||
if (playerState.playback_status === "playing") {
|
||||
setSyncInterval(setInterval(syncPlayback, 1000))
|
||||
} else {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval)
|
||||
}
|
||||
}
|
||||
}, [context.playback_status])
|
||||
}, [playerState.playback_status])
|
||||
|
||||
React.useEffect(() => {
|
||||
setTitleIsOverflown(isOverflown(titleRef.current))
|
||||
setTrackDuration(app.cores.player.duration())
|
||||
}, [context.track_manifest])
|
||||
setTrackDuration(app.cores.player.controls.duration())
|
||||
}, [playerState.track_manifest])
|
||||
|
||||
React.useEffect(() => {
|
||||
syncPlayback()
|
||||
}, [])
|
||||
|
||||
const isStopped = context.playback_status === "stopped"
|
||||
const isStopped = playerState.playback_status === "stopped"
|
||||
|
||||
return <div
|
||||
className={classnames(
|
||||
@ -124,8 +124,8 @@ const PlayerController = React.forwardRef((props, ref) => {
|
||||
)}
|
||||
>
|
||||
{
|
||||
context.playback_status === "stopped" ? "Nothing is playing" : <>
|
||||
{context.track_manifest?.title ?? "Nothing is playing"}
|
||||
playerState.playback_status === "stopped" ? "Nothing is playing" : <>
|
||||
{playerState.track_manifest?.title ?? "Nothing is playing"}
|
||||
</>
|
||||
}
|
||||
</h4>
|
||||
@ -143,7 +143,7 @@ const PlayerController = React.forwardRef((props, ref) => {
|
||||
isStopped ?
|
||||
"Nothing is playing" :
|
||||
<>
|
||||
{context.track_manifest?.title ?? "Untitled"}
|
||||
{playerState.track_manifest?.title ?? "Untitled"}
|
||||
</>
|
||||
}
|
||||
</h4>
|
||||
@ -152,9 +152,9 @@ const PlayerController = React.forwardRef((props, ref) => {
|
||||
</div>
|
||||
|
||||
<div className="lyrics-player-controller-info-details">
|
||||
<RenderArtist artist={context.track_manifest?.artists} />
|
||||
<RenderArtist artist={playerState.track_manifest?.artists} />
|
||||
-
|
||||
<RenderAlbum album={context.track_manifest?.album} />
|
||||
<RenderAlbum album={playerState.track_manifest?.album} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -189,7 +189,7 @@ const PlayerController = React.forwardRef((props, ref) => {
|
||||
|
||||
<div className="lyrics-player-controller-tags">
|
||||
{
|
||||
context.track_manifest?.metadata.lossless && <Tag
|
||||
playerState.track_manifest?.metadata.lossless && <Tag
|
||||
icon={<Icons.TbWaveSine />}
|
||||
bordered={false}
|
||||
>
|
||||
@ -197,7 +197,7 @@ const PlayerController = React.forwardRef((props, ref) => {
|
||||
</Tag>
|
||||
}
|
||||
{
|
||||
context.track_manifest?.explicit && <Tag
|
||||
playerState.track_manifest?.explicit && <Tag
|
||||
bordered={false}
|
||||
>
|
||||
Explicit
|
||||
@ -212,7 +212,7 @@ const PlayerController = React.forwardRef((props, ref) => {
|
||||
</Tag>
|
||||
}
|
||||
{
|
||||
props.lyrics?.available_langs && <Button
|
||||
props.lyrics?.available_langs?.length > 1 && <Button
|
||||
icon={<Icons.MdTranslate />}
|
||||
type={props.translationEnabled ? "primary" : "default"}
|
||||
onClick={() => props.toggleTranslationEnabled()}
|
||||
|
@ -2,10 +2,10 @@ import React from "react"
|
||||
import classnames from "classnames"
|
||||
import { Motion, spring } from "react-motion"
|
||||
|
||||
import { Context } from "@contexts/WithPlayerContext"
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
|
||||
const LyricsText = React.forwardRef((props, textRef) => {
|
||||
const context = React.useContext(Context)
|
||||
const playerState = usePlayerStateContext()
|
||||
|
||||
const { lyrics } = props
|
||||
|
||||
@ -18,9 +18,9 @@ const LyricsText = React.forwardRef((props, textRef) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentTrackTime = app.cores.player.seek() * 1000
|
||||
const currentTrackTime = app.cores.player.controls.seek() * 1000
|
||||
|
||||
const lineIndex = lyrics.lrc.findIndex((line) => {
|
||||
const lineIndex = lyrics.synced_lyrics.findIndex((line) => {
|
||||
return currentTrackTime >= line.startTimeMs && currentTrackTime <= line.endTimeMs
|
||||
})
|
||||
|
||||
@ -32,7 +32,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const line = lyrics.lrc[lineIndex]
|
||||
const line = lyrics.synced_lyrics[lineIndex]
|
||||
|
||||
setCurrentLineIndex(lineIndex)
|
||||
|
||||
@ -74,8 +74,8 @@ const LyricsText = React.forwardRef((props, textRef) => {
|
||||
|
||||
//* Handle when playback status change
|
||||
React.useEffect(() => {
|
||||
if (typeof lyrics?.lrc !== "undefined") {
|
||||
if (context.playback_status === "playing") {
|
||||
if (typeof lyrics?.synced_lyrics !== "undefined") {
|
||||
if (playerState.playback_status === "playing") {
|
||||
startSyncInterval()
|
||||
} else {
|
||||
if (syncInterval) {
|
||||
@ -85,15 +85,15 @@ const LyricsText = React.forwardRef((props, textRef) => {
|
||||
} else {
|
||||
clearInterval(syncInterval)
|
||||
}
|
||||
}, [context.playback_status])
|
||||
}, [playerState.playback_status])
|
||||
|
||||
//* Handle when lyrics object change
|
||||
React.useEffect(() => {
|
||||
clearInterval(syncInterval)
|
||||
|
||||
if (lyrics) {
|
||||
if (typeof lyrics?.lrc !== "undefined") {
|
||||
if (context.playback_status === "playing") {
|
||||
if (typeof lyrics?.synced_lyrics !== "undefined") {
|
||||
if (playerState.playback_status === "playing") {
|
||||
startSyncInterval()
|
||||
}
|
||||
}
|
||||
@ -104,7 +104,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
|
||||
setVisible(false)
|
||||
clearInterval(syncInterval)
|
||||
setCurrentLineIndex(0)
|
||||
}, [context.track_manifest])
|
||||
}, [playerState.track_manifest])
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
@ -112,7 +112,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!lyrics?.lrc) {
|
||||
if (!lyrics?.synced_lyrics) {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -133,7 +133,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
|
||||
}}
|
||||
>
|
||||
{
|
||||
lyrics.lrc.map((line, index) => {
|
||||
lyrics.synced_lyrics.map((line, index) => {
|
||||
return <p
|
||||
key={index}
|
||||
id={`lyrics-line-${index}`}
|
||||
|
@ -1,31 +1,36 @@
|
||||
import React from "react"
|
||||
import HLS from "hls.js"
|
||||
|
||||
import classnames from "classnames"
|
||||
import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
|
||||
import { Context } from "@contexts/WithPlayerContext"
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
|
||||
const maxLatencyInMs = 55
|
||||
|
||||
const LyricsVideo = React.forwardRef((props, videoRef) => {
|
||||
const context = React.useContext(Context)
|
||||
const playerState = usePlayerStateContext()
|
||||
|
||||
const { lyrics } = props
|
||||
|
||||
const [syncInterval, setSyncInterval] = React.useState(null)
|
||||
const [syncingVideo, setSyncingVideo] = React.useState(false)
|
||||
const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0)
|
||||
const hls = React.useRef(new HLS())
|
||||
|
||||
async function seekVideoToSyncAudio() {
|
||||
if (lyrics) {
|
||||
if (lyrics.video_source && typeof lyrics.sync_audio_at_ms !== "undefined") {
|
||||
const currentTrackTime = app.cores.player.seek()
|
||||
if (!lyrics) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!lyrics.video_source || typeof lyrics.sync_audio_at_ms === "undefined") {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentTrackTime = app.cores.player.controls.seek()
|
||||
|
||||
setSyncingVideo(true)
|
||||
|
||||
videoRef.current.currentTime = currentTrackTime + (lyrics.sync_audio_at_ms / 1000) + app.cores.player.gradualFadeMs / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPlayback() {
|
||||
if (!lyrics) {
|
||||
@ -41,7 +46,7 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentTrackTime = app.cores.player.seek()
|
||||
const currentTrackTime = app.cores.player.controls.seek()
|
||||
const currentVideoTime = videoRef.current.currentTime - (lyrics.sync_audio_at_ms / 1000)
|
||||
|
||||
//console.log(`Current track time: ${currentTrackTime}, current video time: ${currentVideoTime}`)
|
||||
@ -91,8 +96,8 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
|
||||
|
||||
//* Handle when playback status change
|
||||
React.useEffect(() => {
|
||||
if (typeof lyrics?.sync_audio_at_ms !== "undefined") {
|
||||
if (context.playback_status === "playing") {
|
||||
if (lyrics?.video_source && typeof lyrics?.sync_audio_at_ms !== "undefined") {
|
||||
if (playerState.playback_status === "playing") {
|
||||
videoRef.current.play()
|
||||
|
||||
setSyncInterval(setInterval(syncPlayback, 500))
|
||||
@ -104,42 +109,39 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [context.playback_status])
|
||||
}, [playerState.playback_status])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (context.loading === true && context.playback_status === "playing") {
|
||||
if (lyrics?.video_source && playerState.loading === true && playerState.playback_status === "playing") {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
|
||||
if (context.loading === false && context.playback_status === "playing") {
|
||||
if (lyrics?.video_source && playerState.loading === false && playerState.playback_status === "playing") {
|
||||
videoRef.current.play()
|
||||
}
|
||||
|
||||
}, [context.loading])
|
||||
}, [playerState.loading])
|
||||
|
||||
//* Handle when lyrics object change
|
||||
React.useEffect(() => {
|
||||
if (lyrics) {
|
||||
clearInterval(syncInterval)
|
||||
setCurrentVideoLatency(0)
|
||||
setSyncingVideo(false)
|
||||
|
||||
if (lyrics) {
|
||||
if (lyrics.video_source) {
|
||||
videoRef.current.src = lyrics.video_source
|
||||
|
||||
videoRef.current.load()
|
||||
hls.current.loadSource(lyrics.video_source)
|
||||
|
||||
if (typeof lyrics.sync_audio_at_ms !== "undefined") {
|
||||
videoRef.current.currentTime = lyrics.sync_audio_at_ms / 1000
|
||||
|
||||
if (context.playback_status === "playing") {
|
||||
if (playerState.playback_status === "playing") {
|
||||
videoRef.current.play()
|
||||
startSyncInterval()
|
||||
} else {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
|
||||
const currentTime = app.cores.player.seek()
|
||||
const currentTime = app.cores.player.controls.seek()
|
||||
|
||||
if (currentTime > 0) {
|
||||
seekVideoToSyncAudio()
|
||||
@ -148,13 +150,19 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
|
||||
videoRef.current.loop = true
|
||||
videoRef.current.play()
|
||||
}
|
||||
} else {
|
||||
videoRef.current
|
||||
}
|
||||
} else {
|
||||
videoRef.current
|
||||
}
|
||||
}, [lyrics])
|
||||
|
||||
React.useEffect(() => {
|
||||
clearInterval(syncInterval)
|
||||
|
||||
hls.current.attachMedia(videoRef.current)
|
||||
|
||||
return () => {
|
||||
clearInterval(syncInterval)
|
||||
}
|
||||
@ -165,7 +173,6 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
|
||||
props.lyrics?.sync_audio_at && <div
|
||||
className={classnames(
|
||||
"videoDebugOverlay",
|
||||
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
@ -181,7 +188,12 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
|
||||
}
|
||||
|
||||
<video
|
||||
className="lyrics-video"
|
||||
className={classnames(
|
||||
"lyrics-video",
|
||||
{
|
||||
["hidden"]: !lyrics || !lyrics?.video_source
|
||||
}
|
||||
)}
|
||||
ref={videoRef}
|
||||
controls={false}
|
||||
muted
|
||||
|
@ -2,7 +2,7 @@ import React from "react"
|
||||
import classnames from "classnames"
|
||||
|
||||
import useMaxScreen from "@hooks/useMaxScreen"
|
||||
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
|
||||
import MusicService from "@models/music"
|
||||
|
||||
@ -12,8 +12,18 @@ import LyricsText from "./components/text"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
function getDominantColorStr(track_manifest) {
|
||||
if (!track_manifest) {
|
||||
return `0,0,0`
|
||||
}
|
||||
|
||||
const values = track_manifest.cover_analysis?.value ?? [0, 0, 0]
|
||||
|
||||
return `${values[0]}, ${values[1]}, ${values[2]}`
|
||||
}
|
||||
|
||||
const EnchancedLyrics = (props) => {
|
||||
const context = React.useContext(Context)
|
||||
const playerState = usePlayerStateContext()
|
||||
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
const [lyrics, setLyrics] = React.useState(null)
|
||||
@ -46,18 +56,20 @@ const EnchancedLyrics = (props) => {
|
||||
|
||||
React.useEffect((prev) => {
|
||||
if (initialized) {
|
||||
loadLyrics(context.track_manifest._id)
|
||||
loadLyrics(playerState.track_manifest._id)
|
||||
}
|
||||
}, [translationEnabled])
|
||||
|
||||
//* Handle when context change track_manifest
|
||||
React.useEffect(() => {
|
||||
setLyrics(null)
|
||||
|
||||
if (context.track_manifest) {
|
||||
loadLyrics(context.track_manifest._id)
|
||||
if (playerState.track_manifest) {
|
||||
if (!lyrics || (lyrics.track_id !== playerState.track_manifest._id)) {
|
||||
loadLyrics(playerState.track_manifest._id)
|
||||
}
|
||||
}, [context.track_manifest])
|
||||
} else {
|
||||
setLyrics(null)
|
||||
}
|
||||
}, [playerState.track_manifest])
|
||||
|
||||
//* Handle when lyrics data change
|
||||
React.useEffect(() => {
|
||||
@ -72,19 +84,27 @@ const EnchancedLyrics = (props) => {
|
||||
className={classnames(
|
||||
"lyrics",
|
||||
{
|
||||
["stopped"]: context.playback_status !== "playing",
|
||||
["stopped"]: playerState.playback_status !== "playing",
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
"--dominant-color": getDominantColorStr(playerState.track_manifest)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="lyrics-background-color"
|
||||
/>
|
||||
|
||||
{
|
||||
!lyrics?.video_source && <div
|
||||
playerState.track_manifest && !lyrics?.video_source && <div
|
||||
className="lyrics-background-wrapper"
|
||||
>
|
||||
|
||||
<div
|
||||
className="lyrics-background-cover"
|
||||
>
|
||||
<img
|
||||
src={context.track_manifest.cover}
|
||||
src={playerState.track_manifest.cover}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -109,12 +129,4 @@ const EnchancedLyrics = (props) => {
|
||||
</div>
|
||||
}
|
||||
|
||||
const EnchancedLyricsPage = (props) => {
|
||||
return <WithPlayerContext>
|
||||
<EnchancedLyrics
|
||||
{...props}
|
||||
/>
|
||||
</WithPlayerContext>
|
||||
}
|
||||
|
||||
export default EnchancedLyricsPage
|
||||
export default EnchancedLyrics
|
@ -15,8 +15,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics-background-wrapper {
|
||||
.lyrics-background-color {
|
||||
position: absolute;
|
||||
|
||||
z-index: 100;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background:
|
||||
linear-gradient(0deg, rgba(var(--dominant-color), 1), rgba(0, 0, 0, 0)),
|
||||
url("data:image/svg+xml,%3Csvg viewBox='0 0 284 284' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='7.59' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.lyrics-background-wrapper {
|
||||
z-index: 110;
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
@ -25,28 +38,35 @@
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.lyrics-background-cover {
|
||||
position: relative;
|
||||
|
||||
z-index: 110;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 80px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
img {
|
||||
width: 40vw;
|
||||
height: 40vw;
|
||||
|
||||
object-fit: contain;
|
||||
|
||||
border-radius: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics-video {
|
||||
z-index: 105;
|
||||
z-index: 120;
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
@ -58,10 +78,14 @@
|
||||
object-fit: cover;
|
||||
|
||||
transition: all 150ms ease-out;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics-text-wrapper {
|
||||
z-index: 110;
|
||||
z-index: 200;
|
||||
position: fixed;
|
||||
|
||||
bottom: 0;
|
||||
@ -103,7 +127,7 @@
|
||||
|
||||
.lyrics-player-controller-wrapper {
|
||||
position: fixed;
|
||||
z-index: 115;
|
||||
z-index: 210;
|
||||
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
@ -282,7 +306,7 @@
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
|
||||
z-index: 115;
|
||||
z-index: 300;
|
||||
|
||||
display: flex;
|
||||
|
||||
|
@ -507,7 +507,7 @@ export default class SettingItemComponent extends React.PureComponent {
|
||||
{
|
||||
this.state.debouncedValue && <antd.Button
|
||||
type="round"
|
||||
icon={<FiSave />}
|
||||
icon={<Icons.FiSave />}
|
||||
onClick={async () => await this.dispatchUpdate(this.state.debouncedValue)}
|
||||
>
|
||||
Save
|
||||
|
@ -6,6 +6,8 @@
|
||||
--fontSize: 14px;
|
||||
--fontWeight: normal;
|
||||
|
||||
font-family: "DM Mono", sans-serif;
|
||||
|
||||
.editable-text-value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -31,8 +33,11 @@
|
||||
|
||||
gap: 10px;
|
||||
|
||||
font-family: "DM Mono", sans-serif;
|
||||
|
||||
.ant-input {
|
||||
background-color: transparent;
|
||||
|
||||
font-family: "DM Mono", sans-serif;
|
||||
|
||||
font-size: var(--fontSize);
|
||||
|
@ -78,17 +78,14 @@ const ProfileData = (props) => {
|
||||
}
|
||||
|
||||
async function handleEditName() {
|
||||
const modal = app.modal.info({
|
||||
title: "Edit name",
|
||||
content: <ProfileCreator
|
||||
close={() => modal.destroy()}
|
||||
editValue={profile.profile_name}
|
||||
onEdit={async (value) => {
|
||||
app.layout.modal.open("name_editor", ProfileCreator, {
|
||||
props: {
|
||||
editValue: profile.profile_name,
|
||||
onEdit: async (value) => {
|
||||
await handleChange("profile_name", value)
|
||||
app.eventBus.emit("app:profiles_updated", profile._id)
|
||||
}}
|
||||
/>,
|
||||
footer: null
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -118,18 +115,18 @@ const ProfileData = (props) => {
|
||||
/>
|
||||
}
|
||||
|
||||
return <div className="profile-data">
|
||||
return <div className="tvstudio-profile-data">
|
||||
<div
|
||||
className="profile-data-header"
|
||||
className="tvstudio-profile-data-header"
|
||||
>
|
||||
<img
|
||||
className="profile-data-header-image"
|
||||
className="tvstudio-profile-data-header-image"
|
||||
src={profile.info?.thumbnail}
|
||||
/>
|
||||
<div className="profile-data-header-content">
|
||||
<div className="tvstudio-profile-data-header-content">
|
||||
<EditableText
|
||||
value={profile.info?.title ?? "Untitled"}
|
||||
className="profile-data-header-title"
|
||||
className="tvstudio-profile-data-header-title"
|
||||
style={{
|
||||
"--fontSize": "2rem",
|
||||
"--fontWeight": "800"
|
||||
@ -141,7 +138,7 @@ const ProfileData = (props) => {
|
||||
/>
|
||||
<EditableText
|
||||
value={profile.info?.description ?? "No description"}
|
||||
className="profile-data-header-description"
|
||||
className="tvstudio-profile-data-header-description"
|
||||
style={{
|
||||
"--fontSize": "1rem",
|
||||
}}
|
||||
@ -153,8 +150,8 @@ const ProfileData = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-data-field">
|
||||
<div className="profile-data-field-header">
|
||||
<div className="tvstudio-profile-data-field">
|
||||
<div className="tvstudio-profile-data-field-header">
|
||||
<MdOutlineWifiTethering />
|
||||
<span>Server</span>
|
||||
</div>
|
||||
@ -184,8 +181,8 @@ const ProfileData = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-data-field">
|
||||
<div className="profile-data-field-header">
|
||||
<div className="tvstudio-profile-data-field">
|
||||
<div className="tvstudio-profile-data-field-header">
|
||||
<GrConfigure />
|
||||
<span>Configuration</span>
|
||||
</div>
|
||||
@ -233,8 +230,8 @@ const ProfileData = (props) => {
|
||||
</div>
|
||||
|
||||
{
|
||||
profile.sources && <div className="profile-data-field">
|
||||
<div className="profile-data-field-header">
|
||||
profile.sources && <div className="tvstudio-profile-data-field">
|
||||
<div className="tvstudio-profile-data-field-header">
|
||||
<FiLink />
|
||||
<span>Media URL</span>
|
||||
</div>
|
||||
@ -302,8 +299,8 @@ const ProfileData = (props) => {
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="profile-data-field">
|
||||
<div className="profile-data-field-header">
|
||||
<div className="tvstudio-profile-data-field">
|
||||
<div className="tvstudio-profile-data-field-header">
|
||||
<span>Other</span>
|
||||
</div>
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
.profile-data {
|
||||
.tvstudio-profile-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
.profile-data-header {
|
||||
.tvstudio-profile-data-header {
|
||||
position: relative;
|
||||
|
||||
max-height: 200px;
|
||||
@ -17,7 +17,7 @@
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
.profile-data-header-image {
|
||||
.tvstudio-profile-data-header-image {
|
||||
position: absolute;
|
||||
|
||||
left: 0;
|
||||
@ -28,7 +28,7 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-data-header-content {
|
||||
.tvstudio-profile-data-header-content {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
@ -44,13 +44,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.profile-data-field {
|
||||
.tvstudio-profile-data-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.profile-data-field-header {
|
||||
.tvstudio-profile-data-field-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
|
@ -4,7 +4,7 @@ import * as antd from "antd"
|
||||
import Streaming from "@models/spectrum"
|
||||
|
||||
const ProfileSelector = (props) => {
|
||||
const [loading, result, error, repeat] = app.cores.api.useRequest(Streaming.getOwnProfiles)
|
||||
const [loading, list, error, repeat] = app.cores.api.useRequest(Streaming.getOwnProfiles)
|
||||
const [selectedProfileId, setSelectedProfileId] = React.useState(null)
|
||||
|
||||
function handleOnChange(value) {
|
||||
@ -22,7 +22,7 @@ const ProfileSelector = (props) => {
|
||||
|
||||
const handleOnDeletedProfile = async (profile_id) => {
|
||||
await repeat()
|
||||
handleOnChange(result[0]._id)
|
||||
handleOnChange(list[0]._id)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -70,7 +70,7 @@ const ProfileSelector = (props) => {
|
||||
className="profile-selector"
|
||||
>
|
||||
{
|
||||
result.map((profile) => {
|
||||
list.map((profile) => {
|
||||
return <antd.Select.Option
|
||||
key={profile._id}
|
||||
value={profile._id}
|
||||
|
@ -5,26 +5,27 @@ import ProfileSelector from "./components/ProfileSelector"
|
||||
import ProfileData from "./components/ProfileData"
|
||||
import ProfileCreator from "./components/ProfileCreator"
|
||||
|
||||
import useCenteredContainer from "@hooks/useCenteredContainer"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const TVStudioPage = (props) => {
|
||||
useCenteredContainer(true)
|
||||
|
||||
const [selectedProfileId, setSelectedProfileId] = React.useState(null)
|
||||
|
||||
function newProfileModal() {
|
||||
const modal = app.modal.info({
|
||||
title: "Create new profile",
|
||||
content: <ProfileCreator
|
||||
close={() => modal.destroy()}
|
||||
onCreate={(id, data) => {
|
||||
app.layout.modal.open("tv_profile_creator", ProfileCreator, {
|
||||
props: {
|
||||
onCreate: (id, data) => {
|
||||
setSelectedProfileId(id)
|
||||
}}
|
||||
/>,
|
||||
footer: null
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return <div className="main-page">
|
||||
<div className="main-page-actions">
|
||||
return <div className="tvstudio-page">
|
||||
<div className="tvstudio-page-actions">
|
||||
<ProfileSelector
|
||||
onChange={setSelectedProfileId}
|
||||
/>
|
||||
@ -44,18 +45,10 @@ const TVStudioPage = (props) => {
|
||||
}
|
||||
|
||||
{
|
||||
!selectedProfileId && <div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "70vh"
|
||||
}}
|
||||
>
|
||||
<h3>
|
||||
!selectedProfileId && <div className="tvstudio-page-selector-hint">
|
||||
<h1>
|
||||
Select profile or create new
|
||||
</h3>
|
||||
</h1>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
@ -1,17 +1,19 @@
|
||||
.main-page {
|
||||
.tvstudio-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.main-page-actions {
|
||||
.tvstudio-page-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
width: 100%;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.profile-selector {
|
||||
@ -21,4 +23,15 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.tvstudio-page-selector-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding: 50px 0;
|
||||
}
|
||||
}
|
@ -3,10 +3,30 @@ import { Translation } from "react-i18next"
|
||||
|
||||
import { PagePanelWithNavMenu } from "@components/PagePanels"
|
||||
|
||||
import usePageWidgets from "@hooks/usePageWidgets"
|
||||
|
||||
import Tabs from "./tabs"
|
||||
|
||||
export default class Home extends React.Component {
|
||||
render() {
|
||||
const TrendingsCard = () => {
|
||||
return <div className="card">
|
||||
<div className="card-header">
|
||||
<span>Trendings</span>
|
||||
</div>
|
||||
|
||||
<div className="card-content">
|
||||
<span>XD</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const TimelinePage = () => {
|
||||
usePageWidgets([
|
||||
{
|
||||
id: "trendings",
|
||||
component: TrendingsCard
|
||||
}
|
||||
])
|
||||
|
||||
return <PagePanelWithNavMenu
|
||||
tabs={Tabs}
|
||||
extraItems={[
|
||||
@ -29,5 +49,6 @@ export default class Home extends React.Component {
|
||||
transition
|
||||
masked
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
export default TimelinePage
|
@ -1,8 +1,6 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
// import { version as linebridgeVersion } from "linebridge/package.json"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
import LatencyIndicator from "@components/PerformanceIndicators/latency"
|
||||
|
||||
@ -198,7 +196,7 @@ export default {
|
||||
</div>
|
||||
|
||||
<div className="field_value">
|
||||
{app.__eviteVersion ?? "Unknown"}
|
||||
{app.__version ?? "Unknown"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -90,6 +90,10 @@ export default {
|
||||
label: "Varela Round",
|
||||
value: "'Varela Round', sans-serif"
|
||||
},
|
||||
{
|
||||
label: "Manrope",
|
||||
value: "'Manrope', sans-serif"
|
||||
}
|
||||
]
|
||||
},
|
||||
defaultValue: () => {
|
||||
|
15
packages/app/src/settings/tap_share/badge_editor/index.jsx
Normal file
15
packages/app/src/settings/tap_share/badge_editor/index.jsx
Normal 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
|
@ -1,6 +1,5 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import { Input } from "antd"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
@ -8,8 +7,14 @@ import NFCModel from "comty.js/models/nfc"
|
||||
|
||||
import StepsContext from "../../context"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
export default (props) => {
|
||||
const context = React.useContext(StepsContext)
|
||||
const ref = React.useRef()
|
||||
const [form] = antd.Form.useForm()
|
||||
|
||||
const behaviorType = antd.Form.useWatch("behavior", form)
|
||||
|
||||
if (!context.values.serial) {
|
||||
app.message.error("Serial not available.")
|
||||
@ -20,12 +25,14 @@ export default (props) => {
|
||||
}
|
||||
|
||||
const handleOnFinish = async (values) => {
|
||||
console.log({ values })
|
||||
|
||||
context.setValue("alias", values.alias)
|
||||
context.setValue("behavior", values.behavior)
|
||||
|
||||
const result = await NFCModel.registerTag(context.values.serial, {
|
||||
alias: values.alias,
|
||||
behavior: values.behavior
|
||||
behavior: values.behavior,
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
|
||||
@ -55,7 +62,10 @@ export default (props) => {
|
||||
return context.next()
|
||||
}
|
||||
|
||||
return <div className="tap-share-register_step">
|
||||
return <div
|
||||
className="tap-share-register_step"
|
||||
ref={ref}
|
||||
>
|
||||
<h2>
|
||||
Tag Data
|
||||
</h2>
|
||||
@ -68,6 +78,7 @@ export default (props) => {
|
||||
alias: context.values.alias,
|
||||
behavior: context.values.behavior,
|
||||
}}
|
||||
form={form}
|
||||
>
|
||||
<antd.Form.Item
|
||||
name="serial"
|
||||
@ -76,7 +87,7 @@ export default (props) => {
|
||||
Serial
|
||||
</>}
|
||||
>
|
||||
<Input
|
||||
<antd.Input
|
||||
disabled
|
||||
/>
|
||||
</antd.Form.Item>
|
||||
@ -109,10 +120,11 @@ export default (props) => {
|
||||
What will happen when someone taps your tag?
|
||||
</span>
|
||||
|
||||
<div className="ant-form_with_selector">
|
||||
<div
|
||||
className={"ant-form_with_selector"}
|
||||
>
|
||||
<antd.Form.Item
|
||||
name={["behavior", "type"]}
|
||||
noStyle
|
||||
size="large"
|
||||
rules={[
|
||||
{
|
||||
@ -120,32 +132,41 @@ export default (props) => {
|
||||
message: "Please select your tag behavior."
|
||||
}
|
||||
]}
|
||||
initialValue={"url"}
|
||||
noStyle
|
||||
>
|
||||
<antd.Select
|
||||
placeholder="Options"
|
||||
size="large"
|
||||
>
|
||||
<antd.Select.Option
|
||||
value="url"
|
||||
>
|
||||
getPopupContainer={() => ref.current}
|
||||
options={[
|
||||
{
|
||||
value: "url",
|
||||
label: <span className="flex-row gap10">
|
||||
<Icons.FiLink />
|
||||
Custom URL
|
||||
</antd.Select.Option>
|
||||
|
||||
<antd.Select.Option
|
||||
value="profile"
|
||||
>
|
||||
Profile
|
||||
</antd.Select.Option>
|
||||
|
||||
<antd.Select.Option
|
||||
value="random_list"
|
||||
>
|
||||
</span>
|
||||
},
|
||||
{
|
||||
value: "badge",
|
||||
label: <span className="flex-row gap10">
|
||||
<Icons.FiTag />
|
||||
Badge
|
||||
</span>
|
||||
},
|
||||
{
|
||||
value: "random_list",
|
||||
label: <span className="flex-row gap10">
|
||||
<Icons.FiList />
|
||||
Random list
|
||||
</antd.Select.Option>
|
||||
</antd.Select>
|
||||
</span>
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</antd.Form.Item>
|
||||
|
||||
<antd.Form.Item
|
||||
{
|
||||
behaviorType?.type !== "badge" && <antd.Form.Item
|
||||
name={["behavior", "value"]}
|
||||
noStyle
|
||||
rules={[
|
||||
@ -163,6 +184,7 @@ export default (props) => {
|
||||
spellCheck="false"
|
||||
/>
|
||||
</antd.Form.Item>
|
||||
}
|
||||
</div>
|
||||
</antd.Form.Item>
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -113,3 +113,16 @@
|
||||
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;
|
||||
}
|
||||
}
|
@ -83,7 +83,19 @@
|
||||
}
|
||||
|
||||
.ant-dropdown-menu {
|
||||
background-color: var(--background-color-primary) !important;
|
||||
background-color: rgba(var(--layoutBackgroundColor), 0.8) !important;
|
||||
backdrop-filter: blur(3px);
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
.ant-dropdown-menu-title-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fix buttons
|
||||
|
@ -2,6 +2,7 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Varela+Round&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap');
|
||||
|
||||
/* Required secondary fonts */
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap');
|
||||
|
@ -1,3 +1,4 @@
|
||||
@import "@styles/layout.less";
|
||||
@import "@styles/animations.less";
|
||||
@import "@styles/vars.less";
|
||||
@import "@styles/fonts.less";
|
||||
@ -40,6 +41,10 @@ p {
|
||||
font-size: calc(1em * var(--fontScale));
|
||||
}
|
||||
|
||||
code {
|
||||
user-select: text !important;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
@ -49,6 +54,12 @@ h6,
|
||||
p,
|
||||
span,
|
||||
a {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
gap: 5px;
|
||||
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@ -147,14 +158,14 @@ html {
|
||||
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
&.root-scale-effect {
|
||||
background-color: black !important;
|
||||
// &.root-scale-effect {
|
||||
// background-color: black !important;
|
||||
|
||||
#root {
|
||||
animation: rootScaleOut 250ms ease-in-out forwards;
|
||||
animation-delay: 0;
|
||||
}
|
||||
}
|
||||
// #root {
|
||||
// animation: rootScaleOut 250ms ease-in-out forwards;
|
||||
// animation-delay: 0;
|
||||
// }
|
||||
// }
|
||||
|
||||
&.centered-content {
|
||||
.content_layout {
|
||||
@ -206,6 +217,7 @@ svg {
|
||||
}
|
||||
|
||||
*:not(input):not(textarea):not(a) {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* disable selection/Copy of UIWebView */
|
||||
-webkit-touch-callout: none;
|
||||
@ -368,15 +380,58 @@ svg {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@keyframes rootScaleOut {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
.key-value-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 5px;
|
||||
|
||||
.key-value-field-key {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
100% {
|
||||
border-radius: 24px;
|
||||
transform: perspective(1000px) scale(0.99);
|
||||
box-shadow: 0 0 500px 10px var(--colorPrimary);
|
||||
overflow: hidden;
|
||||
.key-value-field-description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 5px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.key-value-field-value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
padding: 7px 5px;
|
||||
|
||||
border-radius: 8px;
|
||||
background-color: var(--background-color-accent);
|
||||
|
||||
font-family: "DM Mono", monospace;
|
||||
|
||||
font-size: 0.7rem;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
span,
|
||||
a {
|
||||
user-select: all !important;
|
||||
}
|
||||
}
|
||||
}
|
67
packages/app/src/styles/layout.less
Normal file
67
packages/app/src/styles/layout.less
Normal 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);
|
||||
}
|
@ -2,13 +2,13 @@
|
||||
require("dotenv").config()
|
||||
require("sucrase/register")
|
||||
|
||||
const path = require("path")
|
||||
const Module = require("module")
|
||||
const { Buffer } = require("buffer")
|
||||
const { webcrypto: crypto } = require("crypto")
|
||||
const path = require("node:path")
|
||||
const Module = require("node:module")
|
||||
const { Buffer } = require("node:buffer")
|
||||
const { webcrypto: crypto } = require("node:crypto")
|
||||
const { InfisicalClient } = require("@infisical/sdk")
|
||||
|
||||
const moduleAlias = require("module-alias")
|
||||
const { onExit } = require("signal-exit")
|
||||
|
||||
// Override file execution arg
|
||||
process.argv.splice(1, 1)
|
||||
@ -135,6 +135,8 @@ async function Boot(main) {
|
||||
throw new Error("main class is not defined")
|
||||
}
|
||||
|
||||
console.log(`[BOOT] Booting in [${global.isProduction ? "production" : "development"}] mode...`)
|
||||
|
||||
if (process.env.INFISICAL_CLIENT_ID && process.env.INFISICAL_CLIENT_SECRET) {
|
||||
console.log(`[BOOT] INFISICAL Credentials found, injecting env variables from INFISICAL...`)
|
||||
await injectEnvFromInfisical()
|
||||
@ -142,6 +144,18 @@ async function Boot(main) {
|
||||
|
||||
const instance = new main()
|
||||
|
||||
onExit((code, signal) => {
|
||||
console.log(`[BOOT] Cleaning up...`)
|
||||
|
||||
if (typeof instance.onClose === "function") {
|
||||
instance.onClose()
|
||||
}
|
||||
|
||||
instance.engine.close()
|
||||
}, {
|
||||
alwaysLast: true,
|
||||
})
|
||||
|
||||
await instance.initialize()
|
||||
|
||||
if (process.env.lb_service && process.send) {
|
||||
@ -153,13 +167,15 @@ async function Boot(main) {
|
||||
return instance
|
||||
}
|
||||
|
||||
console.log(`[BOOT] Booting in [${global.isProduction ? "production" : "development"}] mode...`)
|
||||
try {
|
||||
// Apply patches
|
||||
registerPatches()
|
||||
|
||||
// Apply patches
|
||||
registerPatches()
|
||||
// Apply aliases
|
||||
registerAliases()
|
||||
|
||||
// Apply aliases
|
||||
registerAliases()
|
||||
|
||||
// execute main
|
||||
Module.runMain()
|
||||
// execute main
|
||||
Module.runMain()
|
||||
} catch (error) {
|
||||
console.error("[BOOT] ❌ Boot error: ", error)
|
||||
}
|
@ -20,39 +20,57 @@ export function checkTotalSize(
|
||||
}
|
||||
|
||||
export function checkChunkUploadHeaders(headers) {
|
||||
const requiredHeaders = [
|
||||
"uploader-chunk-number",
|
||||
"uploader-chunks-total",
|
||||
"uploader-original-name",
|
||||
"uploader-file-id"
|
||||
]
|
||||
|
||||
for (const header of requiredHeaders) {
|
||||
if (!headers[header] || typeof headers[header] !== "string") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!headers["uploader-chunk-number"] ||
|
||||
!headers["uploader-chunks-total"] ||
|
||||
!headers["uploader-original-name"] ||
|
||||
!headers["uploader-file-id"] ||
|
||||
!headers["uploader-chunks-total"].match(/^[0-9]+$/) ||
|
||||
!headers["uploader-chunk-number"].match(/^[0-9]+$/)
|
||||
(header === "uploader-chunk-number" || header === "uploader-chunks-total")
|
||||
&& !/^[0-9]+$/.test(headers[header])
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
export function createAssembleChunksPromise({
|
||||
chunksPath, // chunks to assemble
|
||||
filePath, // final assembled file path
|
||||
chunksPath,
|
||||
filePath,
|
||||
maxFileSize,
|
||||
}) {
|
||||
return () => new Promise(async (resolve, reject) => {
|
||||
let fileSize = 0
|
||||
|
||||
if (!fs.existsSync(chunksPath)) {
|
||||
return reject(new OperationError(500,"No chunks found"))
|
||||
return reject(new OperationError(500, "No chunks found"))
|
||||
}
|
||||
|
||||
const chunks = await fs.promises.readdir(chunksPath)
|
||||
let chunks = await fs.promises.readdir(chunksPath)
|
||||
|
||||
if (chunks.length === 0) {
|
||||
throw new OperationError(500, "No chunks found")
|
||||
return reject(new OperationError(500, "No chunks found"))
|
||||
}
|
||||
|
||||
for await (const chunk of chunks) {
|
||||
// Ordenar los chunks numéricamente
|
||||
chunks = chunks.sort((a, b) => {
|
||||
const aNum = parseInt(a, 10)
|
||||
const bNum = parseInt(b, 10)
|
||||
|
||||
return aNum - bNum
|
||||
})
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const chunkPath = path.join(chunksPath, chunk)
|
||||
|
||||
if (!fs.existsSync(chunkPath)) {
|
||||
@ -60,18 +78,13 @@ export function createAssembleChunksPromise({
|
||||
}
|
||||
|
||||
const data = await fs.promises.readFile(chunkPath)
|
||||
|
||||
fileSize += data.length
|
||||
|
||||
// check if final file gonna exceed max file size
|
||||
// in case early estimation is wrong (due client send bad headers)
|
||||
if (fileSize > maxFileSize) {
|
||||
return reject(new OperationError(413, "File exceeds max total file size, aborting assembly..."))
|
||||
}
|
||||
|
||||
await fs.promises.appendFile(filePath, data)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return resolve({
|
||||
@ -81,7 +94,15 @@ export function createAssembleChunksPromise({
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleChunkFile(fileStream, { tmpDir, headers, maxFileSize, maxChunkSize }) {
|
||||
export async function handleChunkFile(
|
||||
fileStream,
|
||||
{
|
||||
tmpDir,
|
||||
headers,
|
||||
maxFileSize,
|
||||
maxChunkSize
|
||||
}
|
||||
) {
|
||||
return await new Promise(async (resolve, reject) => {
|
||||
const chunksPath = path.join(tmpDir, headers["uploader-file-id"], "chunks")
|
||||
const chunkPath = path.join(chunksPath, headers["uploader-chunk-number"])
|
||||
@ -94,7 +115,7 @@ export async function handleChunkFile(fileStream, { tmpDir, headers, maxFileSize
|
||||
|
||||
// make sure chunk is in range
|
||||
if (chunkCount < 0 || chunkCount >= totalChunks) {
|
||||
throw new OperationError(500, "Chunk is out of range")
|
||||
return reject(new OperationError(500, "Chunk is out of range"))
|
||||
}
|
||||
|
||||
// if is the first chunk check if dir exists before write things
|
||||
@ -162,11 +183,14 @@ export async function handleChunkFile(fileStream, { tmpDir, headers, maxFileSize
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadChunkFile(req, {
|
||||
export async function uploadChunkFile(
|
||||
req,
|
||||
{
|
||||
tmpDir,
|
||||
maxFileSize,
|
||||
maxChunkSize,
|
||||
}) {
|
||||
}
|
||||
) {
|
||||
return await new Promise(async (resolve, reject) => {
|
||||
if (!checkChunkUploadHeaders(req.headers)) {
|
||||
reject(new OperationError(400, "Missing header(s)"))
|
||||
|
147
packages/server/classes/MultiqualityHLSJob/index.js
Normal file
147
packages/server/classes/MultiqualityHLSJob/index.js
Normal 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
|
||||
}
|
||||
}
|
@ -38,8 +38,12 @@ export class StorageClient extends Minio.Client {
|
||||
this.defaultRegion = String(options.defaultRegion)
|
||||
}
|
||||
|
||||
composeRemoteURL = (key) => {
|
||||
const _path = path.join(this.defaultBucket, key)
|
||||
composeRemoteURL = (key, extraKey) => {
|
||||
let _path = path.join(this.defaultBucket, key)
|
||||
|
||||
if (typeof extraKey === "string") {
|
||||
_path = path.join(_path, extraKey)
|
||||
}
|
||||
|
||||
return `${this.protocol}//${this.host}:${this.port}/${_path}`
|
||||
}
|
||||
|
@ -32,6 +32,10 @@ export default {
|
||||
endpoint_url: {
|
||||
type: String,
|
||||
default: "https://comty.app/nfc/no_endpoint"
|
||||
},
|
||||
origin: {
|
||||
type: String,
|
||||
default: "comty.app"
|
||||
}
|
||||
}
|
||||
}
|
21
packages/server/db_models/activationCode/index.js
Normal file
21
packages/server/db_models/activationCode/index.js
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
@ -2,12 +2,36 @@ export default {
|
||||
name: "Post",
|
||||
collection: "posts",
|
||||
schema: {
|
||||
user_id: { type: String, required: true },
|
||||
created_at: { type: String, required: true },
|
||||
message: { type: String },
|
||||
attachments: { type: Array, default: [] },
|
||||
flags: { type: Array, default: [] },
|
||||
reply_to: { type: String, default: null },
|
||||
updated_at: { type: String, default: null },
|
||||
user_id: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
created_at: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
message: {
|
||||
type: String
|
||||
},
|
||||
attachments: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
flags: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
reply_to: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
updated_at: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
poll_options: {
|
||||
type: Array,
|
||||
default: null
|
||||
}
|
||||
}
|
||||
}
|
@ -38,5 +38,9 @@ export default {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
lyrics_enabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
@ -8,6 +8,13 @@ export default {
|
||||
},
|
||||
lrc: {
|
||||
type: Object,
|
||||
default: {}
|
||||
},
|
||||
video_source: {
|
||||
type: String,
|
||||
},
|
||||
sync_audio_at: {
|
||||
type: String,
|
||||
}
|
||||
}
|
||||
}
|
@ -2,20 +2,72 @@ export default {
|
||||
name: "User",
|
||||
collection: "accounts",
|
||||
schema: {
|
||||
username: { type: String, required: true },
|
||||
password: { type: String, required: true, select: false },
|
||||
email: { type: String, required: true, select: false },
|
||||
description: { type: String, default: null },
|
||||
created_at: { type: String },
|
||||
public_name: { type: String, default: null },
|
||||
cover: { type: String, default: null },
|
||||
avatar: { type: String, default: null },
|
||||
roles: { type: Array, default: [] },
|
||||
verified: { type: Boolean, default: false },
|
||||
badges: { type: Array, default: [] },
|
||||
links: { type: Array, default: [] },
|
||||
location: { type: String, default: null },
|
||||
birthday: { type: Date, default: null, select: false },
|
||||
accept_tos: { type: Boolean, default: false },
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
select: false
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
select: false
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
created_at: {
|
||||
type: String
|
||||
},
|
||||
public_name: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
cover: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
avatar: {
|
||||
type:
|
||||
String,
|
||||
default: null
|
||||
},
|
||||
roles: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
verified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
badges: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
links: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
location: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
birthday: {
|
||||
type: Date,
|
||||
default: null,
|
||||
select: false
|
||||
},
|
||||
accept_tos: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
activated: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
}
|
||||
}
|
18
packages/server/db_models/votePoll/index.js
Normal file
18
packages/server/db_models/votePoll/index.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -5,7 +5,7 @@ import Spinnies from "spinnies"
|
||||
import { Observable } from "@gullerya/object-observer"
|
||||
import { dots as DefaultSpinner } from "spinnies/spinners.json"
|
||||
import EventEmitter from "@foxify/events"
|
||||
import IPCRouter from "linebridge/dist/server/classes/IPCRouter"
|
||||
import IPCRouter from "linebridge/dist/classes/IPCRouter"
|
||||
import chokidar from "chokidar"
|
||||
import { onExit } from "signal-exit"
|
||||
import chalk from "chalk"
|
||||
@ -76,6 +76,7 @@ export default class Gateway {
|
||||
cwd,
|
||||
onReload: this.serviceHandlers.onReload,
|
||||
onClose: this.serviceHandlers.onClose,
|
||||
onError: this.serviceHandlers.onError,
|
||||
onIPCData: this.serviceHandlers.onIPCData,
|
||||
})
|
||||
|
||||
@ -218,6 +219,7 @@ export default class Gateway {
|
||||
cwd,
|
||||
onReload: this.serviceHandlers.onReload,
|
||||
onClose: this.serviceHandlers.onClose,
|
||||
onError: this.serviceHandlers.onError,
|
||||
onIPCData: this.serviceHandlers.onIPCData,
|
||||
})
|
||||
|
||||
@ -246,11 +248,18 @@ export default class Gateway {
|
||||
|
||||
console.log(`[${id}] Exit with code ${code}`)
|
||||
|
||||
if (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
||||
// try to unregister from proxy
|
||||
this.proxy.unregisterAllFromService(id)
|
||||
|
||||
this.serviceRegistry[id].ready = false
|
||||
},
|
||||
onError: (id, err) => {
|
||||
console.error(`[${id}] Error`, err)
|
||||
},
|
||||
}
|
||||
|
||||
onAllServicesReload = (id) => {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import httpProxy from "http-proxy"
|
||||
import defaults from "linebridge/dist/server/defaults"
|
||||
import defaults from "linebridge/dist/defaults"
|
||||
|
||||
import pkg from "../package.json"
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user