merge from local

This commit is contained in:
SrGooglo 2024-10-29 12:19:57 +00:00
parent 534463cfa2
commit 0db536bbea
65 changed files with 1376 additions and 757 deletions

@ -1 +1 @@
Subproject commit d2e6f1bc5856e3084d4fd068dec5d67ab2ef9d8d
Subproject commit e52925b191b6e1d1415f2bb63d921ccad8c18411

View File

@ -7,9 +7,11 @@
<meta name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="Comty, a prototype of social network." />
<meta name="title" content="Comty" />
<meta name="description" content="Comty, a prototype of social network." />
<meta property="og:title" content="Comty" />
<meta property="og:description" content="Comty, a prototype of social network." />
<meta property="og:image" content="https://dl.ragestudio.net/branding/comty/alt/SVG/t3t3.svg" />
<meta property="og:image" content="https://storage.ragestudio.net/rstudio/branding/comty/iso/basic_alt.svg" />
</head>
<body>

View File

@ -13,6 +13,8 @@ export default class TrackInstance {
this.player = player
this.manifest = manifest
this.id = this.manifest.id ?? this.manifest._id
return this
}
@ -98,6 +100,18 @@ export default class TrackInstance {
return this
}
stop = () => {
this.audio.pause()
const lastProcessor = this.attachedProcessors[this.attachedProcessors.length - 1]
if (lastProcessor) {
this.attachedProcessors[this.attachedProcessors.length - 1]._destroy(this)
}
this.attachedProcessors = []
}
resolveManifest = async () => {
if (typeof this.manifest === "string") {
this.manifest = {

View File

@ -57,7 +57,6 @@ export default class TrackManifest {
// Extended from db
lyrics_enabled = false
liked = null
async initialize() {
@ -78,7 +77,7 @@ export default class TrackManifest {
}
if (this.metadata.tags.picture) {
this.cover = app.cores.remoteStorage.binaryArrayToFile(this.metadata.tags.picture, this.title)
this.cover = app.cores.remoteStorage.binaryArrayToFile(this.metadata.tags.picture, "cover")
const coverUpload = await app.cores.remoteStorage.uploadFile(this.cover)

View File

@ -73,7 +73,7 @@ const MoreMenuHandlers = {
}
}
export default (props) => {
const PlaylistView = (props) => {
const [playlist, setPlaylist] = React.useState(props.playlist)
const [searchResults, setSearchResults] = React.useState(null)
const [owningPlaylist, setOwningPlaylist] = React.useState(checkUserIdIsSelf(props.playlist?.user_id))
@ -109,8 +109,27 @@ export default (props) => {
let debounceSearch = null
const makeSearch = (value) => {
//TODO: Implement me using API
return app.message.info("Not implemented yet...")
}
const handleOnSearchChange = (value) => {
debounceSearch = setTimeout(() => {
makeSearch(value)
}, 500)
}
const handleOnSearchEmpty = () => {
if (debounceSearch) {
clearTimeout(debounceSearch)
}
setSearchResults(null)
}
const handleOnClickPlaylistPlay = () => {
app.cores.player.start(playlist.list, 0)
app.cores.player.start(playlist.list)
}
const handleOnClickViewDetails = () => {
@ -131,7 +150,7 @@ export default (props) => {
return
}
// check if is currently playing
// check if clicked track is currently playing
if (app.cores.player.state.track_manifest?._id === track._id) {
app.cores.player.playback.toggle()
} else {
@ -141,48 +160,7 @@ export default (props) => {
}
}
const handleTrackLike = async (track) => {
return await MusicModel.toggleTrackLike(track._id)
}
const makeSearch = (value) => {
//TODO: Implement me using API
return app.message.info("Not implemented yet...")
const options = {
includeScore: true,
keys: [
"title",
"artist",
"album",
],
}
const fuseInstance = new fuse(playlist.list, options)
const results = fuseInstance.search(value)
console.log(results)
setSearchResults(results.map((result) => {
return result.item
}))
}
const handleOnSearchChange = (value) => {
debounceSearch = setTimeout(() => {
makeSearch(value)
}, 500)
}
const handleOnSearchEmpty = () => {
if (debounceSearch) {
clearTimeout(debounceSearch)
}
setSearchResults(null)
}
const updateTrackLike = (track_id, liked) => {
const handleUpdateTrackLike = (track_id, liked) => {
setPlaylist((prev) => {
const index = prev.list.findIndex((item) => {
return item._id === track_id
@ -202,6 +180,29 @@ export default (props) => {
})
}
const handleTrackChangeState = (track_id, update) => {
setPlaylist((prev) => {
const index = prev.list.findIndex((item) => {
return item._id === track_id
})
if (index !== -1) {
const newState = {
...prev,
}
newState.list[index] = {
...newState.list[index],
...update
}
return newState
}
return prev
})
}
const handleMoreMenuClick = async (e) => {
const handler = MoreMenuHandlers[e.key]
@ -213,8 +214,8 @@ export default (props) => {
}
useWsEvents({
"music:self:track:toggle:like": (data) => {
updateTrackLike(data.track_id, data.action === "liked")
"music:track:toggle:like": (data) => {
handleUpdateTrackLike(data.track_id, data.action === "liked")
}
}, {
socketName: "music",
@ -268,7 +269,7 @@ export default (props) => {
}
<div className="play_info_statistics_item">
<p>
<Icons.MdLibraryMusic /> {props.length ?? playlist.total_length ?? playlist.list.length} Tracks
<Icons.MdLibraryMusic /> {props.length ?? playlist.total_length ?? playlist.list.length} Items
</p>
</div>
{
@ -332,16 +333,29 @@ export default (props) => {
</div>
<div className="list">
<div className="list_header">
<h1>
<Icons.MdPlaylistPlay /> Tracks
</h1>
{
playlist.list.length > 0 && <div className="list_header">
<h1>
<Icons.MdPlaylistPlay /> Tracks
</h1>
<SearchButton
onChange={handleOnSearchChange}
onEmpty={handleOnSearchEmpty}
<SearchButton
onChange={handleOnSearchChange}
onEmpty={handleOnSearchEmpty}
disabled
/>
</div>
}
{
playlist.list.length === 0 && <antd.Empty
description={
<>
<Icons.MdLibraryMusic /> This playlist its empty!
</>
}
/>
</div>
}
{
searchResults && searchResults.map((item) => {
@ -350,21 +364,11 @@ export default (props) => {
order={item._id}
track={item}
onClickPlayBtn={() => handleOnClickTrack(item)}
onLike={() => handleTrackLike(item)}
changeState={(update) => handleTrackChangeState(item._id, update)}
/>
})
}
{
!searchResults && playlist.list.length === 0 && <antd.Empty
description={
<>
<Icons.MdLibraryMusic /> This playlist its empty!
</>
}
/>
}
{
!searchResults && playlist.list.length > 0 && <LoadMore
className="list_content"
@ -379,7 +383,7 @@ export default (props) => {
order={index + 1}
track={item}
onClickPlayBtn={() => handleOnClickTrack(item)}
onLike={() => handleTrackLike(item)}
changeState={(update) => handleTrackChangeState(item._id, update)}
/>
})
}
@ -390,4 +394,6 @@ export default (props) => {
</div>
</WithPlayerContext>
</PlaylistContext.Provider>
}
}
export default PlaylistView

View File

@ -7,6 +7,8 @@ import RGBStringToValues from "@utils/rgbToValues"
import ImageViewer from "@components/ImageViewer"
import { Icons } from "@components/Icons"
import MusicModel from "@models/music"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
@ -14,21 +16,29 @@ import "./index.less"
const handlers = {
"like": async (ctx, track) => {
app.cores.player.toggleCurrentTrackLike(true, track)
await MusicModel.toggleItemFavourite("track", track._id, true)
ctx.changeState({
liked: true,
})
ctx.closeMenu()
},
"unlike": async (ctx, track) => {
app.cores.player.toggleCurrentTrackLike(false, track)
await MusicModel.toggleItemFavourite("track", track._id, false)
ctx.changeState({
liked: false,
})
ctx.closeMenu()
},
}
const Track = (props) => {
const {
const [{
loading,
track_manifest,
playback_status,
} = usePlayerStateContext()
}] = usePlayerStateContext()
const playlist_ctx = React.useContext(PlaylistContext)
@ -74,7 +84,8 @@ const Track = (props) => {
{
closeMenu: () => {
setMoreMenuOpened(false)
}
},
changeState: props.changeState,
},
props.track
)

View File

@ -35,7 +35,7 @@ const EventsHandlers = {
}
const Controls = (props) => {
const playerState = usePlayerStateContext()
const [playerState] = usePlayerStateContext()
const handleAction = (event, ...args) => {
if (typeof EventsHandlers[event] !== "function") {

View File

@ -6,11 +6,12 @@ import LikeButton from "@components/LikeButton"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
const ExtraActions = (props) => {
const playerState = usePlayerStateContext()
import MusicModel from "@models/music"
const ExtraActions = (props) => {
const [playerState] = usePlayerStateContext()
const handleClickLike = async () => {
await app.cores.player.toggleCurrentTrackLike(!playerState.track_manifest?.liked)
await MusicModel.toggleItemFavourite("track", playerState.track_manifest._id)
}
return <div className="extra_actions">
@ -21,9 +22,10 @@ const ExtraActions = (props) => {
disabled={!playerState.track_manifest?.lyrics_enabled}
/>
}
{
!app.isMobile && <LikeButton
liked={playerState.track_manifest?.liked ?? false}
liked={playerState.track_manifest?.fetchLikeStatus}
onClick={handleClickLike}
/>
}

View File

@ -43,7 +43,7 @@ const ServiceIndicator = (props) => {
}
const Player = (props) => {
const playerState = usePlayerStateContext()
const [playerState] = usePlayerStateContext()
const contentRef = React.useRef()
const titleRef = React.useRef()

View File

@ -31,13 +31,13 @@ const SelfActionsItems = [
]
const MoreActionsItems = [
{
key: "onClickRepost",
label: <>
<Icons.MdCallSplit />
<span>Repost</span>
</>,
},
// {
// key: "onClickRepost",
// label: <>
// <Icons.MdCallSplit />
// <span>Repost</span>
// </>,
// },
{
key: "onClickShare",
label: <>
@ -57,6 +57,19 @@ const MoreActionsItems = [
},
]
const BuiltInActions = {
onClickShare: (post) => {
navigator.share({
title: post.title,
text: `Check this post on Comty`,
url: post.share_url
})
},
onClickReport: (post) => {
}
}
const PostActions = (props) => {
const [isSelf, setIsSelf] = React.useState(false)
@ -77,8 +90,10 @@ const PostActions = (props) => {
}
const handleDropdownClickItem = (e) => {
if (typeof props.actions[e.key] === "function") {
props.actions[e.key]()
const action = BuiltInActions[e.key] ?? props.actions[e.key]
if (typeof action === "function") {
action(props.post)
}
}

View File

@ -46,6 +46,12 @@ const messageRegexs = [
return <a key={key} onClick={() => window.app.location.push(`/@${result[1].substr(1)}`)}>{result[1]}</a>
}
},
{
regex: /#[a-zA-Z0-9_]+/gi,
fn: (key, result) => {
return <a key={key} onClick={() => window.app.location.push(`/trending/${result[0].substr(1)}`)}>{result[0]}</a>
}
}
]
export default class PostCard extends React.PureComponent {
@ -223,6 +229,7 @@ export default class PostCard extends React.PureComponent {
</div>
<PostActions
post={this.state.data}
user_id={this.state.data.user_id}
likesCount={this.state.countLikes}

View File

@ -4,7 +4,7 @@ import classnames from "classnames"
import "./index.less"
export default (props) => {
const SearchButton = (props) => {
const searchBoxRef = React.useRef(null)
const [value, setValue] = React.useState()
@ -51,6 +51,9 @@ export default (props) => {
openSearchBox(false)
}
}}
disabled={props.disabled}
/>
</div>
}
}
export default SearchButton

View File

@ -0,0 +1,45 @@
import React from "react"
import { Skeleton } from "antd"
import { Icons } from "@components/Icons"
import PostsModel from "@models/post"
import "./index.less"
const TrendingsCard = (props) => {
const [L_Trendings, R_Trendings, E_Trendings] = app.cores.api.useRequest(PostsModel.getTrendings)
return <div className="card">
<div className="card-header">
<h1><Icons.IoMdTrendingUp /> Trendings</h1>
</div>
<div className="card-content trendings">
{
L_Trendings && <Skeleton active />
}
{
E_Trendings && <span>Something went wrong</span>
}
{
!L_Trendings && !E_Trendings && R_Trendings && R_Trendings.map((trending, index) => {
return <div
key={index}
className="trending"
onClick={() => window.app.location.push(`/trending/${trending.hashtag}`)}
>
<div className="trending-level">
<span>#{index + 1} {trending.hashtag}</span>
</div>
<div className="trending-info">
<span>{trending.count} posts</span>
</div>
</div>
})
}
</div>
</div>
}
export default TrendingsCard

View File

@ -0,0 +1,35 @@
.trendings {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0 10px;
.trending {
display: inline-flex;
flex-direction: column;
justify-content: center;
gap: 5px;
cursor: pointer;
&:not(:last-child) {
padding-bottom: 10px;
border-bottom: 2px solid var(--border-color);
}
.trending-level {
font-family: "DM Mono", monospace;
}
.trending-info {
font-size: 0.7rem;
span {
color: var(--background-color-contrast);
}
}
}
}

View File

@ -1,20 +1,19 @@
import React from "react"
function deepUnproxy(obj) {
// Verificar si es un array y hacer una copia en consecuencia
if (Array.isArray(obj)) {
obj = [...obj];
obj = [...obj]
} else {
obj = Object.assign({}, obj);
obj = Object.assign({}, obj)
}
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
obj[key] = deepUnproxy(obj[key])
}
}
return obj;
return obj
}
export const usePlayerStateContext = (updater) => {
@ -40,7 +39,7 @@ export const usePlayerStateContext = (updater) => {
}
}, [])
return state
return [state, setState]
}
export const Context = React.createContext({})

View File

@ -53,6 +53,13 @@ export default class APICore extends Core {
enableWs: true,
})
this.client.eventBus.on("ws:disconnected", () => {
app.cores.notifications.new({
title: "Failed to connect to server",
description: "The connection to the server was lost. Some features may not work properly.",
})
})
this.client.eventBus.on("auth:login_success", () => {
app.eventBus.emit("auth:login_success")
})

View File

@ -43,7 +43,7 @@ export default class PlayerProcessors {
Object.entries(processor.exposeToPublic).forEach(([key, value]) => {
const refName = processor.constructor.refName
if (typeof this.public[refName] === "undefined") {
if (typeof this.player.public[refName] === "undefined") {
// by default create a empty object
this.player.public[refName] = {}
}
@ -55,8 +55,6 @@ export default class PlayerProcessors {
}
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)

View File

@ -10,7 +10,6 @@ export default class PlayerUI {
//
// UI Methods
//
attachPlayerComponent() {
if (this.currentDomWindow) {
this.player.console.warn("EmbbededMediaPlayer already attached")
@ -18,7 +17,9 @@ export default class PlayerUI {
}
if (app.layout.tools_bar) {
this.currentDomWindow = app.layout.tools_bar.attachRender("mediaPlayer", ToolBarPlayer)
this.currentDomWindow = app.layout.tools_bar.attachRender("mediaPlayer", ToolBarPlayer, undefined, {
position: "bottom",
})
}
}

View File

@ -4,6 +4,7 @@ export default class Presets {
constructor({
storage_key,
defaultPresetValue,
onApplyValues,
}) {
if (!storage_key) {
throw new Error("storage_key is required")
@ -11,6 +12,7 @@ export default class Presets {
this.storage_key = storage_key
this.defaultPresetValue = defaultPresetValue
this.onApplyValues = onApplyValues
return this
}
@ -38,14 +40,25 @@ export default class Presets {
}
get currentPresetValues() {
const presets = this.presets
const key = this.currentPresetKey
if (!presets || !presets[key]) {
if (!this.presets || !this.presets[this.currentPresetKey]) {
return this.defaultPresetValue
}
return presets[key]
return this.presets[this.currentPresetKey]
}
set currentPresetValues(values) {
const newData = this.presets
newData[this.currentPresetKey] = values
this.presets = newData
}
applyValues() {
if (typeof this.onApplyValues === "function") {
this.onApplyValues(this.presets)
}
}
deletePreset(key) {
@ -54,64 +67,74 @@ export default class Presets {
return false
}
// if current preset is deleted, change to default
if (this.currentPresetKey === key) {
this.changePreset("default")
}
let presets = this.presets
let newData = this.presets
delete presets[key]
delete newData[key]
this.presets = presets
this.presets = newData
return presets
this.applyValues()
return newData
}
createPreset(key, values) {
let presets = this.presets
if (presets[key]) {
if (this.presets[key]) {
app.message.error("Preset already exists")
return false
}
presets[key] = values ?? this.defaultPresetValue
let newData = this.presets
this.presets = presets
newData[key] = values ?? this.defaultPresetValue
return presets[key]
this.applyValues()
this.presets = newData
return newData
}
changePreset(key) {
let presets = this.presets
// create new one
if (!presets[key]) {
presets[key] = this.defaultPresetValue
this.presets = presets
if (!this.presets[key]) {
this.presets[key] = this.defaultPresetValue
}
this.currentPresetKey = key
return presets[key]
this.applyValues()
return this.presets[key]
}
setToCurrent(values) {
let preset = this.currentPresetValues
preset = {
...preset,
this.currentPresetValues = {
...this.currentPresetValues,
...values,
}
// update presets
let presets = this.presets
this.applyValues()
presets[this.currentPresetKey] = preset
return this.currentPresetValues
}
this.presets = presets
async setCurrentPresetToDefault() {
return await new Promise((resolve) => {
app.layout.modal.confirm.confirm({
title: "Reset to default values?",
content: "Are you sure you want to reset to default values?",
onOk: () => {
this.setToCurrent(this.defaultPresetValue)
return preset
resolve(this.currentPresetValues)
}
})
})
}
}

View File

@ -0,0 +1,126 @@
export default class QueueManager {
constructor(params = {}) {
this.params = params
return this
}
prevItems = []
nextItems = []
currentItem = null
next = ({ random = false } = {}) => {
if (this.nextItems.length === 0) {
return null
}
if (this.currentItem) {
this.prevItems.push(this.currentItem)
}
if (random) {
const randomIndex = Math.floor(Math.random() * this.nextItems.length)
this.currentItem = this.nextItems.splice(randomIndex, 1)[0]
} else {
this.currentItem = this.nextItems.shift()
}
return this.currentItem
}
set = (item) => {
if (typeof item === "number") {
item = this.nextItems[item]
}
if (this.currentItem && this.currentItem.id === item.id) {
return this.currentItem
}
const itemInNext = this.nextItems.findIndex((i) => i.id === item.id)
const itemInPrev = this.prevItems.findIndex((i) => i.id === item.id)
if (itemInNext === -1 && itemInPrev === -1) {
throw new Error("Item not found in the queue")
}
if (itemInNext > -1) {
if (this.currentItem) {
this.prevItems.push(this.currentItem)
}
this.prevItems.push(...this.nextItems.splice(0, itemInNext))
this.currentItem = this.nextItems.shift()
}
if (itemInPrev > -1) {
if (this.currentItem) {
this.nextItems.unshift(this.currentItem)
}
this.nextItems.unshift(...this.prevItems.splice(itemInPrev + 1))
this.currentItem = this.prevItems.pop()
}
return this.currentItem
}
previous = () => {
if (this.prevItems.length === 0) {
return this.currentItem
}
if (this.currentItem) {
this.nextItems.unshift(this.currentItem)
}
this.currentItem = this.prevItems.pop()
return this.currentItem
}
add = (items) => {
if (!Array.isArray(items)) {
items = [items]
}
this.nextItems = [...this.nextItems, ...items]
return items
}
remove = (item) => {
const indexNext = this.nextItems.findIndex((i) => i.id === item.id)
const indexPrev = this.prevItems.findIndex((i) => i.id === item.id)
if (indexNext > -1) {
this.nextItems.splice(indexNext, 1)
}
if (indexPrev > -1) {
this.prevItems.splice(indexPrev, 1)
}
this.consoleState()
}
flush = () => {
this.nextItems = []
this.prevItems = []
this.currentItem = null
this.consoleState()
}
async load(item) {
if (typeof this.params.loadFunction === "function") {
return await this.params.loadFunction(item)
}
return item
}
}

View File

@ -6,6 +6,7 @@ import ServiceProviders from "./classes/Services"
import PlayerState from "./classes/PlayerState"
import PlayerUI from "./classes/PlayerUI"
import PlayerProcessors from "./classes/PlayerProcessors"
import QueueManager from "./classes/QueueManager"
import setSampleRate from "./helpers/setSampleRate"
@ -28,8 +29,8 @@ export default class Player extends Core {
state = new PlayerState(this)
ui = new PlayerUI(this)
service_providers = new ServiceProviders()
native_controls = new MediaSession()
serviceProviders = new ServiceProviders()
nativeControls = new MediaSession()
audioContext = new AudioContext({
sampleRate: AudioPlayerStorage.get("sample_rate") ?? Player.defaultSampleRate,
latencyHint: "playback"
@ -37,9 +38,11 @@ export default class Player extends Core {
audioProcessors = new PlayerProcessors(this)
track_prev_instances = []
track_instance = null
track_next_instances = []
queue = new QueueManager({
loadFunction: this.createInstance
})
currentTrackInstance = null
public = {
start: this.start,
@ -61,7 +64,7 @@ export default class Player extends Core {
setSampleRate: setSampleRate,
}),
track: () => {
return this.track_instance
return this.queue.currentItem
},
eventBus: () => {
return this.eventBus
@ -77,7 +80,7 @@ export default class Player extends Core {
this.state.volume = 1
}
await this.native_controls.initialize()
await this.nativeControls.initialize()
await this.audioProcessors.initialize()
}
@ -85,130 +88,75 @@ export default class Player extends Core {
// Instance managing methods
//
async abortPreloads() {
for await (const instance of this.track_next_instances) {
for await (const instance of this.queue.nextItems) {
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 (isIndex) {
this.track_next_instances[index] = instance
}
return instance
}
async destroyCurrentInstance() {
if (!this.track_instance) {
return false
}
// stop playback
if (this.track_instance.audio) {
this.track_instance.audio.pause()
}
// reset track_instance
this.track_instance = null
async createInstance(manifest) {
return new TrackInstance(this, manifest)
}
//
// 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")
}
// resume audio context if needed
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()
}
// chage current track instance with provided
this.track_instance = instance
// initialize instance if is not
if (this.track_instance._initialized === false) {
this.track_instance = await instance.initialize()
if (this.queue.currentItem._initialized === false) {
this.queue.currentItem = await instance.initialize()
}
// update manifest
this.state.track_manifest = this.track_instance.manifest
this.state.track_manifest = this.queue.currentItem.manifest
// attach processors
this.track_instance = await this.audioProcessors.attachProcessorsToInstance(this.track_instance)
this.queue.currentItem = await this.audioProcessors.attachProcessorsToInstance(this.queue.currentItem)
// reconstruct audio src if is not set
if (this.track_instance.audio.src !== this.track_instance.manifest.source) {
this.track_instance.audio.src = this.track_instance.manifest.source
if (this.queue.currentItem.audio.src !== this.queue.currentItem.manifest.source) {
this.queue.currentItem.audio.src = this.queue.currentItem.manifest.source
}
// 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
// set audio properties
this.queue.currentItem.audio.currentTime = params.time ?? 0
this.queue.currentItem.audio.muted = this.state.muted
this.queue.currentItem.audio.loop = this.state.playback_mode === "repeat"
this.queue.currentItem.gainNode.gain.value = this.state.volume
// play
await this.track_instance.audio.play()
await this.queue.currentItem.audio.play()
this.console.debug(`Playing track >`, this.track_instance)
this.console.debug(`Playing track >`, this.queue.currentItem)
// update native controls
this.native_controls.update(this.track_instance.manifest)
this.nativeControls.update(this.queue.currentItem.manifest)
return this.track_instance
return this.queue.currentItem
}
async start(manifest, { time, startIndex = 0 } = {}) {
this.ui.attachPlayerComponent()
// !IMPORTANT: abort preloads before destroying current instance
if (this.queue.currentItem) {
await this.queue.currentItem.stop()
}
await this.abortPreloads()
await this.destroyCurrentInstance()
await this.queue.flush()
this.state.loading = true
this.track_prev_instances = []
this.track_next_instances = []
let playlist = Array.isArray(manifest) ? manifest : [manifest]
if (playlist.length === 0) {
@ -217,60 +165,47 @@ export default class Player extends Core {
}
if (playlist.some((item) => typeof item === "string")) {
playlist = await this.service_providers.resolveMany(playlist)
playlist = await this.serviceProviders.resolveMany(playlist)
}
playlist = playlist.slice(startIndex)
for await (const [index, _manifest] of playlist.entries()) {
let instance = await this.createInstance(_manifest)
for (const [index, _manifest] of playlist.entries()) {
let instance = new TrackInstance(this, _manifest)
this.track_next_instances.push(instance)
if (index === 0) {
this.play(this.track_next_instances[0], {
time: time ?? 0
})
}
this.queue.add(instance)
}
const item = this.queue.set(startIndex)
this.play(item, {
time: time ?? 0
})
return manifest
}
next() {
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.queue.currentItem) {
this.queue.currentItem.stop()
}
if (this.track_next_instances.length === 0) {
this.console.log(`No more tracks to play, stopping...`)
//const isRandom = this.state.playback_mode === "shuffle"
const item = this.queue.next()
if (!item) {
return this.stopPlayback()
}
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])
return this.play(item)
}
previous() {
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.queue.currentItem) {
this.queue.currentItem.stop()
}
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)
}
const item = this.queue.previous()
return this.play(item)
}
//
@ -290,23 +225,23 @@ export default class Player extends Core {
}
return await new Promise((resolve, reject) => {
if (!this.track_instance) {
if (!this.queue.currentItem) {
this.console.error("No audio instance")
return null
}
// set gain exponentially
this.track_instance.gainNode.gain.linearRampToValueAtTime(
this.queue.currentItem.gainNode.gain.linearRampToValueAtTime(
0.0001,
this.audioContext.currentTime + (Player.gradualFadeMs / 1000)
)
setTimeout(() => {
this.track_instance.audio.pause()
this.queue.currentItem.audio.pause()
resolve()
}, Player.gradualFadeMs)
this.native_controls.updateIsPlaying(false)
this.nativeControls.updateIsPlaying(false)
})
}
@ -316,25 +251,25 @@ export default class Player extends Core {
}
return await new Promise((resolve, reject) => {
if (!this.track_instance) {
if (!this.queue.currentItem) {
this.console.error("No audio instance")
return null
}
// ensure audio elemeto starts from 0 volume
this.track_instance.gainNode.gain.value = 0.0001
this.queue.currentItem.gainNode.gain.value = 0.0001
this.track_instance.audio.play().then(() => {
this.queue.currentItem.audio.play().then(() => {
resolve()
})
// set gain exponentially
this.track_instance.gainNode.gain.linearRampToValueAtTime(
this.queue.currentItem.gainNode.gain.linearRampToValueAtTime(
this.state.volume,
this.audioContext.currentTime + (Player.gradualFadeMs / 1000)
)
this.native_controls.updateIsPlaying(true)
this.nativeControls.updateIsPlaying(true)
})
}
@ -345,8 +280,8 @@ export default class Player extends Core {
this.state.playback_mode = mode
if (this.track_instance) {
this.track_instance.audio.loop = this.state.playback_mode === "repeat"
if (this.queue.currentItem) {
this.queue.currentItem.audio.loop = this.state.playback_mode === "repeat"
}
AudioPlayerStorage.set("mode", mode)
@ -355,17 +290,22 @@ export default class Player extends Core {
}
async stopPlayback() {
this.destroyCurrentInstance()
if (this.queue.currentItem) {
this.queue.currentItem.stop()
}
this.queue.flush()
this.abortPreloads()
this.state.playback_status = "stopped"
this.state.track_manifest = null
this.track_instance = null
this.queue.currentItem = null
this.track_next_instances = []
this.track_prev_instances = []
this.native_controls.destroy()
this.nativeControls.destroy()
}
//
@ -383,7 +323,7 @@ export default class Player extends Core {
if (typeof to === "boolean") {
this.state.muted = to
this.track_instance.audio.muted = to
this.queue.currentItem.audio.muted = to
}
return this.state.muted
@ -413,9 +353,9 @@ export default class Player extends Core {
AudioPlayerStorage.set("volume", volume)
if (this.track_instance) {
if (this.track_instance.gainNode) {
this.track_instance.gainNode.gain.value = this.state.volume
if (this.queue.currentItem) {
if (this.queue.currentItem.gainNode) {
this.queue.currentItem.gainNode.gain.value = this.state.volume
}
}
@ -423,31 +363,31 @@ export default class Player extends Core {
}
seek(time) {
if (!this.track_instance || !this.track_instance.audio) {
if (!this.queue.currentItem || !this.queue.currentItem.audio) {
return false
}
// if time not provided, return current time
if (typeof time === "undefined") {
return this.track_instance.audio.currentTime
return this.queue.currentItem.audio.currentTime
}
// if time is provided, seek to that time
if (typeof time === "number") {
this.console.log(`Seeking to ${time} | Duration: ${this.track_instance.audio.duration}`)
this.console.log(`Seeking to ${time} | Duration: ${this.queue.currentItem.audio.duration}`)
this.track_instance.audio.currentTime = time
this.queue.currentItem.audio.currentTime = time
return time
}
}
duration() {
if (!this.track_instance || !this.track_instance.audio) {
if (!this.queue.currentItem || !this.queue.currentItem.audio) {
return false
}
return this.track_instance.audio.duration
return this.queue.currentItem.audio.duration
}
loop(to) {
@ -458,8 +398,8 @@ export default class Player extends Core {
this.state.loop = to ?? !this.state.loop
if (this.track_instance.audio) {
this.track_instance.audio.loop = this.state.loop
if (this.queue.currentItem.audio) {
this.queue.currentItem.audio.loop = this.state.loop
}
return this.state.loop

View File

@ -1,4 +1,3 @@
import { Modal } from "antd"
import ProcessorNode from "../node"
import Presets from "../../classes/Presets"
@ -6,7 +5,7 @@ export default class CompressorProcessorNode extends ProcessorNode {
constructor(props) {
super(props)
this.presets_controller = new Presets({
this.presets = new Presets({
storage_key: "compressor",
defaultPresetValue: {
threshold: -50,
@ -15,98 +14,19 @@ export default class CompressorProcessorNode extends ProcessorNode {
attack: 0.003,
release: 0.25,
},
onApplyValues: this.applyValues.bind(this),
})
this.state = {
compressorValues: this.presets_controller.currentPresetValues,
}
this.exposeToPublic = {
presets: new Proxy(this.presets_controller, {
get: function (target, key) {
if (!key) {
return target
}
return target[key]
}
}),
deletePreset: this.deletePreset.bind(this),
createPreset: this.createPreset.bind(this),
changePreset: this.changePreset.bind(this),
resetDefaultValues: this.resetDefaultValues.bind(this),
modifyValues: this.modifyValues.bind(this),
detach: this._detach.bind(this),
attach: this._attach.bind(this),
values: this.state.compressorValues,
presets: this.presets,
detach: this._detach,
attach: this._attach,
}
}
static refName = "compressor"
static dependsOnSettings = ["player.compressor"]
deletePreset(key) {
this.changePreset("default")
this.presets_controller.deletePreset(key)
return this.presets_controller.presets
}
createPreset(key, values) {
this.state = {
...this.state,
compressorValues: this.presets_controller.createPreset(key, values),
}
this.presets_controller.changePreset(key)
return this.presets_controller.presets
}
changePreset(key) {
const values = this.presets_controller.changePreset(key)
this.state = {
...this.state,
compressorValues: values,
}
this.applyValues()
return values
}
modifyValues(values) {
values = this.presets_controller.setToCurrent(values)
this.state.compressorValues = {
...this.state.compressorValues,
...values,
}
this.applyValues()
return this.state.compressorValues
}
async resetDefaultValues() {
return await new Promise((resolve) => {
Modal.confirm({
title: "Reset to default values?",
content: "Are you sure you want to reset to default values?",
onOk: () => {
this.modifyValues(this.presets_controller.defaultPresetValue)
resolve(this.state.compressorValues)
},
onCancel: () => {
resolve(this.state.compressorValues)
}
})
})
}
async init(AudioContext) {
if (!AudioContext) {
throw new Error("AudioContext is required")
@ -118,8 +38,8 @@ export default class CompressorProcessorNode extends ProcessorNode {
}
applyValues() {
Object.keys(this.state.compressorValues).forEach((key) => {
this.processor[key].value = this.state.compressorValues[key]
Object.keys(this.presets.currentPresetValues).forEach((key) => {
this.processor[key].value = this.presets.currentPresetValues[key]
})
}
}

View File

@ -1,4 +1,3 @@
import { Modal } from "antd"
import ProcessorNode from "../node"
import Presets from "../../classes/Presets"
@ -6,7 +5,7 @@ export default class EqProcessorNode extends ProcessorNode {
constructor(props) {
super(props)
this.presets_controller = new Presets({
this.presets = new Presets({
storage_key: "eq",
defaultPresetValue: {
32: 0,
@ -20,94 +19,21 @@ export default class EqProcessorNode extends ProcessorNode {
8000: 0,
16000: 0,
},
onApplyValues: this.applyValues.bind(this),
})
this.state = {
eqValues: this.presets_controller.currentPresetValues,
}
this.exposeToPublic = {
presets: new Proxy(this.presets_controller, {
get: function (target, key) {
if (!key) {
return target
}
return target[key]
},
}),
deletePreset: this.deletePreset.bind(this),
createPreset: this.createPreset.bind(this),
changePreset: this.changePreset.bind(this),
modifyValues: this.modifyValues.bind(this),
resetDefaultValues: this.resetDefaultValues.bind(this),
presets: this.presets,
}
}
static refName = "eq"
static lock = true
deletePreset(key) {
this.changePreset("default")
this.presets_controller.deletePreset(key)
return this.presets_controller.presets
}
createPreset(key, values) {
this.state = {
...this.state,
eqValues: this.presets_controller.createPreset(key, values),
}
this.presets_controller.changePreset(key)
return this.presets_controller.presets
}
changePreset(key) {
const values = this.presets_controller.changePreset(key)
this.state = {
...this.state,
eqValues: values,
}
this.applyValues()
return values
}
modifyValues(values) {
values = this.presets_controller.setToCurrent(values)
this.state = {
...this.state,
eqValues: values,
}
this.applyValues()
return values
}
resetDefaultValues() {
Modal.confirm({
title: "Reset to default values?",
content: "Are you sure you want to reset to default values?",
onOk: () => {
this.modifyValues(this.presets_controller.defaultPresetValue)
}
})
return this.state.eqValues
}
applyValues() {
// apply to current instance
this.processor.eqNodes.forEach((processor) => {
const gainValue = this.state.eqValues[processor.frequency.value]
const gainValue = this.presets.currentPresetValues[processor.frequency.value]
if (processor.gain.value !== gainValue) {
console.debug(`[EQ] Applying values to ${processor.frequency.value} Hz frequency with gain ${gainValue}`)
@ -127,7 +53,7 @@ export default class EqProcessorNode extends ProcessorNode {
this.processor.eqNodes = []
const values = Object.entries(this.state.eqValues).map((entry) => {
const values = Object.entries(this.presets.currentPresetValues).map((entry) => {
return {
freq: parseFloat(entry[0]),
gain: parseFloat(entry[1]),

View File

@ -8,7 +8,23 @@ export default class RemoteStorage extends Core {
static depends = ["api", "tasksQueue"]
public = {
uploadFile: this.uploadFile.bind(this),
uploadFile: this.uploadFile,
getFileHash: this.getFileHash,
binaryArrayToFile: this.binaryArrayToFile,
}
binaryArrayToFile(bin, filename) {
const { format, data } = bin
const filenameExt = format.split("/")[1]
filename = `${filename}.${filenameExt}`
const byteArray = new Uint8Array(data)
const blob = new Blob([byteArray], { type: data.type })
return new File([blob], filename, {
type: format,
})
}
async getFileHash(file) {

View File

@ -3,7 +3,9 @@ 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)
app.layout.tools_bar.attachRender(widget.id, widget.component, widget.props, {
position: "top",
})
}
return () => {

View File

@ -1,8 +1,6 @@
import React from "react"
import classnames from "classnames"
import { Motion, spring } from "react-motion"
import { Translation } from "react-i18next"
import { Icons } from "@components/Icons"
import WidgetsWrapper from "@components/WidgetsWrapper"
@ -11,7 +9,10 @@ import "./index.less"
export default class ToolsBar extends React.Component {
state = {
visible: false,
renders: [],
renders: {
top: [],
bottom: [],
},
}
componentDidMount() {
@ -34,20 +35,25 @@ export default class ToolsBar extends React.Component {
visible: to ?? !this.state.visible,
})
},
attachRender: (id, component, props) => {
this.setState({
renders: [...this.state.renders, {
attachRender: (id, component, props, { position = "bottom" } = {}) => {
this.setState((prev) => {
prev.renders[position].push({
id: id,
component: component,
props: props,
}],
})
return prev
})
return component
},
detachRender: (id) => {
this.setState({
renders: this.state.renders.filter((render) => render.id !== id),
renders: {
top: this.state.renders.top.filter((render) => render.id !== id),
bottom: this.state.renders.bottom.filter((render) => render.id !== id),
},
})
return true
@ -78,15 +84,29 @@ export default class ToolsBar extends React.Component {
id="tools_bar"
className="tools-bar"
>
<div className="attached_renders">
<div className="attached_renders top">
{
this.state.renders.map((render) => {
return React.createElement(render.component, render.props)
this.state.renders.top.map((render, index) => {
return React.createElement(render.component, {
...render.props,
key: index,
})
})
}
</div>
<WidgetsWrapper />
<div className="attached_renders bottom">
{
this.state.renders.bottom.map((render, index) => {
return React.createElement(render.component, {
...render.props,
key: index,
})
})
}
</div>
</div>
</div>
}}

View File

@ -7,7 +7,7 @@
right: 0;
z-index: 150;
height: 100vh;
height: 100dvh;
max-width: 420px;
@ -26,6 +26,8 @@
}
.tools-bar {
position: relative;
display: flex;
flex-direction: column;
@ -46,14 +48,32 @@
flex: 0;
.attached_renders {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
height: fit-content;
gap: 10px;
&.bottom {
position: absolute;
bottom: 0;
left: 0;
padding: 10px;
}
.card {
width: 100%;
height: fit-content;
background-color: var(--background-color-primary);
}
}
}

View File

@ -29,7 +29,7 @@ const ServiceIndicator = (props) => {
}
const AudioPlayer = (props) => {
const playerState = usePlayerStateContext()
const [playerState] = usePlayerStateContext()
React.useEffect(() => {
if (app.currentDragger) {

View File

@ -0,0 +1,115 @@
import React, { useState } from "react"
import { Button, Card, List, Typography, Space, Divider, notification } from "antd"
import QueueManager from "@cores/player/classes/QueueManager"
const { Title, Text } = Typography
const QueueDebugger = () => {
const queueManager = React.useRef(new QueueManager())
const [current, setCurrent] = useState(queueManager.current.currentItem)
const [prevItems, setPrevItems] = useState([...queueManager.current.prevItems])
const [nextItems, setNextItems] = useState([...queueManager.current.nextItems])
const updateQueueState = () => {
setCurrent(queueManager.current.currentItem)
setPrevItems([...queueManager.current.prevItems])
setNextItems([...queueManager.current.nextItems])
}
const handleNext = (random = false) => {
queueManager.current.next(random)
updateQueueState()
}
const handlePrevious = () => {
queueManager.current.previous()
updateQueueState()
}
const handleSet = (item) => {
try {
queueManager.current.set(item)
updateQueueState()
} catch (error) {
notification.error({
message: "Error",
description: error.message,
placement: "bottomRight",
})
}
}
const handleAdd = () => {
const newItem = {
id: (nextItems.length + prevItems.length + 2).toString(),
name: `Item ${nextItems.length + prevItems.length + 2}`
}
queueManager.current.add(newItem)
updateQueueState()
}
const handleRemove = (item) => {
queueManager.current.remove(item)
updateQueueState()
}
React.useEffect(() => {
queueManager.current.add({ id: "1", name: "Item 1" })
queueManager.current.add({ id: "2", name: "Item 2" })
queueManager.current.add({ id: "3", name: "Item 3" })
queueManager.current.add({ id: "4", name: "Item 4" })
updateQueueState()
}, [])
return (
<Space direction="vertical" size="large" style={{ width: "100%", padding: "20px" }}>
<Title level={2}>Queue Debugger</Title>
<Card title="Current Item">
<Text>{current ? current.name : "None"}</Text>
</Card>
<Divider />
<Card title="Previous Items">
<List
bordered
dataSource={prevItems}
renderItem={(item) => (
<List.Item
actions={[
<Button type="link" onClick={() => handleSet(item)}>Set</Button>,
]}
>
{item.name}
</List.Item>
)}
/>
</Card>
<Card title="Next Items">
<List
bordered
dataSource={nextItems}
renderItem={(item) => (
<List.Item
actions={[
<Button type="link" onClick={() => handleSet(item)}>Set</Button>,
<Button type="link" danger onClick={() => handleRemove(item)}>Remove</Button>,
]}
>
{item.name}
</List.Item>
)}
/>
</Card>
<Divider />
<Space>
<Button onClick={handlePrevious}>Previous</Button>
<Button onClick={() => handleNext(false)}>Next</Button>
<Button onClick={() => handleNext(true)}>Next (Random)</Button>
<Button type="primary" onClick={handleAdd}>Add Item</Button>
</Space>
</Space>
)
}
export default QueueDebugger

View File

@ -47,7 +47,7 @@ const RenderAlbum = (props) => {
}
const PlayerController = React.forwardRef((props, ref) => {
const playerState = usePlayerStateContext()
const [playerState] = usePlayerStateContext()
const titleRef = React.useRef()

View File

@ -5,7 +5,7 @@ import { Motion, spring } from "react-motion"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
const LyricsText = React.forwardRef((props, textRef) => {
const playerState = usePlayerStateContext()
const [playerState] = usePlayerStateContext()
const { lyrics } = props
@ -55,7 +55,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
setVisible(false)
} else {
setVisible(true)
console.log(`Scrolling to line ${currentLineIndex}`)
// find line element by id
const lineElement = textRef.current.querySelector(`#lyrics-line-${currentLineIndex}`)

View File

@ -7,7 +7,7 @@ import { usePlayerStateContext } from "@contexts/WithPlayerContext"
const maxLatencyInMs = 55
const LyricsVideo = React.forwardRef((props, videoRef) => {
const playerState = usePlayerStateContext()
const [playerState] = usePlayerStateContext()
const { lyrics } = props
@ -57,12 +57,10 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
setCurrentVideoLatency(currentVideoTimeDiff)
if (syncingVideo === true) {
console.log(`Syncing video...`)
return false
}
if (currentVideoTimeDiff > maxOffset) {
console.warn(`Video offset exceeds`, maxOffset)
seekVideoToSyncAudio()
}
}

View File

@ -22,8 +22,8 @@ function getDominantColorStr(track_manifest) {
return `${values[0]}, ${values[1]}, ${values[2]}`
}
const EnchancedLyrics = (props) => {
const playerState = usePlayerStateContext()
const EnchancedLyricsPage = () => {
const [playerState] = usePlayerStateContext()
const [initialized, setInitialized] = React.useState(false)
const [lyrics, setLyrics] = React.useState(null)
@ -71,11 +71,6 @@ const EnchancedLyrics = (props) => {
}
}, [playerState.track_manifest])
//* Handle when lyrics data change
React.useEffect(() => {
console.log(lyrics)
}, [lyrics])
React.useEffect(() => {
setInitialized(true)
}, [])
@ -129,4 +124,4 @@ const EnchancedLyrics = (props) => {
</div>
}
export default EnchancedLyrics
export default EnchancedLyricsPage

View File

@ -0,0 +1,49 @@
import React from "react"
import Image from "@components/Image"
import MusicModel from "@models/music"
const FeaturedPlaylist = (props) => {
const [featuredPlaylist, setFeaturedPlaylist] = React.useState(false)
const onClick = () => {
if (!featuredPlaylist) {
return
}
app.navigation.goToPlaylist(featuredPlaylist.playlist_id)
}
React.useEffect(() => {
MusicModel.getFeaturedPlaylists().then((data) => {
if (data[0]) {
console.log(`Loaded featured playlist >`, data[0])
setFeaturedPlaylist(data[0])
}
})
}, [])
if (!featuredPlaylist) {
return null
}
return <div className="featured_playlist" onClick={onClick}>
<Image
src={featuredPlaylist.cover_url}
/>
<div className="featured_playlist_content">
<h1>{featuredPlaylist.title}</h1>
<p>{featuredPlaylist.description}</p>
{
featuredPlaylist.genre && <div className="featured_playlist_genre">
<span>{featuredPlaylist.genre}</span>
</div>
}
</div>
</div>
}
export default FeaturedPlaylist

View File

@ -0,0 +1,18 @@
import React from "react"
import Searcher from "@components/Searcher"
import MusicModel from "@models/music"
const MusicNavbar = (props) => {
return <div className="music_navbar">
<Searcher
useUrlQuery
renderResults={false}
model={MusicModel.search}
onSearchResult={props.setSearchResults}
onEmpty={() => props.setSearchResults(false)}
/>
</div>
}
export default MusicNavbar

View File

@ -0,0 +1,14 @@
import React from "react"
import { Icons } from "@components/Icons"
import "./index.less"
const RecentlyPlayedList = (props) => {
return <div className="recently_played">
<div className="recently_played-header">
<h1>Recently played</h1>
</div>
</div>
}
export default RecentlyPlayedList

View File

@ -0,0 +1,17 @@
.recently_played {
display: flex;
flex-direction: column;
gap: 10px;
.recently_played-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
font-size: 1rem;
}
}

View File

@ -0,0 +1,103 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { Translation } from "react-i18next"
import { createIconRender } from "@components/Icons"
import MusicTrack from "@components/Music/Track"
import PlaylistItem from "@components/Music/PlaylistItem"
const ResultGroupsDecorators = {
"playlists": {
icon: "MdPlaylistPlay",
label: "Playlists",
renderItem: (props) => {
return <PlaylistItem
key={props.key}
playlist={props.item}
/>
}
},
"tracks": {
icon: "MdMusicNote",
label: "Tracks",
renderItem: (props) => {
return <MusicTrack
key={props.key}
track={props.item}
onClickPlayBtn={() => app.cores.player.start(props.item)}
onClick={() => app.location.push(`/play/${props.item._id}`)}
/>
}
}
}
const SearchResults = ({
data
}) => {
if (typeof data !== "object") {
return null
}
let groupsKeys = Object.keys(data)
// filter out empty groups
groupsKeys = groupsKeys.filter((key) => {
return data[key].length > 0
})
if (groupsKeys.length === 0) {
return <div className="music-explorer_search_results no_results">
<antd.Result
status="info"
title="No results"
subTitle="We are sorry, but we could not find any results for your search."
/>
</div>
}
return <div
className={classnames(
"music-explorer_search_results",
{
["one_column"]: groupsKeys.length === 1,
}
)}
>
{
groupsKeys.map((key, index) => {
const decorator = ResultGroupsDecorators[key] ?? {
icon: null,
label: key,
renderItem: () => null
}
return <div className="music-explorer_search_results_group" key={index}>
<div className="music-explorer_search_results_group_header">
<h1>
{
createIconRender(decorator.icon)
}
<Translation>
{(t) => t(decorator.label)}
</Translation>
</h1>
</div>
<div className="music-explorer_search_results_group_list">
{
data[key].map((item, index) => {
return decorator.renderItem({
key: index,
item
})
})
}
</div>
</div>
})
}
</div>
}
export default SearchResults

View File

@ -1,175 +1,27 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { Translation } from "react-i18next"
import Image from "@components/Image"
import Searcher from "@components/Searcher"
import { Icons, createIconRender } from "@components/Icons"
import MusicTrack from "@components/Music/Track"
import PlaylistItem from "@components/Music/PlaylistItem"
import ReleasesList from "@components/ReleasesList"
import { Icons } from "@components/Icons"
import FeedModel from "@models/feed"
import MusicModel from "@models/music"
import Navbar from "./components/Navbar"
import RecentlyPlayedList from "./components/RecentlyPlayedList"
import SearchResults from "./components/SearchResults"
import ReleasesList from "./components/ReleasesList"
import FeaturedPlaylist from "./components/FeaturedPlaylist"
import "./index.less"
const FeaturedPlaylist = (props) => {
const [featuredPlaylist, setFeaturedPlaylist] = React.useState(false)
const onClick = () => {
if (!featuredPlaylist) {
return
}
app.navigation.goToPlaylist(featuredPlaylist.playlist_id)
}
React.useEffect(() => {
MusicModel.getFeaturedPlaylists().then((data) => {
if (data[0]) {
console.log(`Loaded featured playlist >`, data[0])
setFeaturedPlaylist(data[0])
}
})
}, [])
if (!featuredPlaylist) {
return null
}
return <div className="featured_playlist" onClick={onClick}>
<Image
src={featuredPlaylist.cover_url}
/>
<div className="featured_playlist_content">
<h1>{featuredPlaylist.title}</h1>
<p>{featuredPlaylist.description}</p>
{
featuredPlaylist.genre && <div className="featured_playlist_genre">
<span>{featuredPlaylist.genre}</span>
</div>
}
</div>
</div>
}
const MusicNavbar = (props) => {
return <div className="music_navbar">
<Searcher
useUrlQuery
renderResults={false}
model={MusicModel.search}
onSearchResult={props.setSearchResults}
onEmpty={() => props.setSearchResults(false)}
/>
</div>
}
const ResultGroupsDecorators = {
"playlists": {
icon: "MdPlaylistPlay",
label: "Playlists",
renderItem: (props) => {
return <PlaylistItem
key={props.key}
playlist={props.item}
/>
}
},
"tracks": {
icon: "MdMusicNote",
label: "Tracks",
renderItem: (props) => {
return <MusicTrack
key={props.key}
track={props.item}
onClickPlayBtn={() => app.cores.player.start(props.item)}
onClick={() => app.location.push(`/play/${props.item._id}`)}
/>
}
}
}
const SearchResults = ({
data
}) => {
if (typeof data !== "object") {
return null
}
let groupsKeys = Object.keys(data)
// filter out empty groups
groupsKeys = groupsKeys.filter((key) => {
return data[key].length > 0
})
if (groupsKeys.length === 0) {
return <div className="music-explorer_search_results no_results">
<antd.Result
status="info"
title="No results"
subTitle="We are sorry, but we could not find any results for your search."
/>
</div>
}
return <div
className={classnames(
"music-explorer_search_results",
{
["one_column"]: groupsKeys.length === 1,
}
)}
>
{
groupsKeys.map((key, index) => {
const decorator = ResultGroupsDecorators[key] ?? {
icon: null,
label: key,
renderItem: () => null
}
return <div className="music-explorer_search_results_group" key={index}>
<div className="music-explorer_search_results_group_header">
<h1>
{
createIconRender(decorator.icon)
}
<Translation>
{(t) => t(decorator.label)}
</Translation>
</h1>
</div>
<div className="music-explorer_search_results_group_list">
{
data[key].map((item, index) => {
return decorator.renderItem({
key: index,
item
})
})
}
</div>
</div>
})
}
</div>
}
export default (props) => {
const MusicExploreTab = (props) => {
const [searchResults, setSearchResults] = React.useState(false)
React.useEffect(() => {
app.layout.toggleCenteredContent(true)
app.layout.page_panels.attachComponent("music_navbar", MusicNavbar, {
app.layout.page_panels.attachComponent("music_navbar", Navbar, {
props: {
setSearchResults: setSearchResults,
}
@ -192,9 +44,6 @@ export default (props) => {
useUrlQuery
renderResults={false}
model={MusicModel.search}
modelParams={{
useTidal: app.cores.sync.getActiveLinkedServices().tidal,
}}
onSearchResult={setSearchResults}
onEmpty={() => setSearchResults(false)}
/>
@ -210,6 +59,8 @@ export default (props) => {
!searchResults && <div className="feed_main">
<FeaturedPlaylist />
<RecentlyPlayedList />
<ReleasesList
headerTitle="From your following artists"
headerIcon={<Icons.MdPerson />}
@ -224,4 +75,6 @@ export default (props) => {
</div>
}
</div>
}
}
export default MusicExploreTab

View File

@ -49,47 +49,62 @@ export default class FavoriteTracks extends React.Component {
loading: true,
})
const result = await MusicModel.getFavoriteTracks({
useTidal: app.cores.sync.getActiveLinkedServices().tidal,
const result = await MusicModel.getFavouriteFolder({
offset: offset,
limit: limit,
}).catch((err) => {
}).catch((error) => {
this.setState({
error: err.message,
error: error.message,
})
return false
return null
})
console.log("Loaded favorites => ", result)
if (result) {
const { tracks, total_length } = result
const {
tracks,
releases,
playlists,
total_length,
} = result
this.setState({
total_length
})
const data = [
...tracks.list,
...releases.list,
...playlists.list,
]
if (tracks.length === 0) {
if (offset === 0) {
this.setState({
empty: true,
})
}
return this.setState({
if (total_length === 0) {
this.setState({
empty: true,
hasMore: false,
initialLoading: false,
})
}
if (data.length === 0) {
return this.setState({
empty: false,
hasMore: false,
initialLoading: false,
})
}
if (replace) {
this.setState({
list: tracks,
list: data,
})
} else {
this.setState({
list: [...this.state.list, ...tracks],
list: [...this.state.list, ...data],
})
}
this.setState({
total_length
})
}
this.setState({
@ -112,7 +127,7 @@ export default class FavoriteTracks extends React.Component {
}
return <PlaylistView
favorite
favorite
type="vertical"
playlist={{
title: "Your favorites",
@ -122,7 +137,6 @@ export default class FavoriteTracks extends React.Component {
centered={app.isMobile}
onLoadMore={this.onLoadMore}
hasMore={this.state.hasMore}
empty={this.state.empty}
length={this.state.total_length}
/>
}

View File

@ -2,23 +2,12 @@ import React from "react"
import { Translation } from "react-i18next"
import { PagePanelWithNavMenu } from "@components/PagePanels"
import TrendingsCard from "@components/TrendingsCard"
import usePageWidgets from "@hooks/usePageWidgets"
import Tabs from "./tabs"
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([
{

View File

@ -3,7 +3,7 @@ import * as antd from "antd"
import { Icons } from "@components/Icons"
import UserModel from "@models/user"
import AuthModel from "@models/auth"
import "./index.less"
@ -32,7 +32,7 @@ const ChangePasswordComponent = (props) => {
setError(null)
setLoading(true)
const result = await UserModel.changePassword({ currentPassword, newPassword }).catch((err) => {
const result = await AuthModel.changePassword({ currentPassword, newPassword }).catch((err) => {
console.error(err)
setError(err.response.data.message)
return null

View File

@ -6,14 +6,23 @@ import { Icons } from "@components/Icons"
import Sliders from "../sliderValues"
export default (props) => {
const [selectedPreset, setSelectedPreset] = React.useState(props.controller.presets.currentPresetKey)
const [presets, setPresets] = React.useState(props.controller.presets.presets ?? {})
const [selectedPreset, setSelectedPreset] = React.useState(props.controller.currentPresetKey)
const [presets, setPresets] = React.useState(props.controller.presets ?? {})
const createPreset = (key) => {
setPresets(props.controller.createPreset(key))
const presets = props.controller.createPreset(key)
setPresets(presets)
setSelectedPreset(key)
}
const deletePreset = (key) => {
const presets = props.controller.deletePreset(key)
setPresets(presets)
setSelectedPreset(props.controller.currentPresetKey)
}
const handleCreateNewPreset = () => {
app.layout.modal.open("create_preset", (props) => {
const [presetKey, setPresetKey] = React.useState("")
@ -51,15 +60,11 @@ export default (props) => {
})
}
const handleDeletePreset = () => {
const handleDeleteCurrentPreset = () => {
Modal.confirm({
title: "Delete preset",
content: "Are you sure you want to delete this preset?",
onOk: () => {
props.controller.deletePreset(selectedPreset)
setPresets(props.controller.presets.presets ?? {})
setSelectedPreset(props.controller.presets.currentPresetKey)
}
onOk: () => deletePreset(selectedPreset)
})
}
@ -77,14 +82,13 @@ export default (props) => {
]
React.useEffect(() => {
const presets = props.controller.presets.presets ?? {}
const preset = presets[selectedPreset]
const presetValues = props.controller.presets[selectedPreset]
if (props.controller.presets.currentPresetKey !== selectedPreset) {
if (props.controller.currentPresetKey !== selectedPreset) {
props.controller.changePreset(selectedPreset)
}
props.ctx.updateCurrentValue(preset)
props.ctx.updateCurrentValue(presetValues)
}, [selectedPreset])
return <>
@ -152,7 +156,7 @@ export default (props) => {
}}
/>
<Button
onClick={handleDeletePreset}
onClick={handleDeleteCurrentPreset}
icon={<Icons.MdDelete />}
disabled={selectedPreset === "default"}
/>

View File

@ -64,7 +64,7 @@ export default {
return app.cores.player.audioContext.sampleRate
},
onUpdate: async (value) => {
const sampleRate = await app.cores.player.setSampleRate(value)
const sampleRate = await app.cores.player.controls.setSampleRate(value)
return sampleRate
},
@ -93,6 +93,18 @@ export default {
app.cores.player.compressor.detach()
}
},
extraActions: [
{
id: "reset",
title: "Default",
icon: "MdRefresh",
onClick: async (ctx) => {
const values = await app.cores.player.compressor.presets.setCurrentPresetToDefault()
ctx.updateCurrentValue(values)
}
}
],
props: {
valueFormat: (value) => `${value}dB`,
sliders: [
@ -131,20 +143,8 @@ export default {
},
],
},
extraActions: [
{
id: "reset",
title: "Default",
icon: "MdRefresh",
onClick: async (ctx) => {
const values = await app.cores.player.compressor.resetDefaultValues()
ctx.updateCurrentValue(values)
}
}
],
onUpdate: (value) => {
app.cores.player.compressor.modifyValues(value)
app.cores.player.compressor.presets.setToCurrent(value)
return value
},
@ -164,15 +164,15 @@ export default {
title: "Reset",
icon: "MdRefresh",
onClick: (ctx) => {
const values = app.cores.player.eq.resetDefaultValues()
const values = app.cores.player.eq.presets.setCurrentPresetToDefault()
ctx.updateCurrentValue(values)
}
},
],
dependsOn: {
"player.equalizer": true
},
// dependsOn: {
// "player.equalizer": true
// },
props: {
valueFormat: (value) => `${value}dB`,
marks: [
@ -251,7 +251,7 @@ export default {
return acc
}, {})
app.cores.player.eq.modifyValues(values)
app.cores.player.eq.presets.setToCurrent(values)
return value
},

View File

@ -4,7 +4,7 @@ import SlidersWithPresets from "../../../components/slidersWithPresets"
export default (props) => {
return <SlidersWithPresets
{...props}
controller={app.cores.player.compressor}
controller={app.cores.player.compressor.presets}
extraHeaderItems={[
<Switch
onChange={props.onEnabledChange}

View File

@ -3,6 +3,6 @@ import SlidersWithPresets from "../../../components/slidersWithPresets"
export default (props) => {
return <SlidersWithPresets
{...props}
controller={app.cores.player.eq}
controller={app.cores.player.eq.presets}
/>
}

View File

@ -26,6 +26,18 @@ export default defineConfig({
headers: {
"Strict-Transport-Security": `max-age=${oneYearInSeconds}`
},
proxy: {
"/api": {
target: "https://0.0.0.0:9000",
rewrite: (path) => path.replace(/^\/api/, ""),
hostRewrite: true,
changeOrigin: true,
xfwd: true,
//ws: true,
toProxy: true,
secure: false,
}
}
},
css: {
preprocessorOptions: {
@ -42,7 +54,7 @@ export default defineConfig({
build: {
target: "esnext",
rollupOptions: {
output:{
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
return id.toString().split('node_modules/')[1].split('/')[0].toString();

View File

@ -160,13 +160,11 @@ export default class Proxy {
}
if (sanitizedUrl === "/") {
return res.end(`
{
"name": "${pkg.name}",
"version": "${pkg.version}",
"lb_version": "${defaults.version}"
}
`)
return res.end(JSON.stringify({
name: pkg.name,
version: pkg.version,
lb_version: defaults.version
}))
}
const namespace = `/${sanitizedUrl.split("/")[1]}`

View File

@ -2,4 +2,6 @@ export default class Track {
static create = require("./methods/create").default
static delete = require("./methods/delete").default
static get = require("./methods/get").default
static toggleFavourite = require("./methods/toggleFavourite").default
static isFavourite = require("./methods/isFavourite").default
}

View File

@ -1,32 +1,66 @@
import { Track } from "@db_models"
import { Track, TrackLike } from "@db_models"
export default async (track_id, { limit = 50, offset = 0 } = {}) => {
export default async (track_id, { user_id = null, onlyList = false } = {}) => {
if (!track_id) {
throw new OperationError(400, "Missing track_id")
}
const isMultiple = track_id.includes(",")
const isMultiple = Array.isArray(track_id) || track_id.includes(",")
if (isMultiple) {
const track_ids = track_id.split(",")
const track_ids = Array.isArray(track_id) ? track_id : track_id.split(",")
const tracks = await Track.find({ _id: { $in: track_ids } })
.limit(limit)
.skip(offset)
const tracks = await Track.find({
_id: { $in: track_ids }
}).lean()
if (user_id) {
const trackLikes = await TrackLike.find({
user_id: user_id,
track_id: { $in: track_ids }
})
// FIXME: this could be a performance issue when there are a lot of likes
// Array.find may not be a good idea
for (const trackLike of trackLikes) {
const track = tracks.find(track => track._id.toString() === trackLike.track_id.toString())
if (track) {
track.liked_at = trackLike.created_at
track.liked = true
}
}
}
if (onlyList) {
return tracks
}
return {
total_count: await Track.countDocuments({ _id: { $in: track_ids } }),
list: tracks.map(track => track.toObject()),
list: tracks,
}
}
const track = await Track.findOne({
_id: track_id
})
}).lean()
if (!track) {
throw new OperationError(404, "Track not found")
}
if (user_id) {
const trackLike = await TrackLike.findOne({
user_id: user_id,
track_id: track_id,
})
if (trackLike) {
track.liked_at = trackLike.created_at
track.liked = true
}
}
return track
}

View File

@ -0,0 +1,26 @@
import { Track, TrackLike } from "@db_models"
export default async (user_id, track_id, to) => {
if (!user_id) {
throw new OperationError(400, "Missing user_id")
}
if (!track_id) {
throw new OperationError(400, "Missing track_id")
}
const track = await Track.findById(track_id)
if (!track) {
throw new OperationError(404, "Track not found")
}
let trackLike = await TrackLike.findOne({
user_id: user_id,
track_id: track_id,
}).catch(() => null)
return {
liked: !!trackLike
}
}

View File

@ -0,0 +1,64 @@
import { Track, TrackLike } from "@db_models"
export default async (user_id, track_id, to) => {
if (!user_id) {
throw new OperationError(400, "Missing user_id")
}
if (!track_id) {
throw new OperationError(400, "Missing track_id")
}
const track = await Track.findById(track_id)
if (!track) {
throw new OperationError(404, "Track not found")
}
let trackLike = await TrackLike.findOne({
user_id: user_id,
track_id: track_id,
}).catch(() => null)
if (typeof to === "undefined") {
to = !!!trackLike
}
if (to) {
if (!trackLike) {
trackLike = new TrackLike({
user_id: user_id,
track_id: track_id,
created_at: Date.now(),
})
await trackLike.save()
}
} else {
if (trackLike) {
await TrackLike.deleteOne({
user_id: user_id,
track_id: track_id,
})
trackLike = null
}
}
console.log(global.websocket.find)
const targetSocket = await global.websocket.find.socketByUserId(user_id)
if (targetSocket) {
await targetSocket.emit("music:track:toggle:like", {
track_id: track_id,
action: trackLike ? "liked" : "unliked"
})
}
return {
liked: trackLike ? true : false,
track_like_id: trackLike ? trackLike._id : null,
track_id: track._id.toString(),
}
}

View File

@ -7,6 +7,7 @@ import LimitsClass from "@shared-classes/Limits"
export default class API extends Server {
static refName = "music"
static enableWebsockets = true
static routesPath = `${__dirname}/routes`
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3003

View File

@ -61,6 +61,10 @@ export default async (req) => {
if (typeof trackLyrics.lrc === "object") {
trackLyrics.translated_lang = translate_lang
if (!trackLyrics.lrc[translate_lang]) {
translate_lang = "original"
}
if (trackLyrics.lrc[translate_lang]) {
trackLyrics.synced_lyrics = await remoteLcrToSyncedLyrics(trackLyrics.lrc[translate_lang])
}

View File

@ -0,0 +1,73 @@
import {
TrackLike,
} from "@db_models"
import TrackClass from "@classes/track"
//
// A endpoint to fetch track & playlists & releases likes
//
export default {
middlewares: ["withAuthentication"],
fn: async (req) => {
const user_id = req.auth.session.user_id
const { limit, offset } = req.query
const [
totalTrackLikes,
totalReleasesLikes,
totalPlaylistsLikes,
] = await Promise.all([
TrackLike.countDocuments({ user_id }),
0,
0,
])
let [
trackLikes,
releasesLikes,
playlistsLikes
] = await Promise.all([
TrackLike.find({
user_id
})
.limit(limit)
.skip(offset),
[],
[],
])
let [
Tracks,
Releases,
Playlists,
] = await Promise.all([
TrackClass.get(trackLikes.map(trackLike => trackLike.track_id), {
user_id,
onlyList: true,
}),
[],
[],
])
Tracks = Tracks.sort((a, b) => b.liked_at - a.liked_at)
// Releases = Releases.sort((a, b) => b.liked_at - a.liked_at)
// Playlists = Playlists.sort((a, b) => b.liked_at - a.liked_at)
return {
tracks: {
list: Tracks,
total_items: totalTrackLikes,
},
releases: {
list: Releases,
total_items: totalReleasesLikes,
},
playlists: {
list: Playlists,
total_items: totalPlaylistsLikes,
},
total_length: totalTrackLikes + totalReleasesLikes + totalPlaylistsLikes,
}
}
}

View File

@ -1,30 +1,34 @@
import { MusicRelease, Track } from "@db_models"
import TrackClass from "@classes/track"
export default async (req) => {
const { release_id } = req.params
const { limit = 50, offset = 0 } = req.query
export default {
middlewares: ["withOptionalAuthentication"],
fn: async (req) => {
const { release_id } = req.params
const { limit = 50, offset = 0 } = req.query
let release = await MusicRelease.findOne({
_id: release_id
})
let release = await MusicRelease.findOne({
_id: release_id
})
if (!release) {
throw new OperationError(404, "Release not found")
if (!release) {
throw new OperationError(404, "Release not found")
}
release = release.toObject()
const totalTracks = await Track.countDocuments({
_id: release.list
})
const tracks = await TrackClass.get(release.list, {
user_id: req.auth?.session?.user_id,
onlyList: true
})
release.listLength = totalTracks
release.list = tracks
return release
}
release = release.toObject()
const totalTracks = await Track.countDocuments({
_id: release.list
})
const tracks = await Track.find({
_id: { $in: release.list }
})
.limit(limit)
.skip(offset)
release.listLength = totalTracks
release.list = tracks
return release
}

View File

@ -1,10 +1,14 @@
import TrackClass from "@classes/track"
export default {
middlewares: ["withOptionalAuthentication"],
fn: async (req) => {
const { track_id } = req.params
const user_id = req.auth?.session?.user_id
const track = await TrackClass.get(track_id)
const track = await TrackClass.get(track_id, {
user_id
})
return track
}

View File

@ -0,0 +1,17 @@
import TrackClass from "@classes/track"
export default {
middlewares: ["withAuthentication"],
fn: async (req) => {
const { track_id } = req.params
const { to } = req.body
const track = await TrackClass.toggleFavourite(
req.auth.session.user_id,
track_id,
to,
)
return track
}
}

View File

@ -0,0 +1,15 @@
import TrackClass from "@classes/track"
export default {
middlewares: ["withAuthentication"],
fn: async (req) => {
const { track_id } = req.params
const likeStatus = await TrackClass.isFavourite(
req.auth.session.user_id,
track_id,
)
return likeStatus
}
}

View File

@ -126,6 +126,8 @@ export default async (payload = {}) => {
}
}
post.share_url = `${process.env.APP_URL}/post/${post._id}`
return {
...post,
user,

View File

@ -0,0 +1,57 @@
import { Post } from "@db_models"
import { DateTime } from "luxon"
const maxDaysOld = 30
export default async (req) => {
// fetch all posts that contain in message an #, with a maximun of 5 diferent hashtags
let posts = await Post.find({
message: {
$regex: /#/gi
},
created_at: {
$gte: DateTime.local().minus({ days: maxDaysOld }).toISO()
}
})
.lean()
// get the hastag content
posts = posts.map((post) => {
post.hashtags = post.message.match(/#[a-zA-Z0-9_]+/gi)
post.hashtags = post.hashtags.map((hashtag) => {
return hashtag.substring(1)
})
return post
})
// build trendings
let trendings = posts.reduce((acc, post) => {
post.hashtags.forEach((hashtag) => {
if (acc.find((trending) => trending.hashtag === hashtag)) {
acc = acc.map((trending) => {
if (trending.hashtag === hashtag) {
trending.count++
}
return trending
})
} else {
acc.push({
hashtag,
count: 1
})
}
})
return acc
}, [])
// sort by count
trendings = trendings.sort((a, b) => {
return b.count - a.count
})
return trendings
}

View File

@ -67,18 +67,18 @@ async function linkInternalSubmodules(packages) {
const appPath = path.resolve(rootPath, pkgjson._web_app_path)
const comtyjsPath = path.resolve(rootPath, "comty.js")
const evitePath = path.resolve(rootPath, "evite")
const vesselPath = path.resolve(rootPath, "vessel")
const linebridePath = path.resolve(rootPath, "linebridge")
//* EVITE LINKING
console.log(`Linking Evite to app...`)
//* APP RUNTIME LINKING
console.log(`Linking Vessel to app...`)
await child_process.execSync("yarn link", {
cwd: evitePath,
cwd: vesselPath,
stdio: "inherit",
})
await child_process.execSync(`yarn link "evite"`, {
await child_process.execSync(`yarn link "vessel"`, {
cwd: appPath,
stdio: "inherit",
})

View File