merge from local

This commit is contained in:
SrGooglo 2024-05-09 21:34:51 +00:00
parent f871dd3c83
commit 93638f0fa3
112 changed files with 2758 additions and 2787 deletions

View File

@ -0,0 +1,41 @@
export default {
"default-context": (items) => {
const text = window.getSelection().toString()
if (text) {
items.push({
label: "Copy",
icon: "Copy",
action: (clickedItem, ctx) => {
copyToClipboard(text)
ctx.close()
}
})
}
items.push({
label: "Paste",
icon: "Clipboard",
action: (clickedItem, ctx) => {
app.message.error("This action is not supported by your browser")
ctx.close()
}
})
items.push({
label: "Report a bug",
icon: "AlertTriangle",
action: (clickedItem, ctx) => {
app.eventBus.emit("app.reportBug", {
clickedItem,
})
ctx.close()
}
})
return items
}
}

View File

@ -1,119 +0,0 @@
import download from "@utils/download"
import copyToClipboard from "@utils/copyToClipboard"
export default {
"default-context": () => {
const items = []
const text = window.getSelection().toString()
if (text) {
items.push({
label: "Copy",
icon: "Copy",
action: (clickedItem, ctx) => {
copyToClipboard(text)
ctx.close()
}
})
}
items.push({
label: "Paste",
icon: "Clipboard",
action: (clickedItem, ctx) => {
app.message.error("This action is not supported by your browser")
ctx.close()
}
})
items.push({
label: "Report a bug",
icon: "AlertTriangle",
action: (clickedItem, ctx) => {
app.eventBus.emit("app.reportBug", {
clickedItem,
})
ctx.close()
}
})
return items
},
"postCard-context": (parent, element, control) => {
const items = [
{
label: "Copy ID",
icon: "Copy",
action: () => {
copyToClipboard(parent.id)
control.close()
}
},
{
label: "Copy Link",
icon: "Link",
action: () => {
copyToClipboard(`${window.location.origin}/post/${parent.id}`)
control.close()
}
}
]
let media = null
// if element div has `addition` class, search inside it for video or image
if (element.classList.contains("addition") || element.classList.contains("image-wrapper")) {
media = element.querySelector("video, img")
}
// if element div has `plyr__poster` class, search outside it for video or image
if (element.classList.contains("plyr__poster")) {
console.log(element.parentElement)
media = element.parentElement.querySelector("video")
}
// if media is found, and is a video, search for the source
if (media && media.tagName === "VIDEO") {
media = media.querySelector("source")
}
if (media) {
items.push({
type: "separator"
})
items.push({
label: "Copy media URL",
icon: "Copy",
action: () => {
copyToClipboard(media.src)
control.close()
}
})
items.push({
label: "Open media in new tab",
icon: "ExternalLink",
action: () => {
window.open(media.src, "_blank")
control.close()
}
})
items.push({
label: "Download media",
icon: "Download",
action: () => {
download(media.src)
control.close()
}
})
}
return items
}
}

View File

@ -0,0 +1,70 @@
import copyToClipboard from "@utils/copyToClipboard"
import download from "@utils/download"
export default {
"post-card": (items, parent, element, control) => {
items.push({
label: "Copy ID",
icon: "Copy",
action: () => {
copyToClipboard(parent.id)
control.close()
}
})
items.push({
label: "Copy Link",
icon: "Link",
action: () => {
copyToClipboard(`${window.location.origin}/post/${parent.id}`)
control.close()
}
})
let media = null
if (parent.querySelector(".attachment")) {
media = parent.querySelector(".attachment")
media = media.querySelector("video, img")
if (media.querySelector("source")) {
media = media.querySelector("source")
}
}
if (media) {
items.push({
type: "separator",
})
items.push({
label: "Copy media URL",
icon: "Copy",
action: () => {
copyToClipboard(media.src)
control.close()
}
})
items.push({
label: "Open media in new tab",
icon: "ExternalLink",
action: () => {
window.open(media.src, "_blank")
control.close()
}
})
items.push({
label: "Download media",
icon: "Download",
action: () => {
download(media.src)
control.close()
}
})
}
return items
}
}

View File

@ -40,11 +40,12 @@
"@mui/material": "^5.11.9",
"@ragestudio/cordova-nfc": "^1.2.0",
"@sentry/browser": "^7.64.0",
"@tanstack/react-virtual": "^3.5.0",
"@tauri-apps/api": "^1.5.4",
"@tsmx/human-readable": "^1.0.7",
"antd": "^5.6.4",
"antd": "^5.17.0",
"antd-mobile": "^5.31.0",
"axios": "^1.4.0",
"axios": "^1.6.8",
"bear-react-carousel": "^4.0.10-alpha.0",
"buffer": "^6.0.3",
"capacitor-music-controls-plugin-v3": "^1.1.0",
@ -65,7 +66,7 @@
"lottie-react": "^2.4.0",
"lru-cache": "^10.0.0",
"luxon": "^3.0.4",
"million": "^2.5.4-beta.1",
"million": "^2.6.4",
"mime": "^3.0.0",
"moment": "2.29.4",
"mpegts.js": "^1.6.10",
@ -95,61 +96,15 @@
"remark-gfm": "^3.0.1",
"rxjs": "^7.5.5",
"store": "^2.0.12",
"ua-parser-js": "^1.0.36"
"ua-parser-js": "^1.0.36",
"vite": "^5.2.11"
},
"devDependencies": {
"@capacitor/assets": "^2.0.4",
"@electron-forge/cli": "^6.0.0-beta.66",
"@electron-forge/maker-deb": "^6.0.0-beta.66",
"@electron-forge/maker-rpm": "^6.0.0-beta.66",
"@electron-forge/maker-squirrel": "^6.0.0-beta.66",
"@electron-forge/maker-zip": "^6.0.0-beta.66",
"@electron-forge/plugin-vite": "^6.4.2",
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@types/jest": "^26.0.24",
"@types/node": "^16.4.10",
"@types/react": "^17.0.15",
"@types/react-dom": "^17.0.9",
"@types/react-router-config": "^5.0.3",
"@types/react-router-dom": "^5.1.8",
"@typescript-eslint/eslint-plugin": "^4.29.0",
"concurrently": "^7.4.0",
"cors": "2.8.5",
"cross-env": "^7.0.3",
"electron": "^21.0.1",
"electron-builder": "^24.6.4",
"electron-is": "^3.0.0",
"electron-log": "^4.4.8",
"electron-squirrel-startup": "^1.0.0",
"express": "^4.17.1",
"typescript": "^4.3.5"
},
"config": {
"forge": {
"packagerConfig": {},
"makers": [
{
"name": "@electron-forge/maker-squirrel",
"config": {
"name": "comty"
}
},
{
"name": "@electron-forge/maker-zip",
"platforms": [
"darwin"
]
},
{
"name": "@electron-forge/maker-deb",
"config": {}
},
{
"name": "@electron-forge/maker-rpm",
"config": {}
}
]
}
}
}

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="COMTY" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 66.35 72">
<defs>
<style>
.cls-1 {
fill: #4b6fb5;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
stroke-width: 0px;
}
.cls-2 {
fill: #6f8cc4;
}
.cls-3 {
fill: #b7c5e1;
}
.cls-5 {
opacity: .5;
}
.cls-4 {
fill: #93a9d3;
}
</style>
</defs>
<g id="ISO-COMPOSED">
<path id="C-BODY" class="cls-1" d="M64.43,19.59s-7.75,4.48-11.22,6.48c-.89.51-2.02.25-2.62-.59-4.54-6.28-13.01-9.53-21.82-6.06-4.07,1.6-7.38,4.73-9.1,8.75-5.63,13.16,3.87,25.83,16.34,25.83,4.31,0,8.26-1.51,11.36-4.04.81-.65,1.97-.62,2.71.11l9.92,9.93c.82.81.78,2.15-.08,2.92-9.71,8.63-24.13,12.09-38.92,6.04C9.38,64.21,0,49.7,0,37.14V2C0,.9.9,0,2.01,0h34c12.56,0,23.6,6.44,30.04,16.19.63.97.33,2.27-1.62,3.4Z"/>
<g id="C-MASK" class="cls-5">
<path class="cls-3" d="M18,0v18H0V2C0,.9.9,0,2.01,0h15.99Z"/>
<rect class="cls-4" x="18" y=".01" width="18" height="18"/>
<path class="cls-2" d="M54,4.84C48.77,1.81,42.71.05,36.24.01h-.24v18h18V4.84Z"/>
<rect class="cls-4" y="17.97" width="18" height="18"/>
<path class="cls-2" d="M18,35.97H0v1.18C0,42.71,1.85,48.65,4.91,53.97h13.09v-18Z"/>
<path class="cls-2" d="M21.23,25.16c.96-1.57,2.28-2.9,3.85-3.87,3.71-2.3,7.43-3.26,10.92-3.26v-.05h-18v18h.02c0-3.46.97-7.15,3.21-10.81Z"/>
</g>
</g>
<path class="cls-1" d="M40.45,23.38c-10.75-3.5-20.54,6.29-17.04,17.04,1.25,3.84,4.3,6.88,8.13,8.13,10.75,3.5,20.54-6.29,17.04-17.04-1.25-3.84-4.3-6.88-8.13-8.13ZM29.64,42.33h0c-.95-.95-.95-2.48,0-3.42l9.31-9.31c.95-.95,2.48-.95,3.42,0h0c.95.95.95,2.48,0,3.42l-9.31,9.31c-.95.95-2.48.95-3.42,0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="COMTY" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 292.9 72" style="enable-background:new 0 0 292.9 72;" xml:space="preserve">
<style type="text/css">
.st0{fill:#4B6FB5;}
.st1{display:none;}
.st2{display:inline;fill:#FF6064;}
.st3{display:inline;fill:#FF8083;}
.st4{display:inline;fill:#FFA0A2;}
.st5{display:inline;fill:#FFBFC1;}
.st6{display:inline;}
.st7{opacity:0.65;fill:#FF7C8C;}
.st8{opacity:0.65;fill:#FF4971;}
.st9{opacity:0.65;fill:#FF4457;}
.st10{opacity:0.5;}
.st11{fill:#B7C5E1;}
.st12{fill:#93A9D3;}
.st13{fill:#6F8CC4;}
</style>
<path id="Y-BODY" class="st0" d="M262,72h-13.6c-1.1,0-2-0.9-2-2v-9.5c0-1.1,0.9-2,2-2h5.8c0.7,0,1.4-0.4,1.7-1l5.3-9.2
c0.4-0.6,0.4-1.4,0-2L246.7,21c-0.8-1.3,0.2-3,1.7-3h11c0.7,0,1.4,0.4,1.7,1l6.8,11.8c0.8,1.3,2.7,1.3,3.5,0l6.8-11.8
c0.4-0.6,1-1,1.7-1h11c1.5,0,2.5,1.7,1.7,3l-28.9,50C263.4,71.6,262.7,72,262,72z"/>
<path id="T-BODY" class="st0" d="M240,18h-7.2c-1.1,0-2-0.9-2-2V2c0-1.1-0.9-2-2-2h-9.5c-1.1,0-2,0.9-2,2v14c0,1.1-0.9,2-2,2H208
c-1.1,0-2,0.9-2,2v9.5c0,1.1,0.9,2,2,2h7.2c1.1,0,2,0.9,2,2V70c0,1.1,0.9,2,2,2h9.5c1.1,0,2-0.9,2-2V33.5c0-1.1,0.9-2,2-2h7.2
c1.1,0,2-0.9,2-2V20C242,18.9,241.1,18,240,18z"/>
<path id="M-BODY" class="st0" d="M201.5,40.5L201.5,40.5c0-12.4-10.1-22.5-22.5-22.5c-5.5,0-10.6,2-14.5,5.3c-0.7,0.6-1.8,0.6-2.6,0
c-3.9-3.3-9-5.3-14.5-5.3c-2.8,0-5.5,0.5-8,1.5c-0.5,0.2-1.1-0.2-1.1-0.7v0c0-0.4-0.3-0.8-0.8-0.8h-10.7c-1.1,0-2,0.9-2,2v50
c0,1.1,0.9,2,2,2h9.1h0.4h0.1c1.1,0,2-0.9,2-2V40.5c0-5,4-9,9-9c4.9,0,8.9,4,9,8.9V70c0,1.1,0.9,2,2,2h0.1h0.4h8.7h0.4h0
c1.1,0,2-0.9,2-2V40.5h0c0-5,4-9,9-9c4.9,0,9,4,9,8.9V70c0,1.1,0.9,2,2,2h0.1h0.4h9c1.1,0,2-0.9,2-2V40.5z"/>
<path id="O-BODY" class="st0" d="M93.4,18c-14.9,0-27,12.1-27,27s12.1,27,27,27s27-12.1,27-27S108.3,18,93.4,18z M93.4,58.5
c-7.5,0-13.5-6-13.5-13.5c0-7.5,6-13.5,13.5-13.5c7.5,0,13.5,6,13.5,13.5C106.9,52.4,100.8,58.5,93.4,58.5z"/>
<g id="Colors" class="st1">
<rect x="0.1" y="-27.5" class="st2" width="22.5" height="22.5"/>
<rect x="22.5" y="-27.5" class="st3" width="22.5" height="22.5"/>
<rect x="45" y="-27.5" class="st4" width="22.5" height="22.5"/>
<rect x="67.5" y="-27.5" class="st5" width="22.5" height="22.5"/>
</g>
<g id="old_x5F_iso" class="st1">
<path id="C_00000029753336008474061920000012106504942734039743_" class="st2" d="M129.4-23c-12.5,0-22-12.7-16.3-25.8
c1.7-4,5-7.1,9.1-8.7c8.8-3.5,17.3-0.2,21.8,6.1c0.6,0.8,1.7,1.1,2.6,0.6l11.2-6.5c1.9-1.1,2.2-2.4,1.6-3.4
c-6.4-9.7-17.5-16.2-30-16.2h-34c-1.1,0-2,0.9-2,2l0,35.1c0,12.6,9.4,27.1,21,31.8c14.8,6,29.2,2.6,38.9-6c0.9-0.8,0.9-2.1,0.1-2.9
l-9.9-9.9c-0.7-0.7-1.9-0.8-2.7-0.1C137.6-24.5,133.7-23,129.4-23z"/>
<g id="_x2D__00000003068151388543984340000009125836902913508246_" class="st6">
<path class="st7" d="M111.4-76.9H95.3c-1.1,0-1.9,0.9-1.9,2v16h18V-76.9z"/>
<rect x="111.4" y="-76.9" class="st8" width="18" height="18"/>
<path class="st9" d="M147.3-72.1c-5.2-3-11.3-4.8-17.8-4.8h-0.2v18h16c1.1,0,2-0.9,2-2V-72.1z"/>
<rect x="93.4" y="-59" class="st8" width="18" height="18"/>
<path class="st9" d="M111.4-41h-18v1.2c0,5.6,1.8,11.5,4.9,16.8h11.1c1.1,0,2-0.9,2-2V-41z"/>
<path class="st9" d="M113-48.8c1.7-4,5-7.1,9.1-8.7c2.5-1,4.9-1.4,7.2-1.4V-59h-18v18h0C111.4-43.5,111.9-46.2,113-48.8z"/>
</g>
</g>
<g>
<g id="ISO-COMPOSED_00000069363719166014630790000000862934890392767654_">
<path id="C-BODY_00000127745992915324767630000016197953627436916150_" class="st0" d="M64.4,19.6c0,0-7.8,4.5-11.2,6.5
c-0.9,0.5-2,0.3-2.6-0.6c-4.5-6.3-13-9.5-21.8-6.1c-4.1,1.6-7.4,4.7-9.1,8.8C14,41.3,23.5,54,36,54c4.3,0,8.3-1.5,11.4-4
c0.8-0.6,2-0.6,2.7,0.1L60,60c0.8,0.8,0.8,2.1-0.1,2.9C50.2,71.6,35.8,75,21,69C9.4,64.2,0,49.7,0,37.1V2c0-1.1,0.9-2,2-2h34
c12.6,0,23.6,6.4,30,16.2C66.7,17.2,66.4,18.5,64.4,19.6z"/>
<g id="C-MASK_00000045611387297072415010000003497810413074703294_" class="st10">
<path class="st11" d="M18,0v18H0V2c0-1.1,0.9-2,2-2H18z"/>
<rect x="18" y="0" class="st12" width="18" height="18"/>
<path class="st13" d="M54,4.8c-5.2-3-11.3-4.8-17.8-4.8H36v18h18V4.8z"/>
<rect y="18" class="st12" width="18" height="18"/>
<path class="st13" d="M18,36H0v1.2C0,42.7,1.8,48.7,4.9,54H18V36z"/>
<path class="st13" d="M21.2,25.2c1-1.6,2.3-2.9,3.8-3.9C28.8,19,32.5,18,36,18V18H18v18h0C18,32.5,19,28.8,21.2,25.2z"/>
</g>
</g>
<path class="st0" d="M40.5,23.4c-10.7-3.5-20.5,6.3-17,17c1.2,3.8,4.3,6.9,8.1,8.1c10.7,3.5,20.5-6.3,17-17
C47.3,27.7,44.3,24.6,40.5,23.4z M29.6,42.3L29.6,42.3c-0.9-0.9-0.9-2.5,0-3.4l9.3-9.3c0.9-0.9,2.5-0.9,3.4,0l0,0
c0.9,0.9,0.9,2.5,0,3.4l-9.3,9.3C32.1,43.3,30.6,43.3,29.6,42.3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -28,7 +28,6 @@ import {
NotificationsCenter,
PostCreator,
} from "@components"
import { DOMWindow } from "@components/RenderWindow"
import { Icons } from "@components/Icons"
import DesktopTopBar from "@components/DesktopTopBar"
@ -40,7 +39,9 @@ import Splash from "./splash"
import "@styles/index.less"
if (IS_MOBILE_HOST) {
CapacitorUpdater.notifyAppReady()
}
class ComtyApp extends React.Component {
constructor(props) {
@ -52,7 +53,6 @@ class ComtyApp extends React.Component {
}
state = {
desktopMode: false,
session: null,
initialized: false,
}
@ -62,7 +62,7 @@ class ComtyApp extends React.Component {
window.app.version = config.package.version
window.app.confirm = antd.Modal.confirm
window.app.message = antd.message
window.app.isCapacitor = window.navigator.userAgent === "capacitor"
window.app.isCapacitor = IS_MOBILE_HOST
if (window.app.version !== window.localStorage.getItem("last_version")) {
app.message.info(`Comty has been updated to version ${window.app.version}!`)
@ -157,16 +157,14 @@ class ComtyApp extends React.Component {
framed: false
})
},
openMessages: () => {
app.location.push("/messages")
},
openFullImageViewer: (src) => {
const win = new DOMWindow({
id: "fullImageViewer",
className: "fullImageViewer",
})
win.render(<Lightbox
app.cores.window_mng.render("image_lightbox", <Lightbox
small={src}
large={src}
onClose={() => win.remove()}
onClose={() => app.cores.window_mng.close("image_lightbox")}
hideDownload
showRotate
/>)
@ -298,17 +296,6 @@ class ComtyApp extends React.Component {
},
})
},
"app.no_session": async () => {
const location = window.location.pathname
if (location !== "/auth" && location !== "/register") {
antd.notification.info({
message: "You are not logged in, to use some features you will need to log in.",
btn: <antd.Button type="primary" onClick={() => app.controls.openLoginForm()}>Login</antd.Button>,
duration: 15,
})
}
},
"session.invalid": async (error) => {
const token = await SessionModel.token

View File

@ -11,6 +11,7 @@ import * as lib3 from "react-icons/md"
import * as lib4 from "react-icons/io"
import * as lib5 from "react-icons/si"
import * as lib6 from "react-icons/fa"
import * as lib7 from "react-icons/tb"
const marginedStyle = {
width: "1em",
@ -42,6 +43,7 @@ export const Icons = {
...lib4,
...lib5,
...lib6,
...lib7,
}
export function createIconRender(icon, props) {

View File

@ -1,8 +0,0 @@
export { default as TopBar } from "./topBar"
export { default as BottomBar } from "./bottomBar"
export { default as Drawer } from "./drawer"
export { default as Sidebar } from "./sidebar"
export { default as Sidedrawer } from "./sidedrawer"
export { default as Modal } from "./modal"
export { default as Header } from "./header"
export { default as ToolsBar } from "./toolsBar"

View File

@ -1,79 +0,0 @@
import React from "react"
import { Modal, Button } from "antd"
import { Icons } from "@components/Icons"
import "./index.less"
// TODO: Implement translucid mode
// TODO: Implement close on click mask (and Should remove the close button)
export default class AppModal extends React.Component {
constructor(props) {
super(props)
this.controller = app.ModalController = {
open: this.open,
close: this.close,
modalRef: this.modalRef,
}
}
state = {
currentRender: null,
renderParams: {}
}
modalRef = React.createRef()
open = (render, params = {}) => {
this.setState({
currentRender: render,
renderParams: params
})
}
close = () => {
this.setState({
currentRender: null,
renderParams: {}
})
}
handleModalClose = () => {
this.close()
}
renderModal = () => {
return <div className="appModalWrapper">
<Button
icon={<Icons.X />}
className="closeButton"
onClick={this.handleModalClose}
shape="circle"
/>
<div className="appModal" ref={this.modalRef}>
{
React.createElement(this.state.currentRender, {
...this.state.renderParams.props ?? {},
close: this.close,
})
}
</div>
</div>
}
render() {
return <Modal
open={this.state.currentRender}
maskClosable={this.state.renderParams.maskClosable ?? true}
modalRender={this.renderModal}
maskStyle={{
backgroundColor: "rgba(0, 0, 0, 0.7)",
backdropFilter: "blur(5px)"
}}
destroyOnClose
centered
/>
}
}

View File

@ -1,45 +0,0 @@
.appModalWrapper {
.closeButton {
background-color: var(--background-color-primary);
pointer-events: all;
font-size: 1.2rem;
transform: translate(-25px, -10px);
svg {
margin: 0 !important;
color: var(--text-color);
}
&:hover {
background-color: var(--background-color-primary);
}
}
.appModal {
pointer-events: all;
display: flex;
flex-direction: column;
align-self: center;
border-radius: 8px;
width: fit-content;
height: fit-content;
transition: all 0.2s ease-in-out;
//min-height: 500px;
min-width: 600px;
padding: 30px;
background-color: var(--background-color-accent);
color: var(--text-color);
}
}

View File

@ -18,8 +18,6 @@ export default React.forwardRef((props, ref) => {
const insideViewportCb = (entries) => {
const { fetching, onBottom } = props
console.log("entries", entries)
entries.forEach(element => {
if (element.intersectionRatio > 0 && !fetching) {
onBottom()
@ -50,14 +48,14 @@ export default React.forwardRef((props, ref) => {
>
{children}
<lb style={{ clear: "both" }} />
<div style={{ clear: "both" }} />
<lb
<div
id="bottom"
className="bottom"
style={{ display: hasMore ? "block" : "none" }}
>
{loadingComponent && React.createElement(loadingComponent)}
</lb>
</div>
</div>
})

View File

@ -21,19 +21,19 @@ import MusicModel from "@models/music"
import "./index.less"
const PlaylistTypeDecorators = {
"single": (props) => <span className="playlistType">
"single": () => <span className="playlistType">
<Icons.MdMusicNote />
Single
</span>,
"album": (props) => <span className="playlistType">
"album": () => <span className="playlistType">
<Icons.MdAlbum />
Album
</span>,
"ep": (props) => <span className="playlistType">
"ep": () => <span className="playlistType">
<Icons.MdAlbum />
EP
</span>,
"mix": (props) => <span className="playlistType">
"mix": () => <span className="playlistType">
<Icons.MdMusicNote />
Mix
</span>,
@ -228,12 +228,14 @@ export default (props) => {
return <antd.Skeleton active />
}
const playlistType = playlist.type?.toLowerCase() ?? "playlist"
return <PlaylistContext.Provider value={contextValues}>
<WithPlayerContext>
<div
className={classnames(
"playlist_view",
props.type ?? playlist.type,
playlistType,
)}
>
@ -257,9 +259,9 @@ export default (props) => {
<div className="play_info_statistics">
{
playlist.type && PlaylistTypeDecorators[playlist.type] && <div className="play_info_statistics_item">
playlistType && PlaylistTypeDecorators[playlistType] && <div className="play_info_statistics_item">
{
PlaylistTypeDecorators[playlist.type]()
PlaylistTypeDecorators[playlistType]()
}
</div>
}

View File

@ -84,6 +84,10 @@ export class PagePanelWithNavMenu extends React.Component {
return <></>
}
if (this.props.tabs.length === 0) {
return <></>
}
// slip the active tab by splitting on "."
if (!this.state.activeTab) {
console.error("PagePanelWithNavMenu: activeTab is not defined")

View File

@ -64,7 +64,7 @@ export default (props) => {
onClickReply,
} = props.actions ?? {}
const genItems = () => {
const generateMoreMenuItems = () => {
let items = MoreActionsItems
if (isSelf) {
@ -104,7 +104,7 @@ export default (props) => {
<div className="action" id="more">
<Dropdown
menu={{
items: genItems(),
items: generateMoreMenuItems(),
onClick: handleDropdownClickItem,
}}
trigger={["click"]}

View File

@ -22,7 +22,7 @@
overflow: hidden;
background-color: black;
background-color: var(--background-color-primary);
.bear-react-carousel__pagination-group {
top: 0;

View File

@ -12,6 +12,14 @@ import PostAttachments from "./components/attachments"
import "./index.less"
const articleAnimationProps = {
layout: true,
initial: { y: -100, opacity: 0 },
animate: { y: 0, opacity: 1, },
exit: { scale: 0, opacity: 0 },
transition: { duration: 0.1, },
}
const messageRegexs = [
{
regex: /https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})(&[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+)*/g,
@ -164,26 +172,21 @@ export default class PostCard extends React.PureComponent {
}
render() {
return <motion.div
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1, }}
exit={{ scale: 0, opacity: 0 }}
transition={{
duration: 0.1,
}}
layout
key={this.props.index}
id={this.state.data._id}
post_id={this.state.data._id}
style={this.props.style}
user-id={this.state.data.user_id}
context-menu={"postCard-context"}
return <motion.article
className={classnames(
"post_card",
{
["open"]: this.state.open,
}
)}
id={this.state.data._id}
style={this.props.style}
{...articleAnimationProps}
>
<div
className="post_card_content"
context-menu={"post-card"}
user-id={this.state.data.user_id}
>
<PostHeader
postData={this.state.data}
@ -237,6 +240,8 @@ export default class PostCard extends React.PureComponent {
<span>View {this.state.hasReplies} replies</span>
</div>
}
</motion.div>
</div>
</motion.article>
}
}

View File

@ -33,6 +33,13 @@
color: var(--text-color);
}
.post_card_content {
display: flex;
flex-direction: column;
gap: 15px;
}
.post_content {
position: relative;

View File

@ -1,233 +0,0 @@
import React from "react"
import ReactDOM from "react-dom"
import { Rnd } from "react-rnd"
import { Icons } from "@components/Icons"
import "./index.less"
class DOMWindow {
constructor(props = {}) {
this.props = props
this.id = this.props.id
this.key = 0
this.currentRender = null
this.root = document.getElementById("app_windows")
this.element = document.createElement("div")
this.element.setAttribute("id", this.id)
this.element.setAttribute("key", this.key)
this.element.setAttribute("classname", this.props.className)
// if props clickOutsideToClose is true, add event listener to close window
if (this.props.clickOutsideToClose) {
document.addEventListener("click", this.handleWrapperClick)
}
// handle root container
if (!this.root) {
this.root = document.createElement("div")
this.root.setAttribute("id", "app_windows")
document.body.append(this.root)
}
// get all windows opened has container
const rootNodes = this.root.childNodes
// ensure this window has last key from rootNode
if (rootNodes.length > 0) {
const lastChild = rootNodes[rootNodes.length - 1]
const lastChildKey = Number(lastChild.getAttribute("key"))
this.key = lastChildKey + 1
}
}
handleWrapperClick = (event) => {
if (!this.currentRender) {
return
}
// if click in not renderer fragment, close window
if (!this.element.contains(event.target)) {
this.remove()
}
}
render = (fragment) => {
this.root.appendChild(this.element)
this.currentRender = fragment
return ReactDOM.render(fragment, this.element)
}
remove = () => {
this.root.removeChild(this.element)
this.currentRender = null
}
destroy = () => {
this.element.remove()
this.currentRender = null
}
createDefaultWindow = (children, props) => {
return this.render(<DefaultWindowRender {...this.props} {...props} id={this.id} key={this.key} destroy={this.destroy} >
{children}
</DefaultWindowRender>)
}
}
class DefaultWindowRender extends React.Component {
state = {
actions: [],
dimensions: {
height: this.props.height ?? 600,
width: this.props.width ?? 400,
},
position: this.props.defaultPosition,
visible: false,
}
componentDidMount = () => {
this.setDefaultActions()
if (typeof this.props.actions !== "undefined") {
if (Array.isArray(this.props.actions)) {
const actions = this.state.actions ?? []
this.props.actions.forEach((action) => {
actions.push(action)
})
this.setState({ actions })
}
}
if (!this.state.position) {
this.setState({ position: this.getCenterPosition() })
}
this.toggleVisibility(true)
}
toggleVisibility = (to) => {
this.setState({ visible: to ?? !this.state.visible })
}
getCenterPosition = () => {
const dimensions = this.state?.dimensions ?? {}
const windowHeight = dimensions.height ?? 600
const windowWidth = dimensions.width ?? 400
return {
x: window.innerWidth / 2 - windowWidth / 2,
y: window.innerHeight / 2 - windowHeight / 2,
}
}
setDefaultActions = () => {
const { actions } = this.state
actions.push({
key: "close",
render: () => <Icons.XCircle style={{ margin: 0, padding: 0 }} />,
onClick: () => {
this.props.destroy()
},
})
this.setState({ actions })
}
renderActions = () => {
const actions = this.state.actions
if (Array.isArray(actions)) {
return actions.map((action) => {
return (
<div key={action.key} onClick={action.onClick} {...action.props}>
{React.isValidElement(action.render) ? action.render : React.createElement(action.render)}
</div>
)
})
}
return null
}
getComponentRender = () => {
return React.isValidElement(this.props.children)
? React.cloneElement(this.props.children, this.props.renderProps)
: React.createElement(this.props.children, this.props.renderProps)
}
render() {
const { position, dimensions, visible } = this.state
if (!visible) {
return null
}
return (
<Rnd
default={{
...position,
...dimensions,
}}
onResize={(e, direction, ref, delta, position) => {
this.setState({
dimensions: {
width: ref.offsetWidth,
height: ref.offsetHeight,
},
position,
})
}}
minWidth={
this.props.minWidth
}
minHeight={
this.props.minHeight
}
enableResizing={
this.props.enableResizing ?? true
}
disableDragging={
this.props.disableDragging ?? false
}
dragHandleClassName={
this.props.dragHandleClassName ?? "window_topbar"
}
bounds={
this.props.bounds ?? "#root"
}
>
{
this.props.useWrapper
? <div
style={{
height: dimensions.height,
width: dimensions.width,
}}
className="window_wrapper"
>
<div className="window_topbar">
<div className="title">{this.props.id}</div>
<div className="actions">{this.renderActions()}</div>
</div>
<div className="window_body">{this.getComponentRender()}</div>
</div>
: this.props.children
}
</Rnd>
)
}
}
export { DOMWindow, DefaultWindowRender }

View File

@ -1,93 +0,0 @@
@wrapper_background: rgba(255, 255, 255, 1);
@topbar_height: 30px;
@topbar_background: rgba(0, 0, 0, 0.4);
.window_wrapper {
border-radius: 12px;
background-color: @wrapper_background;
border: 1px solid rgba(161, 133, 133, 0.2);
overflow: hidden;
&.translucid {
border: unset;
background-color: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
--webkit-backdrop-filter: blur(10px);
filter: drop-shadow(8px 8px 10px rgba(0, 0, 0, 0.5));
}
}
.window_topbar {
position: sticky;
z-index: 51;
background-color: @topbar_background;
height: @topbar_height;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
> div {
margin: 0 5px;
line-height: 0;
}
.title {
margin-left: 20px;
color: #fff - @topbar_background;
font-size: 13px;
font-style: italic;
font-family: "JetBrains Mono", monospace;
}
.actions {
display: flex;
flex-direction: row-reverse;
align-items: center;
justify-content: end;
color: #fff - @topbar_background;
> div {
transition: all 150ms ease-in-out;
margin-right: 10px;
cursor: pointer;
height: fit-content;
width: fit-content;
line-height: 0;
display: flex;
align-items: center;
justify-content: end;
}
> div:hover {
color: var(--primary-color);
}
}
}
.window_body {
z-index: 50;
padding: 10px 20px;
height: calc(100% - @topbar_height);
width: 100%;
overflow: overlay;
user-select: text !important;
--webkit-user-select: text !important;
> div {
user-select: text !important;
--webkit-user-select: text !important;
}
}

View File

@ -5,12 +5,11 @@ import { Icons } from "@components/Icons"
import WidgetItemPreview from "@components/WidgetItemPreview"
import useRequest from "comty.js/hooks/useRequest"
import WidgetModel from "comty.js/models/widget"
import "./index.less"
export const WidgetBrowser = (props) => {
const [L_Widgets, R_Widgets, E_Widgets, M_Widgets] = useRequest(WidgetModel.browse)
const [L_Widgets, R_Widgets, E_Widgets, M_Widgets] = []
const [searchValue, setSearchValue] = React.useState("")

View File

@ -1,4 +1,3 @@
import * as Layout from "./Layout"
export { default as Footer } from "./Footer"
export { default as RenderError } from "./RenderError"
@ -41,8 +40,3 @@ export { default as UserBadges } from "./UserBadges"
export { default as UserCard } from "./UserCard"
export { default as FollowersList } from "./FollowersList"
export { default as UserPreview } from "./UserPreview"
// OTHERS
export * as Window from "./RenderWindow"
export { Layout }

View File

@ -1,5 +1,5 @@
.contextMenu {
position: absolute;
position: fixed;
z-index: 100000;
display: flex;
@ -8,7 +8,7 @@
top: 0;
left: 0;
width: 200px;
width: 230px;
height: fit-content;
background-color: var(--background-color-primary);
@ -16,11 +16,11 @@
border-radius: 8px;
padding: 10px;
padding: 7px;
font-family: "Recursive", sans-serif;
font-weight: 600;
font-family: var(--fontFamily);
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
h1,
@ -42,7 +42,7 @@
align-items: center;
justify-content: space-between;
padding: 10px 10px 10px 20px;
padding: 10px 10px 10px 15px;
transition: all 50ms ease-in-out;
@ -52,7 +52,7 @@
&:hover {
background-color: var(--background-color-accent);
padding-left: 25px;
padding-left: 18px;
}
&:active {

View File

@ -1,10 +1,10 @@
import React from "react"
import Core from "evite/src/core"
import { DOMWindow } from "@components/RenderWindow"
import ContextMenu from "./components/contextMenu"
import InternalContexts from "@config/context-menu"
import DefaultContenxt from "@config/context-menu/default"
import PostCardContext from "@config/context-menu/post"
export default class ContextMenuCore extends Core {
static namespace = "contextMenu"
@ -15,13 +15,10 @@ export default class ContextMenuCore extends Core {
registerContext: this.registerContext.bind(this),
}
contexts = Object()
DOMWindow = new DOMWindow({
id: "contextMenu",
className: "contextMenuWrapper",
clickOutsideToClose: true,
})
contexts = {
...DefaultContenxt,
...PostCardContext,
}
async onInitialize() {
if (app.isMobile) {
@ -65,27 +62,26 @@ export default class ContextMenuCore extends Core {
contexts.push("default-context")
}
for await (const context of contexts) {
let contextObject = this.contexts[context] || InternalContexts[context]
for await (const [index, context] of contexts.entries()) {
let contextObject = this.contexts[context]
if (!contextObject) {
this.console.warn(`Context ${context} not found`)
continue
}
if (typeof contextObject === "function") {
contextObject = await contextObject(parentElement, element, {
contextObject = await contextObject(items, parentElement, element, {
close: this.hide,
})
}
// push divider
if (items.length > 0) {
if (contexts.length > 0 && index !== contexts.length - 1) {
items.push({
type: "separator"
})
}
if (Array.isArray(contextObject)) {
items.push(...contextObject)
} else {
items.push(contextObject)
}
}
// fullfill each item with a correspondent index if missing declared
@ -142,10 +138,13 @@ export default class ContextMenuCore extends Core {
}
show(payload) {
this.DOMWindow.render(React.createElement(ContextMenu, payload))
app.cores.window_mng.render("context-menu", React.createElement(ContextMenu, payload), {
createOrUpdate: true,
closeOnClickOutside: true,
})
}
hide() {
this.DOMWindow.remove()
app.cores.window_mng.close("context-menu")
}
}

View File

@ -12,7 +12,7 @@ class NotificationFeedback {
return (window.app.cores.settings.get("sfx:notifications_volume") ?? 50) / 100
}
static playHaptic = async (options = {}) => {
static playHaptic = async () => {
if (app.cores.settings.get("haptics:notifications_feedback")) {
await Haptics.vibrate()
}
@ -28,7 +28,11 @@ class NotificationFeedback {
}
}
static async feedback(type) {
static async feedback({ type = "notification", feedback = true }) {
if (!feedback) {
return false
}
NotificationFeedback.playHaptic(type)
NotificationFeedback.playAudio(type)
}

View File

@ -22,10 +22,15 @@ export default class NotificationCore extends Core {
public = {
new: this.new,
close: this.close,
}
async new(notification, options = {}) {
NotificationUI.notify(notification, options)
NotificationFeedback.feedback(options.type)
async new(notification) {
NotificationUI.notify(notification)
NotificationFeedback.feedback(notification)
}
async close(id) {
NotificationUI.close(id)
}
}

View File

@ -5,12 +5,7 @@ import { notification as Notf, Space, Button } from "antd"
import { Icons, createIconRender } from "@components/Icons"
class NotificationUI {
static async notify(
notification,
options = {
type: "info"
}
) {
static async notify(notification) {
if (typeof notification === "string") {
notification = {
title: "New notification",
@ -19,8 +14,8 @@ class NotificationUI {
}
const notfObj = {
duration: options.duration ?? 4,
key: options.key ?? Date.now(),
duration: typeof notification.duration === "undefined" ? 4 : notification.duration,
key: notification.key ?? Date.now(),
}
if (notification.title) {
@ -73,11 +68,11 @@ class NotificationUI {
notfObj.icon = React.isValidElement(notification.icon) ? notification.icon : (createIconRender(notification.icon) ?? <Icons.Bell />)
}
if (Array.isArray(options.actions)) {
if (Array.isArray(notification.actions)) {
notfObj.btn = (
<Space>
{
options.actions.map((action, index) => {
notification.actions.map((action, index) => {
const handleClick = () => {
if (typeof action.onClick === "function") {
action.onClick()
@ -101,11 +96,24 @@ class NotificationUI {
)
}
if (typeof Notf[options.type] !== "function") {
options.type = "info"
if (typeof notification.closable) {
notfObj.closable = notification.closable
}
return Notf[options.type](notfObj)
if (notification.type === "loading") {
notification.type = "open"
notfObj.icon = <Icons.LoadingOutlined />
}
if (typeof Notf[notification.type] !== "function") {
notification.type = "info"
}
return Notf[notification.type](notfObj)
}
static close(key) {
Notf.destroy(key)
}
}

View File

@ -110,7 +110,8 @@ export default class Player extends Core {
set: (target, prop, value) => {
return false
}
})
}),
gradualFadeMs: Player.gradualFadeMs,
}
internalEvents = {
@ -128,10 +129,6 @@ export default class Player extends Core {
},
}
wsEvents = {
}
async onInitialize() {
this.native_controls.initialize()
@ -695,6 +692,10 @@ export default class Player extends Core {
}
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")

View File

@ -32,8 +32,7 @@ export default class RemoteStorage extends Core {
const fn = async () => new Promise((resolve, reject) => {
const uploader = new ChunkedUpload({
endpoint: `${app.cores.api.client().mainOrigin}/upload/chunk`,
// TODO: get chunk size from settings
splitChunkSize: 5 * 1024 * 1024, // 5MB in bytes
splitChunkSize: 5 * 1024 * 1024,
file: file,
service: service,
})

View File

@ -239,8 +239,6 @@ export default class StyleCore extends Core {
app.eventBus.emit("style.update", {
...this.public.mutation,
})
ConfigProvider.config({ theme: this.public.mutation })
}
applyVariant(variant = (this.public.theme.defaultVariant ?? "light")) {

View File

@ -0,0 +1,17 @@
import React from "react"
export default React.createContext({
title: null,
close: () => { },
updatePosition: () => { },
updateDimensions: () => { },
updateTitle: () => { },
position: {
x: 0,
y: 0,
},
dimensions: {
width: 0,
height: 0,
},
})

View File

@ -0,0 +1,196 @@
import React from "react"
import { Rnd } from "react-rnd"
import { Icons } from "@components/Icons"
import WindowContext from "./context"
export default class DefaultWindowRender extends React.Component {
static contextType = WindowContext
ref = React.createRef()
state = {
renderError: false,
title: null,
actions: [],
dimensions: {
height: this.props.height ?? 600,
width: this.props.width ?? 400,
},
position: this.props.defaultPosition,
visible: false,
}
componentDidMount = () => {
this.setDefaultActions()
if (typeof this.props.actions !== "undefined") {
if (Array.isArray(this.props.actions)) {
const actions = this.state.actions ?? []
this.props.actions.forEach((action) => {
actions.push(action)
})
this.setState({ actions })
}
}
if (!this.state.position) {
this.setState({ position: this.getCenterPosition() })
}
this.toggleVisibility(true)
}
componentDidCatch = (error) => {
console.error(error)
this.setState({
renderError: error,
})
}
updateTitle = (title) => {
this.setState({ title })
}
updateDimensions = (dimensions = {
width: 0,
height: 0,
}) => {
this.ref.current?.updateSize({
width: dimensions.width,
height: dimensions.height,
})
}
updatePosition = (position = {
x: 0,
y: 0,
}) => {
this.ref.current?.updatePosition({
x: position.x,
y: position.y,
})
}
toggleVisibility = (to) => {
this.setState({ visible: to ?? !this.state.visible })
}
getCenterPosition = () => {
const dimensions = this.state?.dimensions ?? {}
const windowHeight = dimensions.height ?? 600
const windowWidth = dimensions.width ?? 400
return {
x: window.innerWidth / 2 - windowWidth / 2,
y: window.innerHeight / 2 - windowHeight / 2,
}
}
setDefaultActions = () => {
const { actions } = this.state
actions.push({
key: "close",
render: () => <Icons.XCircle style={{ margin: 0, padding: 0 }} />,
onClick: () => {
this.props.close()
},
})
this.setState({ actions })
}
renderActions = () => {
const actions = this.state.actions
if (Array.isArray(actions)) {
return actions.map((action) => {
return (
<div key={action.key} onClick={action.onClick} {...action.props}>
{React.isValidElement(action.render) ? action.render : React.createElement(action.render)}
</div>
)
})
}
return null
}
getComponentRender = () => {
const ctx = {
...this.props,
updateTitle: this.updateTitle,
updateDimensions: this.updateDimensions,
updatePosition: this.updatePosition,
close: this.props.close,
position: this.state.position,
dimensions: this.state.dimensions,
}
return <WindowContext.Provider value={ctx}>
{
React.isValidElement(this.props.children)
? React.cloneElement(this.props.children, ctx)
: React.createElement(this.props.children, ctx)
}
</WindowContext.Provider>
}
render() {
const { position, dimensions, visible } = this.state
if (!visible) {
return null
}
return <Rnd
ref={this.ref}
default={{
...position,
...dimensions,
}}
minWidth={
this.props.minWidth
}
minHeight={
this.props.minHeight
}
enableResizing={
this.props.enableResizing ?? true
}
disableDragging={
this.props.disableDragging ?? false
}
dragHandleClassName={
this.props.dragHandleClassName ?? "window_topbar"
}
bounds={
this.props.bounds ?? "#root"
}
className="window_wrapper"
>
<div className="window_topbar">
<div className="title">{this.state.title}</div>
<div className="actions">{this.renderActions()}</div>
</div>
<div className="window_body">
{
this.state.renderError && <div className="render_error">
<h1>Render Error</h1>
<code>{this.state.renderError.message}</code>
</div>
}
{
!this.state.renderError && this.getComponentRender()
}
</div>
</Rnd>
}
}

View File

@ -0,0 +1,115 @@
@topbar_height: 30px;
@topbar_background: var(--background-color-accent);
.window_wrapper {
border-radius: 12px;
background-color: var(--background-color-primary);
border: 2px solid var(--border-color);
//filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.5));
overflow: hidden;
}
.window_topbar {
position: sticky;
z-index: 51;
background-color: @topbar_background;
color: var(--text-color);
height: @topbar_height;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
>div {
margin: 0 5px;
line-height: 0;
}
.title {
margin-left: 20px;
color: var(--text-color);
font-size: 13px;
font-style: italic;
font-family: "JetBrains Mono", monospace;
}
.actions {
display: flex;
flex-direction: row-reverse;
align-items: center;
justify-content: end;
color: var(--text-color);
>div {
transition: all 150ms ease-in-out;
margin-right: 10px;
cursor: pointer;
height: fit-content;
width: fit-content;
line-height: 0;
display: flex;
align-items: center;
justify-content: end;
}
>div:hover {
color: var(--primary-color);
}
}
}
.window_body {
z-index: 50;
padding: 10px 20px;
height: calc(100% - @topbar_height);
width: 100%;
overflow: overlay;
user-select: text !important;
--webkit-user-select: text !important;
color: var(--text-color);
>div {
user-select: text !important;
--webkit-user-select: text !important;
}
.render_error {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
h1 {
font-size: 2rem;
}
code {
background-color: var(--background-color-accent);
padding: 5px;
border-radius: 8px;
user-select: text;
}
}
}

View File

@ -0,0 +1,151 @@
import React from "react"
import Core from "evite/src/core"
import { createRoot } from "react-dom/client"
import DefaultWindow from "./components/defaultWindow"
import DefaultWindowContext from "./components/defaultWindow/context"
import "./index.less"
export default class WindowManager extends Core {
static namespace = "window_mng"
static idMount = "windows"
root = null
windows = []
public = {
close: this.close.bind(this),
render: this.render.bind(this),
}
async onInitialize() {
this.root = document.createElement("div")
this.root.setAttribute("id", this.constructor.idMount)
document.body.append(this.root)
}
handleWrapperClick = (id, event) => {
const element = this.root.querySelector(`#${id}`)
if (element) {
if (!element.contains(event.target)) {
this.close(id)
}
}
}
/**
* Creates a new element with the specified id and appends it to the root element.
* If the element already exists and createOrUpdate option is false, it will throw an error.
* If the element already exists and createOrUpdate option is true, it will update the element.
* If useFrame option is true, it wraps the fragment with a DefaultWindow component before rendering.
*
* @param {string} id - The id of the element to create or update.
* @param {ReactElement} fragment - The React element to render inside the created element.
* @param {Object} options - The options for creating or updating the element.
* @param {boolean} options.useFrame - Specifies whether to wrap the fragment with a DefaultWindow component.
* @param {boolean} options.createOrUpdate - Specifies whether to create a new element or update an existing one.
* @return {HTMLElement} The created or updated element.
*/
render(
id,
fragment,
{
useFrame = false,
createOrUpdate = false,
closeOnClickOutside = false,
} = {}
) {
let element = document.createElement("div")
let node = null
let win = null
// check if window already exist
if (this.root.querySelector(`#${id}`) && !createOrUpdate) {
const newId = `${id}_${Date.now()}`
this.console.warn(`Window ${id} already exist, overwritting id to ${newId}.\nYou can use {createOrUpdate = true} option to force refresh render of window`)
id = newId
}
if (this.root.querySelector(`#${id}`) && createOrUpdate) {
element = document.getElementById(id)
win = this.windows.find((_node) => {
return _node.id === id
})
if (win) {
node = win.node
}
} else {
element.setAttribute("id", id)
this.root.appendChild(element)
node = createRoot(element)
win = {
id: id,
node: node,
}
this.windows.push(win)
}
if (useFrame) {
fragment = <DefaultWindow>
{fragment}
</DefaultWindow>
}
if (closeOnClickOutside) {
document.addEventListener("click", (e) => this.handleWrapperClick(id, e))
}
node.render(React.cloneElement(fragment, {
close: () => {
this.close(id)
}
}))
return element
}
/**
* Closes the window with the given ID.
*
* @param {string} id - The ID of the window to be closed.
* @return {boolean} Returns true if the window was successfully closed, false otherwise.
*/
close(id) {
const element = document.getElementById(id)
const win = this.windows.find((node) => {
return node.id === id
})
if (!win) {
this.console.warn(`Window ${id} not found`)
return false
}
win.node.unmount()
this.root.removeChild(element)
this.windows = this.windows.filter((node) => {
return node.id !== id
})
return true
}
}

View File

@ -0,0 +1,98 @@
import React from "react"
export default (to_user_id) => {
const [socket, setSocket] = React.useState(null)
const [messages, setMessages] = React.useState([])
const [scroller, setScroller] = React.useState(null)
const [isLocalTyping, setIsLocalTyping] = React.useState(false)
const [isRemoteTyping, setIsRemoteTyping] = React.useState(false)
const [timeoutOffTypingEvent, setTimeoutOffTypingEvent] = React.useState(null)
async function sendMessage(message) {
emitTypingEvent(false)
await socket.emit("chat:send:message", {
to_user_id: to_user_id,
content: message,
})
}
async function emitTypingEvent(to) {
if (isLocalTyping === true && to === true) {
return debouncedOffTypingEvent()
}
await socket.emit("chat:state:typing", {
to_user_id: to_user_id,
is_typing: to,
})
setIsLocalTyping(to)
}
async function debouncedOffTypingEvent() {
if (timeoutOffTypingEvent) {
clearTimeout(timeoutOffTypingEvent)
}
setTimeoutOffTypingEvent(setTimeout(() => {
emitTypingEvent(false)
}, 5000))
}
const listenEvents = {
"chat:receive:message": (message) => {
setMessages((messages) => {
return [
...messages,
message
]
})
},
"chat:state:typing": (state) => {
setIsRemoteTyping(state.is_typing)
},
}
React.useEffect(() => {
if (scroller?.current) {
const paddingBottom = scroller.current.style.paddingBottom.replace("px", "")
scroller.current?.scrollTo({
top: scroller.current.scrollHeight + paddingBottom,
behavior: "smooth",
})
}
}, [messages])
React.useEffect(() => {
const targetSocket = app.cores.api.client().sockets.chats
setSocket(targetSocket)
for (const [event, handler] of Object.entries(listenEvents)) {
targetSocket.on(event, handler)
}
return () => {
for (const [event, handler] of Object.entries(listenEvents)) {
targetSocket.off(event, handler)
}
if (timeoutOffTypingEvent) {
clearTimeout(timeoutOffTypingEvent)
}
}
}, [])
return {
sendMessage,
messages,
setMessages,
setScroller,
emitTypingEvent,
isRemoteTyping,
}
}

View File

@ -0,0 +1,70 @@
import React from "react"
import { EventEmitter } from "@foxify/events"
function useTextRoom(route, options = {
persistent: false,
}) {
const eventEmitter = new EventEmitter()
const [lines, setLines] = React.useState([])
const socket = app.cores.api.client().sockets.chats
function pushToLines(line) {
setLines((lines) => {
return [
...lines,
line,
]
})
}
function deleteLine(message) {
setLines((lines) => {
return lines.filter((line) => line._id !== message._id)
})
}
function send(payload) {
socket.emit("room:send:message", {
...payload,
route: route,
})
}
const socketEvents = {
"room:message": (message) => {
eventEmitter.emit("room:message", message)
pushToLines(message)
},
"room:delete:message": (message) => {
eventEmitter.emit("room:delete:message", message)
deleteLine(message)
}
}
React.useEffect(() => {
socket.emit("join:room", {
...options,
room: route,
})
for (const [event, handler] of Object.entries(socketEvents)) {
socket.on(event, handler)
}
return () => {
socket.emit("leave:room", {
room: route,
})
for (const [event, handler] of Object.entries(socketEvents)) {
socket.off(event, handler)
}
}
}, [])
return [send, lines, setLines, eventEmitter]
}
export default useTextRoom

View File

@ -44,14 +44,7 @@ export default class Layout extends React.PureComponent {
}
transitionLayer.classList.remove("fade-opacity-leave")
},
"router.navigate": async (path, options) => {
this.progressBar.start()
await this.makePageTransition(options)
this.progressBar.done()
},
}
}
componentDidMount() {
@ -82,33 +75,6 @@ export default class Layout extends React.PureComponent {
this.setState({ renderError: { info, stack } })
}
async makePageTransition(options = {}) {
if (document.startViewTransition) {
return document.startViewTransition(async () => {
await new Promise((resolve) => {
setTimeout(resolve, options.state?.transitionDelay ?? 250)
})
})
}
const content_layout = document.getElementById("content_layout")
if (!content_layout) {
console.warn("content_layout not found, no animation will be played")
return false
}
content_layout.classList.add("fade-transverse-leave")
return await new Promise((resolve) => {
setTimeout(() => {
resolve()
content_layout.classList.remove("fade-transverse-leave")
}, options.state?.transitionDelay ?? 250)
})
}
layoutInterface = window.app.layout = {
set: (layout) => {
if (typeof Layouts[layout] !== "function") {

View File

@ -8,7 +8,7 @@ import { Icons, createIconRender } from "@components/Icons"
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
import { QuickNavMenuItems, QuickNavMenu } from "@components/Layout/quickNav"
import { QuickNavMenuItems, QuickNavMenu } from "@layouts/components/quickNav"
import PlayerView from "@pages/@mobile-views/player"
import CreatorView from "@pages/@mobile-views/creator"

View File

@ -1,132 +0,0 @@
import React from "react"
import classnames from "classnames"
import { DOMWindow } from "@components/RenderWindow"
import "./index.less"
class FloatingStackItem extends React.PureComponent {
state = {
renderError: null
}
componentDidCatch(error, info) {
console.log(error, info)
this.setState({
renderError: error,
})
}
render() {
if (this.state.renderError) {
return <div className="floating_stack_item">
<h1>Render Error</h1>
</div>
}
return <div className="floating_stack_item" key={this.props.id} id={this.props.id}>
<React.Fragment>
{this.props.children}
</React.Fragment>
</div>
}
}
export default class FloatingStack extends React.Component {
state = {
renders: [],
globalVisibility: true,
}
public = {
add: (id, render) => {
try {
if (!id) {
console.error(`FloatingStack: id is required`)
return false
}
if (!render) {
console.error(`FloatingStack: render is required`)
return false
}
if (this.state.renders.find((item) => item.id === id)) {
console.error(`FloatingStack: id ${id} already exists`)
return false
}
this.setState({
renders: [
...this.state.renders,
{
id,
render: React.createElement(render),
},
]
})
return render
} catch (error) {
console.log(error)
return null
}
},
remove: (id) => {
this.setState({
renders: this.state.renders.filter((item) => {
return item.id !== id
})
})
return true
},
toggleGlobalVisibility: (to) => {
if (typeof to !== "boolean") {
to = !this.state.globalVisibility
}
this.setState({
globalVisibility: to,
})
}
}
componentDidMount() {
window.app.layout.floatingStack = this.public
}
componentWillUnmount() {
window.app.layout.floatingStack = null
delete window.app.layout.floatingStack
}
render() {
return <div
className={classnames(
"floating_stack",
{
["hidden"]: !this.state.globalVisibility,
}
)}
>
{
this.state.renders.map((item) => {
return <FloatingStackItem id={item.id}>
{item.render}
</FloatingStackItem>
})
}
</div>
}
}
export const createWithDom = () => {
const dom = new DOMWindow({
id: "FloatingStack",
})
dom.render(<FloatingStack />)
return dom
}

View File

@ -1,30 +0,0 @@
.floating_stack {
position: absolute;
z-index: 300;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: 450px;
gap: 20px;
margin: 20px;
transition: all 0.3s ease-in-out;
&.hidden {
transform: translateX(100%);
opacity: 0;
}
}
.floating_stack_item {
width: 100%;
}

View File

@ -12,7 +12,9 @@
width: 100%;
.page_header {
display: block;
display: flex;
flex-direction: column;
margin: 10px 0 20px 0;
padding: 5px;

View File

@ -3,7 +3,6 @@ import { Modal as AntdModal } from "antd"
import classnames from "classnames"
import { Icons } from "@components/Icons"
import { DOMWindow } from "@components/RenderWindow"
import useLayoutInterface from "@hooks/useLayoutInterface"
@ -118,8 +117,6 @@ export default () => {
render,
{
framed = true,
frameContentStyle = null,
includeCloseButton = false,
confirmOnOutsideClick = false,
confirmOnClickTitle,
@ -129,20 +126,13 @@ export default () => {
props,
} = {}
) {
const win = new DOMWindow({
id: id,
className: className,
})
win.render(<Modal
app.cores.window_mng.render(id, <Modal
ref={modalRef}
win={win}
onClose={() => {
win.destroy()
app.cores.window_mng.close(id)
}}
includeCloseButton={includeCloseButton}
framed={framed}
frameContentStyle={frameContentStyle}
className={className}
confirmOnOutsideClick={confirmOnOutsideClick}
confirmOnClickTitle={confirmOnClickTitle}
confirmOnClickContent={confirmOnClickContent}

View File

@ -43,7 +43,7 @@
}
&.active {
background-color: rgba(var(--bg_color_6), 0.5);
background-color: rgba(var(--bg_color_6), 0.1);
backdrop-filter: blur(@modal_background_blur);
-webkit-backdrop-filter: blur(@modal_background_blur);

View File

@ -31,6 +31,9 @@ const onClickHandlers = {
search: () => {
window.app.controls.openSearcher()
},
messages: () => {
window.app.controls.openMessages()
},
create: () => {
window.app.controls.openCreator()
},
@ -63,6 +66,37 @@ const generateTopItems = (extra = []) => {
})
}
const BottomMenuDefaultItems = [
{
key: "search",
label: <Translation>
{(t) => t("Search")}
</Translation>,
icon: <Icons.Search />,
},
{
key: "messages",
label: <Translation>
{(t) => t("Messages")}
</Translation>,
icon: <Icons.MessageCircle />,
},
{
key: "notifications",
label: <Translation>
{(t) => t("Notifications")}
</Translation>,
icon: <Icons.Bell />,
},
{
key: "settings",
label: <Translation>
{(t) => t("Settings")}
</Translation>,
icon: <Icons.Settings />,
}
]
const ActionMenuItems = [
{
key: "account",
@ -268,7 +302,7 @@ export default class Sidebar extends React.Component {
}
handleClick = (e) => {
if (e.item.props.ignoreClick) {
if (e.item.props.ignore_click === "true") {
return
}
@ -331,11 +365,48 @@ export default class Sidebar extends React.Component {
}
}
getBottomItems = () => {
const items = [
...BottomMenuDefaultItems,
...this.state.bottomItems,
]
if (app.userData) {
items.push({
key: "account",
ignore_click: "true",
className: "user_avatar",
label: <Dropdown
menu={{
items: ActionMenuItems,
onClick: this.onClickDropdownItem
}}
autoFocus
placement="top"
trigger={["click"]}
>
<Avatar shape="square" src={app.userData?.avatar} />
</Dropdown>,
})
}
if (!app.userData) {
items.push({
key: "login",
label: <Translation>
{t => t("Login")}
</Translation>,
icon: <Icons.LogIn />,
})
}
return items
}
render() {
const defaultSelectedKey = window.location.pathname.replace("/", "")
return <>
<Motion style={{
return <Motion style={{
x: spring(!this.state.visible ? 100 : 0),
}}>
{({ x }) => {
@ -398,77 +469,13 @@ export default class Sidebar extends React.Component {
selectable={false}
mode="inline"
onClick={this.handleClick}
>
{
this.state.bottomItems.map((item) => {
if (item.noContainer) {
return React.createElement(item.children, item.childrenProps)
}
items={this.getBottomItems()}
return <Menu.Item
key={item.id}
className="extra_bottom_item"
icon={createIconRender(item.icon)}
disabled={item.disabled ?? false}
{...item.containerProps}
>
{
React.createElement(item.children, item.childrenProps)
}
</Menu.Item>
})
}
<Menu.Item key="search" icon={<Icons.Search />} >
<Translation>
{(t) => t("Search")}
</Translation>
</Menu.Item>
<Menu.Item key="notifications" icon={<Icons.Bell />}>
<Translation>
{t => t("Notifications")}
</Translation>
</Menu.Item>
<Menu.Item key="settings" icon={<Icons.Settings />}>
<Translation>
{t => t("Settings")}
</Translation>
</Menu.Item>
{
app.userData && <Dropdown
menu={{
items: ActionMenuItems,
onClick: this.onClickDropdownItem
}}
autoFocus
placement="top"
trigger={["click"]}
//onOpenChange={this.onDropdownOpenChange}
>
<Menu.Item
key="account"
className="user_avatar"
ignoreClick
onDoubleClick={onClickHandlers.account}
>
<Avatar shape="square" src={app.userData?.avatar} />
</Menu.Item>
</Dropdown>
}
{
!app.userData && <Menu.Item key="login" icon={<Icons.LogIn />}>
<Translation>
{t => t("Login")}
</Translation>
</Menu.Item>
}
</Menu>
/>
</div>
</div>
</div>
}}
</Motion>
</>
}
}

View File

@ -173,14 +173,26 @@
&.user_avatar {
.ant-menu-title-content {
width: 100%;
display: inline-flex;
align-items: flex-start;
justify-content: center;
width: fit-content;
opacity: 1;
.ant-dropdown-trigger {
width: 100%;
img {
width: fit-content;
border-radius: 10px;
}
}
}
padding: 0 !important;
}

View File

@ -6,7 +6,7 @@
top: 0;
right: 0;
max-width: 20vw;
max-width: 420px;
min-width: 320px;
height: 100vh;

View File

@ -2,32 +2,20 @@ import React from "react"
import classnames from "classnames"
import { Layout } from "antd"
import {
Sidebar,
Drawer,
Sidedrawer,
BottomBar,
TopBar,
ToolsBar,
Header,
} from "@components/Layout"
import Sidebar from "@layouts/components/sidebar"
import Drawer from "@layouts/components/drawer"
import Sidedrawer from "@layouts/components/sidedrawer"
import BottomBar from "@layouts/components/bottomBar"
import TopBar from "@layouts/components/topBar"
import ToolsBar from "@layouts/components/toolsBar"
import Header from "@layouts/components/header"
import InitializeModalsController from "@layouts/components/modals"
import BackgroundDecorator from "@components/BackgroundDecorator"
import { createWithDom as FloatingStack } from "../components/floatingStack"
import InitializeModalsController from "../components/modals"
const DesktopLayout = (props) => {
InitializeModalsController()
React.useEffect(() => {
const floatingStack = FloatingStack()
return () => {
floatingStack.remove()
}
}, [])
return <>
<BackgroundDecorator />

View File

@ -2,7 +2,8 @@ import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { Drawer, Sidedrawer } from "@components/Layout"
import Drawer from "@layouts/components/drawer"
import Sidedrawer from "@layouts/components/sidedrawer"
export default (props) => {
return <antd.Layout className={classnames("app_layout")} style={{ height: "100%" }}>

View File

@ -94,7 +94,6 @@ export default class Account extends React.Component {
})
}
onClickFollow = async () => {
const result = await FollowsModel.toggleFollow({
user_id: this.state.user._id,
@ -183,6 +182,13 @@ export default class Account extends React.Component {
followed={this.state.following}
self={this.state.isSelf}
/>
{
!this.state.isSelf && <antd.Button
icon={<Icons.MdMessage />}
onClick={() => app.location.push(`/messages/${user._id}`)}
/>
}
</div>
</div>

View File

@ -108,7 +108,9 @@
.actions {
display: flex;
flex-direction: column;
flex-direction: row;
gap: 10px;
height: fit-content;
width: 20vw;

View File

@ -0,0 +1,196 @@
import React from "react"
import { Tag } from "antd"
import classnames from "classnames"
import Marquee from "react-fast-marquee"
import { Icons } from "@components/Icons"
import Controls from "@components/Player/Controls"
import { Context } from "@contexts/WithPlayerContext"
function isOverflown(element) {
if (!element) {
return false
}
return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
}
const RenderArtist = (props) => {
const { artist } = props
if (!artist) {
return null
}
if (Array.isArray(artist)) {
return <h3>{artist.join(",")}</h3>
}
return <h3>{artist}</h3>
}
const RenderAlbum = (props) => {
const { album } = props
if (!album) {
return null
}
if (Array.isArray(album)) {
return <h3>{album.join(",")}</h3>
}
return <h3>{album}</h3>
}
const PlayerController = React.forwardRef((props, ref) => {
const context = React.useContext(Context)
const titleRef = React.useRef()
const [titleIsOverflown, setTitleIsOverflown] = React.useState(false)
const [currentTime, setCurrentTime] = React.useState(0)
const [trackDuration, setTrackDuration] = React.useState(0)
const [draggingTime, setDraggingTime] = React.useState(false)
const [currentDragWidth, setCurrentDragWidth] = React.useState(0)
const [syncInterval, setSyncInterval] = React.useState(null)
async function onDragEnd(seekTime) {
setDraggingTime(false)
app.cores.player.seek(seekTime)
}
async function syncPlayback() {
if (!context.track_manifest) {
return false
}
const currentTrackTime = app.cores.player.seek()
setCurrentTime(currentTrackTime)
}
//* Handle when playback status change
React.useEffect(() => {
if (context.playback_status === "playing") {
setSyncInterval(setInterval(syncPlayback, 1000))
} else {
if (syncInterval) {
clearInterval(syncInterval)
}
}
}, [context.playback_status])
React.useEffect(() => {
setTitleIsOverflown(isOverflown(titleRef.current))
setTrackDuration(app.cores.player.duration())
}, [context.track_manifest])
const isStopped = context.playback_status === "stopped"
return <div
className="lyrics-player-controller-wrapper"
>
<div className="lyrics-player-controller">
<div className="lyrics-player-controller-info">
<div className="lyrics-player-controller-info-title">
{
<h4
ref={titleRef}
className={classnames(
"lyrics-player-controller-info-title-text",
{
["overflown"]: titleIsOverflown,
}
)}
>
{
context.playback_status === "stopped" ? "Nothing is playing" : <>
{context.track_manifest?.title ?? "Nothing is playing"}
</>
}
</h4>
}
{
titleIsOverflown && <Marquee
//gradient
//gradientColor={bgColor}
//gradientWidth={20}
play={!isStopped}
>
<h4>
{
isStopped ?
"Nothing is playing" :
<>
{context.track_manifest?.title ?? "Untitled"}
</>
}
</h4>
</Marquee>
}
</div>
<div className="lyrics-player-controller-info-details">
<RenderArtist artist={context.track_manifest?.artists} />
-
<RenderAlbum album={context.track_manifest?.album} />
</div>
</div>
<Controls />
<div className="lyrics-player-controller-progress-wrapper">
<div
className="lyrics-player-controller-progress"
onMouseDown={(e) => {
setDraggingTime(true)
}}
onMouseUp={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const seekTime = trackDuration * (e.clientX - rect.left) / rect.width
onDragEnd(seekTime)
}}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const atWidth = (e.clientX - rect.left) / rect.width * 100
setCurrentDragWidth(atWidth)
}}
>
<div className="lyrics-player-controller-progress-bar"
style={{
width: `${draggingTime ? currentDragWidth : ((currentTime / trackDuration) * 100)}%`
}}
/>
</div>
</div>
<div className="lyrics-player-controller-tags">
{
context.track_manifest?.metadata.lossless && <Tag
color="geekblue"
icon={<Icons.TbWaveSine />}
bordered={false}
>
Lossless
</Tag>
}
{
context.track_manifest?.explicit && <Tag
bordered={false}
>
Explicit
</Tag>
}
</div>
</div>
</div>
})
export default PlayerController

View File

@ -0,0 +1,147 @@
import React from "react"
import classnames from "classnames"
import { Motion, spring } from "react-motion"
import { Context } from "@contexts/WithPlayerContext"
const LyricsText = React.forwardRef((props, textRef) => {
const context = React.useContext(Context)
const { lyrics } = props
const [syncInterval, setSyncInterval] = React.useState(null)
const [currentLineIndex, setCurrentLineIndex] = React.useState(0)
const [visible, setVisible] = React.useState(false)
function syncPlayback() {
if (!lyrics) {
return false
}
const currentTrackTime = app.cores.player.seek() * 1000
const lineIndex = lyrics.lrc.findIndex((line) => {
return currentTrackTime >= line.startTimeMs && currentTrackTime <= line.endTimeMs
})
if (lineIndex === -1) {
if (!visible) {
setVisible(false)
}
return false
}
const line = lyrics.lrc[lineIndex]
setCurrentLineIndex(lineIndex)
if (line.break) {
return setVisible(false)
}
if (line.text) {
return setVisible(true)
}
}
function startSyncInterval() {
setSyncInterval(setInterval(syncPlayback, 100))
}
//* Handle when current line index change
React.useEffect(() => {
if (currentLineIndex === 0) {
setVisible(false)
} else {
// find line element by id
const lineElement = textRef.current.querySelector(`#lyrics-line-${currentLineIndex}`)
// center scroll to current line
if (lineElement) {
lineElement.scrollIntoView({
behavior: "smooth",
block: "center",
})
}
}
}, [currentLineIndex])
//* Handle when playback status change
React.useEffect(() => {
if (lyrics) {
if (typeof lyrics?.lrc !== "undefined") {
if (context.playback_status === "playing") {
startSyncInterval()
} else {
if (syncInterval) {
clearInterval(syncInterval)
}
} startSyncInterval()
}
}
}, [context.playback_status])
//* Handle when lyrics object change
React.useEffect(() => {
clearInterval(syncInterval)
if (lyrics) {
if (typeof lyrics?.lrc !== "undefined") {
if (context.playback_status === "playing") {
startSyncInterval()
}
}
}
}, [lyrics])
React.useEffect(() => {
return () => {
clearInterval(syncInterval)
}
}, [])
if (!lyrics?.lrc) {
return null
}
return <div
className="lyrics-text-wrapper"
>
<Motion
style={{
opacity: spring(visible ? 1 : 0),
}}
>
{({ opacity }) => {
return <div
ref={textRef}
className="lyrics-text"
style={{
opacity
}}
>
{
lyrics.lrc.map((line, index) => {
return <p
key={index}
id={`lyrics-line-${index}`}
className={classnames(
"line",
{
["current"]: currentLineIndex === index
}
)}
>
{line.text}
</p>
})
}
</div>
}}
</Motion>
</div>
})
export default LyricsText

View File

@ -0,0 +1,166 @@
import React from "react"
import { Context } from "@contexts/WithPlayerContext"
const maxLatencyInMs = 55
const LyricsVideo = React.forwardRef((props, videoRef) => {
const context = React.useContext(Context)
const { lyrics } = props
const [syncInterval, setSyncInterval] = React.useState(null)
const [syncingVideo, setSyncingVideo] = React.useState(false)
const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0)
async function seekVideoToSyncAudio() {
if (lyrics) {
if (lyrics.video_source && typeof lyrics.sync_audio_at_ms !== "undefined") {
const currentTrackTime = app.cores.player.seek()
setSyncingVideo(true)
videoRef.current.currentTime = currentTrackTime + (lyrics.sync_audio_at_ms / 1000) + app.cores.player.gradualFadeMs / 1000
}
}
}
async function syncPlayback() {
if (!lyrics) {
return false
}
// if `sync_audio_at_ms` is present, it means the video must be synced with audio
if (lyrics.video_source && typeof lyrics.sync_audio_at_ms !== "undefined") {
const currentTrackTime = app.cores.player.seek()
const currentVideoTime = videoRef.current.currentTime - (lyrics.sync_audio_at_ms / 1000)
//console.log(`Current track time: ${currentTrackTime}, current video time: ${currentVideoTime}`)
const maxOffset = maxLatencyInMs / 1000
const currentVideoTimeDiff = Math.abs(currentVideoTime - currentTrackTime)
setCurrentVideoLatency(currentVideoTimeDiff)
if (syncingVideo === true) {
console.log(`Syncing video...`)
return false
}
if (currentVideoTimeDiff > maxOffset) {
console.warn(`Video offset exceeds`, maxOffset)
seekVideoToSyncAudio()
}
}
}
function startSyncInterval() {
setSyncInterval(setInterval(syncPlayback, 100))
}
React.useEffect(() => {
videoRef.current.addEventListener("seeked", (event) => {
setSyncingVideo(false)
})
// videoRef.current.addEventListener("error", (event) => {
// console.log("Failed to load", event)
// })
// videoRef.current.addEventListener("ended", (event) => {
// console.log("Video ended", event)
// })
// videoRef.current.addEventListener("stalled", (event) => {
// console.log("Failed to fetch data, but trying")
// })
// videoRef.current.addEventListener("waiting", (event) => {
// console.log("Waiting for data...")
// })
}, [])
//* Handle when playback status change
React.useEffect(() => {
if (typeof lyrics?.sync_audio_at_ms !== "undefined") {
if (context.playback_status === "playing") {
videoRef.current.play()
setSyncInterval(setInterval(syncPlayback, 500))
} else {
videoRef.current.pause()
if (syncInterval) {
clearInterval(syncInterval)
}
}
}
}, [context.playback_status])
//* Handle when lyrics object change
React.useEffect(() => {
if (lyrics) {
clearInterval(syncInterval)
setCurrentVideoLatency(0)
setSyncingVideo(false)
if (lyrics.video_source) {
videoRef.current.src = lyrics.video_source
videoRef.current.load()
if (typeof lyrics.sync_audio_at_ms !== "undefined") {
videoRef.current.currentTime = lyrics.sync_audio_at_ms / 1000
if (context.playback_status === "playing") {
videoRef.current.play()
startSyncInterval()
} else {
videoRef.current.pause()
}
const currentTime = app.cores.player.seek()
if (currentTime > 0) {
seekVideoToSyncAudio()
}
} else {
videoRef.current.loop = true
videoRef.current.play()
}
}
}
}, [lyrics])
React.useEffect(() => {
clearInterval(syncInterval)
return () => {
clearInterval(syncInterval)
}
}, [])
return <>
<div className="videoDebugOverlay">
<div>
<p>Maximun latency</p>
<p>{maxLatencyInMs}ms</p>
</div>
<div>
<p>Video Latency</p>
<p>{(currentVideoLatency * 1000).toFixed(2)}ms</p>
</div>
{syncingVideo ? <p>Syncing video...</p> : null}
</div>
<video
className="lyrics-video"
ref={videoRef}
controls={false}
muted
preload="auto"
/>
</>
})
export default LyricsVideo

758
packages/app/src/pages/lyrics/index.jsx Executable file → Normal file
View File

@ -1,750 +1,76 @@
import React from "react"
import classnames from "classnames"
import Marquee from "react-fast-marquee"
import Image from "@components/Image"
import Controls from "@components/Player/Controls"
import useMaxScreen from "@utils/useMaxScreen"
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
import request from "comty.js/handlers/request"
import MusicService from "@models/music"
import PlayerController from "./components/controller"
import LyricsVideo from "./components/video"
import LyricsText from "./components/text"
import "./index.less"
function composeRgbValues(values) {
let value = ""
const EnchancedLyrics = (props) => {
const context = React.useContext(Context)
const [lyrics, setLyrics] = React.useState(null)
// only get the first 3 values
for (let i = 0; i < 3; i++) {
// if last value, don't add comma
if (i === 2) {
value += `${values[i]}`
continue
}
const videoRef = React.useRef()
const textRef = React.useRef()
value += `${values[i]}, `
}
async function loadLyrics(track_id) {
const result = await MusicService.getTrackLyrics(track_id)
return value
}
function calculateLineTime(line) {
if (!line) {
return 0
}
return line.endTimeMs - line.startTimeMs
}
function isOverflown(element) {
if (!element) {
return false
}
return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
}
class PlayerController extends React.Component {
state = {
colorAnalysis: null,
currentDragWidth: 0,
titleOverflown: false,
currentDuration: 0,
currentTime: 0,
currentPlaying: app.cores.player.state["track_manifest"],
loading: app.cores.player.state["loading"] ?? false,
playbackStatus: app.cores.player.state["playback_status"] ?? "stopped",
audioMuted: app.cores.player.state["muted"] ?? false,
volume: app.cores.player.state["volume"],
syncModeLocked: app.cores.player.state["control_locked"] ?? false,
syncMode: app.cores.player.state["sync_mode"],
}
events = {
"player.seeked": (seekTime) => {
this.setState({
currentTime: seekTime,
})
},
"player.state.update:playback_status": (data) => {
this.setState({ playbackStatus: data })
},
"player.state.update:track_manifest": (data) => {
this.setState({ titleOverflown: false })
this.setState({ currentPlaying: data })
},
"player.state.update:control_locked": (to) => {
this.setState({ syncModeLocked: to })
},
"player.state.update:sync_mode": (to) => {
this.setState({ syncMode: to })
},
"player.state.update:muted": (data) => {
this.setState({ audioMuted: data })
},
"player.state.update:volume": (data) => {
this.setState({ audioVolume: data })
},
"player.state.update:loading": (data) => {
this.setState({ loading: data })
},
}
titleRef = React.createRef()
startSync() {
// create a interval to get state from player
if (this.syncInterval) {
clearInterval(this.syncInterval)
}
this.syncInterval = setInterval(() => {
const time = app.cores.player.seek()
const duration = app.cores.player.duration()
this.setState({
currentDuration: duration,
currentTime: time,
colorAnalysis: app.cores.player.state.track_manifest?.metadata.cover_analysis,
})
const titleOverflown = isOverflown(this.titleRef.current)
this.setState({ titleOverflown: titleOverflown })
}, 800)
}
onClickPreviousButton = () => {
app.cores.player.playback.previous()
}
onClickNextButton = () => {
app.cores.player.playback.next()
}
onClicktogglePlayButton = () => {
if (this.state?.playbackStatus === "playing") {
app.cores.player.playback.pause()
} else {
app.cores.player.playback.play()
if (result) {
setLyrics(result)
}
}
updateVolume = (value) => {
app.cores.player.volume(value)
useMaxScreen()
//* Handle when context change track_manifest
React.useEffect(() => {
if (context.track_manifest) {
loadLyrics(context.track_manifest._id)
}
}, [context.track_manifest])
toggleMute = () => {
app.cores.player.toggleMute()
}
//* Handle when lyrics data change
React.useEffect(() => {
console.log(lyrics)
}, [lyrics])
componentDidMount() {
for (const event in this.events) {
app.eventBus.on(event, this.events[event])
}
if (this.syncInterval) {
clearInterval(this.syncInterval)
}
this.startSync()
}
componentWillUnmount() {
for (const event in this.events) {
app.eventBus.off(event, this.events[event])
}
if (this.syncInterval) {
clearInterval(this.syncInterval)
}
}
onDragEnd = (seekTime) => {
this.setState({
currentDragWidth: 0,
dragging: false,
})
app.cores.player.seek(seekTime)
}
render() {
//const bgColor = RGBStringToValues(getComputedStyle(document.documentElement).getPropertyValue("--background-color-accent-values"))
return <div className="player_controller_wrapper">
<div
return <div
className={classnames(
"player_controller",
)}
>
<div className="player_controller_cover">
<Image
src={this.state.currentPlaying?.cover ?? this.state.currentPlaying?.thumbnail ?? "/assets/no_song.png"}
/>
</div>
<div className="player_controller_left">
<div className="player_controller_info">
<div className="player_controller_info_title">
"lyrics",
{
<h4
ref={this.titleRef}
className={classnames(
"player_controller_info_title_text",
{
["overflown"]: this.state.titleOverflown,
["stopped"]: context.playback_status !== "playing",
}
)}
>
{
this.state.plabackState === "stopped" ? "Nothing is playing" : <>
{this.state.currentPlaying?.title ?? "Nothing is playing"}
</>
}
</h4>
}
<LyricsVideo
ref={videoRef}
lyrics={lyrics}
/>
{this.state.titleOverflown &&
<Marquee
//gradient
//gradientColor={bgColor}
//gradientWidth={20}
play={this.state.plabackState !== "stopped"}
>
<h4>
{
this.state.plabackState === "stopped" ? "Nothing is playing" : <>
{this.state.currentPlaying?.title ?? "Nothing is playing"}
</>
}
</h4>
</Marquee>}
</div>
<div className="player_controller_info_artist">
{
(this.state.currentPlaying?.metadata?.artist ?? this.state.currentPlaying?.artist) && <>
<h3>
{this.state.currentPlaying?.metadata?.artist ?? this.state.currentPlaying?.artist ?? "Unknown"}
</h3>
{
(this.state.currentPlaying?.metadata?.album ?? this.state.currentPlaying?.album) && <>
<span> - </span>
<h3>
{this.state.currentPlaying?.metadata?.album ?? this.state.currentPlaying?.album ?? "Unknown"}
</h3>
</>
}
</>
}
</div>
</div>
<LyricsText
ref={textRef}
lyrics={lyrics}
/>
<PlayerController
<Controls
className="player_controller_controls"
controls={{
previous: this.onClickPreviousButton,
toggle: this.onClicktogglePlayButton,
next: this.onClickNextButton,
}}
syncModeLocked={this.state.syncModeLocked}
playbackStatus={this.state.playbackStatus}
loading={this.state.loading}
audioVolume={this.state.audioVolume}
audioMuted={this.state.audioMuted}
onVolumeUpdate={this.updateVolume}
onMuteUpdate={this.toggleMute}
/>
</div>
<div className="player_controller_progress_wrapper">
<div
className="player_controller_progress"
onMouseDown={(e) => {
this.setState({
dragging: true,
})
}}
onMouseUp={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const seekTime = this.state.currentDuration * (e.clientX - rect.left) / rect.width
this.onDragEnd(seekTime)
}}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const atWidth = (e.clientX - rect.left) / rect.width * 100
this.setState({ currentDragWidth: atWidth })
}}
>
<div className="player_controller_progress_bar"
style={{
width: `${this.state.dragging ? this.state.currentDragWidth : this.state.currentTime / this.state.currentDuration * 100}%`
}}
/>
</div>
</div>
</div>
</div>
}
}
export default (props) => {
const EnchancedLyricsPage = (props) => {
return <WithPlayerContext>
<SyncLyrics
<EnchancedLyrics
{...props}
/>
</WithPlayerContext>
}
class SyncLyrics extends React.Component {
static contextType = Context
state = {
loading: true,
notAvailable: false,
currentManifest: null,
currentStatus: null,
canvas_url: null,
lyrics: null,
currentLine: null,
colorAnalysis: null,
classnames: {
"cinematic-mode": false,
"centered-player": false,
"video-canvas-enabled": false,
}
}
visualizerRef = React.createRef()
videoCanvasRef = React.createRef()
coverCanvasRef = React.createRef()
events = {
"player.state.update:track_manifest": (currentManifest) => {
console.log(currentManifest)
this.setState({ currentManifest })
if (document.startViewTransition) {
document.startViewTransition(this.loadLyrics)
} else {
this.loadLyrics()
}
},
"player.state.update:playback_status": (currentStatus) => {
this.setState({ currentStatus })
}
}
toggleClassName = (className, to) => {
if (typeof to === "undefined") {
to = !this.state.classnames[className]
}
if (to) {
if (this.state.classnames[className] === true) {
return false
}
//app.message.info("Toogling on " + className)
this.setState({
classnames: {
...this.state.classnames,
[className]: true
},
})
return true
} else {
if (this.state.classnames[className] === false) {
return false
}
//app.message.info("Toogling off " + className)
this.setState({
classnames: {
...this.state.classnames,
[className]: false
},
})
return true
}
}
toggleVideoCanvas = (to) => {
return this.toggleClassName("video-canvas-enabled", to)
}
toggleCenteredControllerMode = (to) => {
return this.toggleClassName("centered-player", to)
}
toggleCinematicMode = (to) => {
return this.toggleClassName("cinematic-mode", to)
}
isCurrentLine = (line) => {
if (!this.state.currentLine) {
return false
}
return this.state.currentLine.startTimeMs === line.startTimeMs
}
loadLyrics = async () => {
if (this.syncInterval) {
clearInterval(this.syncInterval)
}
if (!this.context.track_manifest) {
return false
}
this.setState({
loading: true,
notAvailable: false,
lyrics: null,
currentLine: null,
canvas_url: null,
})
const api = app.cores.api.instance().instances.music
let response = await request({
instance: api,
method: "get",
url: `/lyrics/${this.state.currentManifest._id}`,
}).catch((err) => {
console.error(err)
this.setState({
notAvailable: true,
})
return {}
})
let data = response.data ?? {
lines: [],
syncType: null,
}
console.log(this.state.currentManifest)
console.log(data)
if (data.lines.length > 0 && data.syncType === "LINE_SYNCED") {
data.lines = data.lines.map((line, index) => {
const ref = React.createRef()
line.ref = ref
line.startTimeMs = Number(line.startTimeMs)
const nextLine = data.lines[index + 1]
// calculate end time
line.endTimeMs = nextLine ? Number(nextLine.startTimeMs) : Math.floor(app.cores.player.duration() * 1000)
return line
})
}
if (data.canvas_url) {
//app.message.info("Video canvas loaded")
console.log(`[SyncLyrics] Video canvas loaded`)
this.toggleVideoCanvas(true)
} else {
//app.message.info("No video canvas available for this song")
console.log(`[SyncLyrics] No video canvas available for this song`)
this.toggleVideoCanvas(false)
}
// if has no lyrics or are unsynced, toggle cinematic mode off and center controller
if (data.lines.length === 0 || data.syncType !== "LINE_SYNCED") {
//app.message.info("No lyrics available for this song")
console.log(`[SyncLyrics] No lyrics available for this song, sync type [${data.syncType}]`)
this.toggleCinematicMode(false)
this.toggleCenteredControllerMode(true)
} else {
//app.message.info("Lyrics loaded, starting sync...")
console.log(`[SyncLyrics] Starting sync with type [${data.syncType}]`)
this.toggleCenteredControllerMode(false)
this.startLyricsSync()
}
// transform times
this.setState({
loading: false,
syncType: data.syncType,
canvas_url: data.canvas_url ?? null,
lyrics: data.lines,
})
}
startLyricsSync = () => {
// create interval to sync lyrics
if (this.syncInterval) {
clearInterval(this.syncInterval)
}
// scroll to top
this.visualizerRef.current.scrollTop = 0
this.syncInterval = setInterval(() => {
if (!this.state.lyrics || !Array.isArray(this.state.lyrics) || this.state.lyrics.length === 0 || !this.state.lyrics[0]) {
console.warn(`Clearing interval because lyrics is not found or lyrics is empty, probably because memory leak or unmounted component`)
clearInterval(this.syncInterval)
return false
}
const time = app.cores.player.seek()
// transform audio seek time to lyrics time (ms from start) // remove decimals
const transformedTime = Math.floor(time * 1000)
const hasStartedFirst = transformedTime >= this.state.lyrics[0].startTimeMs
if (!hasStartedFirst) {
if (this.state.canvas_url) {
this.toggleCinematicMode(true)
}
return false
}
// find the closest line to the transformed time
const line = this.state.lyrics.find((line) => {
// match the closest line to the transformed time
return transformedTime >= line.startTimeMs && transformedTime <= line.endTimeMs
})
if (!line || !line.ref) {
console.warn(`Clearing interval because cannot find line to sync or line REF is not found, probably because memory leak or unmounted component`)
clearInterval(this.syncInterval)
return false
}
if (line) {
if (this.isCurrentLine(line)) {
return false
}
// set current line
this.setState({
currentLine: line,
})
//console.log(line)
if (!line.ref.current) {
console.log(line)
console.warn(`Clearing interval because line CURRENT ref is not found, probably because memory leak or unmounted component`)
clearInterval(this.syncInterval)
return false
}
this.visualizerRef.current.scrollTo({
top: line.ref.current.offsetTop - (this.visualizerRef.current.offsetHeight / 2),
behavior: "smooth",
})
if (this.state.canvas_url) {
if (line.words === "♪" || line.words === "♫" || line.words === " " || line.words === "") {
//console.log(`[SyncLyrics] Toogling cinematic mode on because line is empty`)
this.toggleCinematicMode(true)
} else {
//console.log(`[SyncLyrics] Toogling cinematic mode off because line is not empty`)
this.toggleCinematicMode(false)
}
} else {
if (this.state.classnames["cinematic-mode"] === true) {
this.toggleCinematicMode(false)
}
}
}
}, 100)
}
componentDidMount = async () => {
// register player events
for (const [event, callback] of Object.entries(this.events)) {
app.eventBus.on(event, callback)
}
// get current playback status and time
const {
track_manifest,
playback_status,
} = app.cores.player.state
await this.setState({
currentManifest: track_manifest,
currentStatus: playback_status,
colorAnalysis: track_manifest.cover_analysis,
})
if (app.layout.sidebar) {
app.controls.toggleUIVisibility(false)
}
app.layout.toggleCenteredContent(false)
app.cores.style.compactMode(true)
app.cores.style.applyVariant("dark")
// // request full screen to browser
// if (document.fullscreenEnabled) {
// document.documentElement.requestFullscreen()
// }
// // listen when user exit full screen to exit cinematic mode
// document.addEventListener("fullscreenchange", () => {
// if (!document.fullscreenElement) {
// app.location.back()
// }
// })
window._hacks = {
toggleVideoCanvas: this.toggleVideoCanvas,
toggleCinematicMode: this.toggleCinematicMode,
toggleCenteredControllerMode: this.toggleCenteredControllerMode,
}
await this.loadLyrics()
}
componentWillUnmount() {
// unregister player events
for (const [event, callback] of Object.entries(this.events)) {
app.eventBus.off(event, callback)
}
// clear sync interval
if (this.syncInterval) {
clearInterval(this.syncInterval)
}
delete window._hacks
if (app.layout.sidebar) {
app.controls.toggleUIVisibility(true)
}
app.cores.style.compactMode(false)
app.cores.style.applyInitialVariant()
// // exit full screen
// if (document.fullscreenEnabled) {
// document.exitFullscreen()
// }
}
renderLines() {
if (!this.state.lyrics || this.state.notAvailable || this.state.syncType !== "LINE_SYNCED") {
return null
}
return this.state.lyrics.map((line, index) => {
return <div
ref={line.ref}
className={classnames(
"lyrics_viewer_lines_line",
{
["current"]: this.isCurrentLine(line)
}
)}
id={line.startTimeMs}
key={index}
>
<h2>
{line.words}
</h2>
</div>
})
}
render() {
return <div
ref={this.visualizerRef}
className={classnames(
"lyrics_viewer",
{
["text_dark"]: this.state.colorAnalysis?.isDark ?? false,
...Object.entries(this.state.classnames).reduce((acc, [key, value]) => {
return {
...acc,
[key]: value,
}
}, {}),
},
)}
style={{
"--predominant-color": this.state.colorAnalysis?.hex ?? "unset",
"--predominant-color-rgb-values": this.state.colorAnalysis?.value ? composeRgbValues(this.state.colorAnalysis?.value) : [0, 0, 0],
"--line-time": `${calculateLineTime(this.state.currentLine)}ms`,
"--line-animation-play-state": this.state.currentStatus === "playing" ? "running" : "paused",
}}
>
<div
className="lyrics_viewer_mask"
/>
<div
className="lyrics_viewer_video_canvas"
>
<video
src={this.state.canvas_url}
autoPlay
loop
muted
controls={false}
ref={this.videoCanvasRef}
/>
</div>
<div
className="lyrics_viewer_cover"
>
<Image
src={this.state.currentManifest?.cover ?? this.state.currentManifest?.thumbnail ?? "/assets/no_song.png"}
ref={this.coverRef}
/>
</div>
<PlayerController />
<div className="lyrics_viewer_content">
<div className="lyrics_viewer_lines">
{
this.renderLines()
}
</div>
</div>
</div>
}
}
export default EnchancedLyricsPage

564
packages/app/src/pages/lyrics/index.less Executable file → Normal file
View File

@ -1,405 +1,130 @@
@enabled-video-canvas-opacity: 0.4;
// in px
@cover-width: 150px;
@left-panel-width: 300px;
.lyrics {
position: relative;
z-index: 100;
.lyrics_viewer {
display: flex;
flex-direction: column;
isolation: isolate;
&.stopped {
.lyrics-video {
filter: blur(6px);
}
}
//align-items: center;
.lyrics-video {
z-index: 105;
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
padding: 50px 0;
object-fit: cover;
overflow-y: hidden;
transition: all 150ms ease-in-out;
background-color: rgba(var(--predominant-color-rgb-values), 0.8);
background:
linear-gradient(20deg, rgba(var(--predominant-color-rgb-values), 0.8), rgba(var(--predominant-color-rgb-values), 0.2)),
url(https://grainy-gradients.vercel.app/noise.svg);
//background-size: 1%;
background-position: center;
&.video-canvas-enabled {
background-color: rgba(var(--predominant-color-rgb-values), 1);
.lyrics_viewer_video_canvas {
video {
opacity: @enabled-video-canvas-opacity;
}
}
transition: all 150ms ease-out;
}
&.centered-player {
.lyrics_viewer_cover {
width: 100vw;
.lyrics-text-wrapper {
z-index: 110;
position: fixed;
height: 80vh; //fallback
height: 80dvh;
opacity: 1;
bottom: 20vh;
}
.player_controller_wrapper {
top: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
padding: 60px;
align-items: center;
justify-content: center;
.lyrics-text {
display: flex;
flex-direction: column;
width: 600px;
height: 200px;
padding: 20px;
gap: 30px;
overflow: hidden;
background-color: rgba(var(--background-color-accent-values), 0.6);
border-radius: 12px;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
.line {
font-size: 2rem;
opacity: 0.1;
margin: 0;
.player_controller {
margin-top: 40%;
max-width: 50vw;
max-height: 50vh;
width: 100%;
//height: 100%;
border-radius: 18px;
gap: 0;
padding: 20px 40px;
.player_controller_left {
width: 100%;
max-width: 100%;
min-width: 100%;
}
.player_controller_cover {
width: 0px;
min-width: 0px;
img {
min-width: 0px;
}
}
.player_controller_info {
.player_controller_info_title {
font-size: 3rem;
}
}
}
}
}
&.cinematic-mode {
.lyrics_viewer_mask {
backdrop-filter: blur(0px);
-webkit-backdrop-filter: blur(0px)
}
.lyrics_viewer_video_canvas {
video {
&.current {
opacity: 1;
}
}
.lyrics_viewer_content {
.lyrics_viewer_lines {
opacity: 0;
}
}
}
&.text_dark {
.lyrics_viewer_content {
.lyrics_viewer_lines {
.lyrics_viewer_lines_line {
color: var(--text-color-white);
h2 {
color: var(--text-color-white);
}
}
}
}
}
.lyrics_viewer_mask {
position: absolute;
z-index: 200;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(21px);
-webkit-backdrop-filter: blur(21px)
}
.lyrics_viewer_video_canvas {
position: absolute;
top: 0;
width: 100%;
//height: 100dvh;
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
transition: all 150ms ease-in-out;
video {
width: 100%;
height: 100%;
opacity: 0;
object-fit: cover;
transition: all 150ms ease-in-out;
}
}
.lyrics_viewer_cover {
position: absolute;
.lyrics-player-controller-wrapper {
position: fixed;
z-index: 115;
bottom: 0;
left: 0;
right: 0;
z-index: 250;
padding: 60px;
display: flex;
flex-direction: row;
.lyrics-player-controller {
position: relative;
align-items: center;
justify-content: center;
opacity: 0;
width: 0px;
height: 0px;
transition: all 150ms ease-in-out;
overflow: hidden;
img {
width: 25vw;
height: 25vw;
max-width: 500px;
max-height: 500px;
object-fit: cover;
border-radius: 12px;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2);
}
}
.lyrics_viewer_content {
z-index: 250;
transition: all 150ms ease-in-out;
.lyrics_viewer_lines {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 90%;
gap: 10px;
margin: auto;
width: 300px;
font-family: "Space Grotesk", sans-serif;
transition: all 150ms ease-in-out;
&::after,
&::before {
content: "";
display: block;
width: 100%;
//height: 50dvh;
height: 50vh;
}
.lyrics_viewer_lines_line {
transition: all 150ms ease-in-out;
z-index: 250;
text-wrap: balance;
h2 {
text-wrap: balance;
}
&.current {
margin: 20px 0;
font-size: 2rem;
animation: spacing-letters var(--line-time) ease-in-out forwards;
animation-play-state: var(--line-animation-play-state);
}
}
}
}
}
@keyframes spacing-letters {
0% {
letter-spacing: 0.3rem;
}
100% {
letter-spacing: 0;
}
}
.player_controller_wrapper {
display: flex;
flex-direction: column;
position: absolute;
bottom: 0;
left: 0;
margin: 50px;
z-index: 350;
transition: all 150ms ease-in-out;
.marquee-container {
gap: 60px;
}
.player_controller {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
min-width: 350px;
max-width: 500px;
height: 220px;
background-color: rgba(var(--background-color-accent-values), 0.4);
// background:
// linear-gradient(20deg, rgba(var(--background-color-accent-values), 0.8), transparent),
// url(https://grainy-gradients.vercel.app/noise.svg);
-webkit-backdrop-filter: blur(21px);
backdrop-filter: blur(21px);
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2);
padding: 20px;
padding: 30px;
border-radius: 12px;
gap: 20px;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
color: var(--text-color);
background-color: rgba(var(--background-color-accent-values), 0.8);
transition: all 150ms ease-in-out;
overflow: hidden;
&:hover {
.player_controller_controls {
height: 8vh;
max-height: 100px;
gap: 20px;
.player-controls {
opacity: 1;
height: 30px;
}
.player_controller_progress_wrapper {
bottom: 7px;
.player_controller_progress {
.lyrics-player-controller-tags {
opacity: 1;
height: 10px;
width: 90%;
background-color: var(--background-color-accent);
}
}
}
.player_controller_cover {
.lyrics-player-controller-info {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: @cover-width;
min-width: @cover-width;
max-width: @cover-width;
height: 100%;
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 12px;
}
}
.player_controller_left {
flex: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: @left-panel-width;
transition: all 150ms ease-in-out;
.player_controller_info {
display: flex;
flex-direction: column;
//align-items: flex-start;
width: 100%;
gap: 10px;
transition: all 150ms ease-in-out;
.player_controller_info_title {
.lyrics-player-controller-info-title {
font-size: 1.5rem;
font-weight: 600;
@ -411,7 +136,7 @@
margin: 0;
}
.player_controller_info_title_text {
.lyrics-player-controller-title-text {
transition: all 150ms ease-in-out;
width: 90%;
@ -428,7 +153,7 @@
}
}
.player_controller_info_artist {
.lyrics-player-controller-info-details {
display: flex;
flex-direction: row;
@ -437,120 +162,99 @@
gap: 7px;
font-size: 0.6rem;
font-weight: 400;
// do not wrap text
white-space: nowrap;
h3 {
margin: 0;
}
}
}
.player-controls {
opacity: 0;
height: 0px;
transition: all 150ms ease-in-out;
}
.player_controller_controls {
.lyrics-player-controller-progress-wrapper {
width: 100%;
.lyrics-player-controller-progress {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
width: 100%;
margin: auto;
transition: all 150ms ease-in-out;
border-radius: 12px;
background-color: rgba(var(--background-color-accent-values), 0.8);
&:hover {
.lyrics-player-controller-progress-bar {
height: 10px;
}
}
.lyrics-player-controller-progress-bar {
height: 5px;
background-color: white;
border-radius: 12px;
transition: all 150ms ease-in-out;
}
}
}
.lyrics-player-controller-tags {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 0px;
gap: 10px;
opacity: 0;
transition: all 150ms ease-in-out;
}
}
}
.playButton {
position: relative;
.videoDebugOverlay {
position: fixed;
top: 20px;
right: 20px;
z-index: 115;
display: flex;
align-items: center;
justify-content: center;
.loadCircle {
position: absolute;
z-index: 330;
top: 0;
right: 0;
left: 0;
width: 100%;
height: 100%;
margin: auto;
align-self: center;
justify-self: center;
transform: scale(1.5);
svg {
width: 100%;
height: 100%;
path {
stroke: var(--text-color);
stroke-width: 1;
}
}
}
}
}
.player_controller_progress_wrapper {
position: absolute;
box-sizing: border-box;
bottom: 0;
left: 0;
margin: auto;
width: 100%;
.player_controller_progress {
display: flex;
flex-direction: row;
align-items: center;
height: 5px;
width: 100%;
margin: auto;
transition: all 150ms ease-in-out;
flex-direction: column;
padding: 10px;
border-radius: 12px;
.player_controller_progress_bar {
height: 100%;
background-color: rgba(var(--background-color-accent-values), 0.8);
background-color: var(--background-color-contrast);
border-radius: 12px;
transition: all 150ms ease-in-out;
}
}
}
}
}
@keyframes bottom-to-top {
0% {
bottom: 0;
}
100% {
bottom: 20vh;
width: 200px;
height: fit-content;
}
}

View File

@ -0,0 +1,161 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import UserPreview from "@components/UserPreview"
import useChat from "@hooks/useChat"
import ChatsService from "@models/chats"
import lodash from "lodash"
import "./index.less"
const ChatPage = (props) => {
const { to_user_id } = props.params
const messagesRef = React.useRef()
const [isOnBottomView, setIsOnBottomView] = React.useState(true)
const [currentText, setCurrentText] = React.useState("")
const [L_History, R_History, E_History, M_History] = app.cores.api.useRequest(ChatsService.getChatHistory, to_user_id)
const {
sendMessage,
messages,
setMessages,
setScroller,
emitTypingEvent,
isRemoteTyping,
} = useChat(to_user_id)
async function submitMessage() {
if (!currentText) {
return false
}
await sendMessage(currentText)
setCurrentText("")
}
async function onInputChange(e) {
const value = e.target.value
setCurrentText(value)
if (value === "") {
emitTypingEvent(false)
} {
emitTypingEvent(true)
}
}
React.useEffect(() => {
if (R_History) {
setMessages(R_History.list)
// scroll to bottom
messagesRef.current?.scrollTo({
top: messagesRef.current.scrollHeight,
behavior: "smooth",
})
}
}, [R_History])
React.useEffect(() => {
if (isOnBottomView === true) {
setScroller(messagesRef)
} else {
setScroller(null)
}
}, [isOnBottomView])
if (E_History) {
return <antd.Result
status="warning"
title="Error"
subTitle={E_History.message}
/>
}
if (L_History) {
return <antd.Skeleton active />
}
return <div
className="chat-page"
>
<div className="chat-page-header">
<UserPreview
user_id={to_user_id}
/>
{
isRemoteTyping && <p>Typing...</p>
}
</div>
<div
className={classnames(
"chat-page-messages",
{
["empty"]: messages.length === 0
}
)}
ref={messagesRef}
>
{
messages.length === 0 && <antd.Empty />
}
{
messages.map((line, index) => {
return <div
key={index}
className={classnames(
"chat-page-line-wrapper",
{
["self"]: line.user._id === app.userData._id
}
)}
>
<div className="chat-page-line">
<div
className="chat-page-line-avatar"
>
<img
src={line.user.avatar}
/>
<span>
{line.user.username}
</span>
</div>
<div
className="chat-page-line-text"
>
<p>
{line.content}
</p>
</div>
</div>
</div>
})
}
</div>
<div className="chat-page-input-wrapper">
<div className="chat-page-input">
<antd.Input
placeholder="Enter message"
value={currentText}
onChange={onInputChange}
onPressEnter={submitMessage}
/>
</div>
</div>
</div>
}
export default ChatPage

View File

@ -0,0 +1,108 @@
.chat-page {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 90vh;
gap: 20px;
.chat-page-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 20px;
}
.chat-page-messages {
display: flex;
flex-direction: column;
gap: 10px;
height: 80vh;
background-color: var(--background-color-accent);
border-radius: 12px;
padding: 10px;
padding-bottom: 20px;
overflow-y: overlay;
&.empty {
align-items: center;
justify-content: center;
}
.chat-page-line-wrapper {
display: flex;
flex-direction: column;
gap: 10px;
&.self {
align-self: flex-end;
}
.chat-page-line {
display: flex;
flex-direction: column;
background-color: var(--background-color-primary);
width: fit-content;
max-width: 300px;
gap: 10px;
padding: 10px;
border-radius: 12px;
.chat-page-line-avatar {
display: flex;
flex-direction: row;
align-items: center;
gap: 7px;
img {
width: 30px;
height: 30px;
border-radius: 12px;
}
}
.chat-page-line-text {
p {
white-space: pre-wrap;
line-break: break-all;
word-break: break-all;
margin: 0;
user-select: text;
}
}
}
}
}
.chat-page-input-wrapper {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
.chat-page-input {
display: flex;
flex-direction: row;
}
}
}

View File

@ -0,0 +1,13 @@
import React from "react"
import "./index.less"
const MessagesPage = (props) => {
return <div
className="messages-page"
>
</div>
}
export default MessagesPage

View File

@ -7,10 +7,10 @@ import MusicService from "@models/music"
import "./index.less"
const TrackPage = (props) => {
const { id } = props.params
const Item = (props) => {
const { type, id } = props.params
const [loading, result, error, makeRequest] = app.cores.api.useRequest(MusicService.getTrackData, id)
const [loading, result, error, makeRequest] = app.cores.api.useRequest(MusicService.getReleaseData, id)
if (error) {
return <antd.Result
@ -26,15 +26,11 @@ const TrackPage = (props) => {
return <div className="track-page">
<PlaylistView
playlist={{
title: result.title,
cover: result.cover_url,
list: [result]
}}
playlist={result}
centered={app.isMobile}
hasMore={false}
/>
</div>
}
export default TrackPage
export default Item

View File

@ -0,0 +1,3 @@
.track-page {
width: 100%;
}

View File

@ -1,6 +0,0 @@
.track-page {
display: flex;
flex-direction: column;
width: 100%;
}

View File

@ -3,7 +3,6 @@ import * as antd from "antd"
import { FloatingPanel } from "antd-mobile"
import PostCard from "@components/PostCard"
import CommentsCard from "@components/CommentsCard"
import Post from "@models/post"
@ -44,7 +43,7 @@ export default (props) => {
<PostCard data={data} fullmode />
<FloatingPanel anchors={floatingPanelAnchors}>
<CommentsCard post_id={post_id} />
</FloatingPanel>
</div>
}

View File

@ -1,6 +1,8 @@
// Patch global prototypes
import { Buffer } from "buffer"
globalThis.IS_MOBILE_HOST = window.navigator.userAgent === "capacitor"
window.Buffer = Buffer
Array.prototype.findAndUpdateObject = function (discriminator, obj) {

View File

@ -50,6 +50,7 @@ const generateRoutes = () => {
.replace(/\/src\/pages|index|\.mobile|\.jsx$/g, "")
.replace(/\/src\/pages|index|\.mobile|\.tsx$/g, "")
path = path.replace(/\[([a-z]+)\]/g, ":$1")
path = path.replace(/\[\.{3}.+\]/, "*").replace(/\[(.+)\]/, ":$1")
return {
@ -59,15 +60,8 @@ const generateRoutes = () => {
})
}
function generatePageElementWrapper(route, element, bindProps) {
return React.createElement((props) => {
const params = useParams()
const url = new URL(window.location)
const query = new Proxy(url, {
get: (target, prop) => target.searchParams.get(prop),
})
const routeDeclaration = routesDeclaration.find((layout) => {
function findRouteDeclaration(route) {
return routesDeclaration.find((layout) => {
const routePath = layout.path.replace(/\*/g, ".*").replace(/!/g, "^")
return new RegExp(routePath).test(route)
@ -75,68 +69,79 @@ function generatePageElementWrapper(route, element, bindProps) {
path: route,
useLayout: "default",
}
}
route = route.replace(/\?.+$/, "").replace(/\/{2,}/g, "/")
route = route.replace(/\/$/, "")
function isAuthenticated() {
return !!app.userData
}
if (routeDeclaration) {
if (!bindProps.user && (window.location.pathname !== config.app?.authPath)) {
if (!routeDeclaration.public) {
function handleRouteDeclaration(declaration) {
React.useEffect(() => {
if (declaration) {
// if not authenticated and is not in public route, redirect
if (!isAuthenticated() && !declaration.public && (window.location.pathname !== config.app?.authPath)) {
if (typeof window.app.location.push === "function") {
window.app.location.push(config.app?.authPath ?? "/login")
return <div />
}
app.cores.notifications.new({
title: "Please login to use this feature.",
duration: 15,
})
} else {
window.location.href = config.app?.authPath ?? "/login"
return <div />
}
} else {
if (declaration.useLayout) {
app.layout.set(declaration.useLayout)
}
if (routeDeclaration.useLayout) {
app.layout.set(routeDeclaration.useLayout)
}
if (typeof routeDeclaration.centeredContent !== "undefined") {
if (typeof declaration.centeredContent !== "undefined") {
let finalBool = null
if (typeof routeDeclaration.centeredContent === "boolean") {
finalBool = routeDeclaration.centeredContent
if (typeof declaration.centeredContent === "boolean") {
finalBool = declaration.centeredContent
} else {
if (app.isMobile) {
finalBool = routeDeclaration.centeredContent?.mobile ?? null
finalBool = declaration.centeredContent?.mobile ?? null
} else {
finalBool = routeDeclaration.centeredContent?.desktop ?? null
finalBool = declaration.centeredContent?.desktop ?? null
}
}
app.layout.toggleCenteredContent(finalBool)
}
if (typeof routeDeclaration.useTitle !== "undefined") {
if (typeof routeDeclaration.useTitle === "function") {
routeDeclaration.useTitle = routeDeclaration.useTitle(route, params)
if (typeof declaration.useTitle !== "undefined") {
if (typeof declaration.useTitle === "function") {
declaration.useTitle = declaration.useTitle(path, params)
}
document.title = `${routeDeclaration.useTitle} - ${config.app.siteName}`
document.title = `${declaration.useTitle} - ${config.app.siteName}`
} else {
document.title = config.app.siteName
}
}
if (typeof routeDeclaration?.mobileTopBarSpacer === "boolean" && app.isMobile) {
app.layout.toggleTopBarSpacer(routeDeclaration.mobileTopBarSpacer)
} else {
app.layout.toggleTopBarSpacer(false)
}
}, [])
}
function generatePageElementWrapper(path, element, props, declaration) {
return React.createElement((props) => {
const params = useParams()
const url = new URL(window.location)
const query = new Proxy(url, {
get: (target, prop) => target.searchParams.get(prop),
})
handleRouteDeclaration(declaration)
return React.createElement(
loadable(element, {
fallback: React.createElement(bindProps.staticRenders?.PageLoad || DefaultLoadingRender),
fallback: React.createElement(props.staticRenders?.PageLoad || DefaultLoadingRender),
}),
{
...props,
...bindProps,
...props,
url: url,
params: params,
query: query,
@ -160,36 +165,22 @@ const NavigationController = (props) => {
state = {}
}
const transitionDuration = app.cores.style.getValue("page-transition-duration") ?? "250ms"
state.transitionDelay = Number(transitionDuration.replace("ms", ""))
app.eventBus.emit("router.navigate", to, {
state,
})
app.location.last = window.location
if (state.transitionDelay >= 100) {
await new Promise((resolve) => setTimeout(resolve, state.transitionDelay))
}
return navigate(to, {
state
})
}
async function backLocation() {
const transitionDuration = app.cores.style.getValue("page-transition-duration") ?? "250ms"
app.eventBus.emit("router.navigate")
app.location.last = window.location
if (transitionDuration >= 100) {
await new Promise((resolve) => setTimeout(resolve, transitionDuration))
}
return window.history.back()
}
@ -220,10 +211,12 @@ export const PageRender = React.memo((props) => {
return <Routes>
{
routes.map((route, index) => {
const declaration = findRouteDeclaration(route.path)
return <Route
key={index}
path={route.path}
element={generatePageElementWrapper(route.path, route.element, props)}
element={generatePageElementWrapper(route.path, route.element, props, declaration)}
exact
/>
})

View File

@ -106,7 +106,8 @@ export default {
id: "style.uiFontScale",
group: "aspect",
component: "Slider",
title: "UI font scale",
icon: "MdFormatSize",
title: "Font scale",
description: "Change the font scale of the application.",
props: {
min: 1,
@ -133,7 +134,7 @@ export default {
group: "aspect",
component: "Select",
icon: "MdOutlineFontDownload",
title: "UI font",
title: "Font family",
description: "Change the font of the application.",
props: {
style: {
@ -178,18 +179,7 @@ export default {
},
storaged: false,
},
// {
// id: "style.parallaxBackground",
// group: "aspect",
// component: "Switch",
// icon: "MdOutline3DRotation",
// title: "Parallax background",
// description: "Create a parallax effect on the background.",
// dependsOn: {
// "style.backgroundImage": true
// },
// storaged: true,
// },
{
id: "style.backgroundImage",
group: "aspect",
@ -225,27 +215,6 @@ export default {
},
storaged: false,
},
{
id: "style.backgroundPattern",
group: "aspect",
icon: "MdGrid4X4",
component: loadable(() => import("../components/backgroundSelector")),
title: "Background pattern",
description: "Change background pattern of the application.",
extraActions: [
{
id: "remove",
icon: "Delete",
title: "Remove",
onClick: () => {
app.cores.style.modify({
backgroundSVG: "unset"
})
}
}
],
storaged: false,
},
{
id: "style.backgroundBlur",
group: "aspect",

View File

@ -1,4 +1,4 @@
@buttonsBorderRadius: 9px;
@import "./vars.less";
:root {
--adm-color-background: var(--background-color-primary);

View File

@ -1,17 +0,0 @@
/* Default */
@import url('https://fonts.googleapis.com/css2?family=Varela+Round&display=swap');
/* PostMessage */
@import url('https://fonts.googleapis.com/css?family=Poppins:300,300i,500,500i,700');
@import url('https://fonts.googleapis.com/css?family=Alata&display=swap');
@import url('https://fonts.googleapis.com/css?family=Source+Sans+Pro&display=swap');
@import url('https://fonts.googleapis.com/css?family=Kulim+Park&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Recursive:wght@300;400;500;600;700;800;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;600&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap');
@import url('https://fonts.cdnfonts.com/css/mona-sans');
@import url('https://fonts.cdnfonts.com/css/hubot-sans');

View File

@ -0,0 +1,10 @@
/* Selectable fonts for users */
@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');
/* Required secondary fonts */
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;600&display=swap');
/* Disabled fonts */
/* @import url('https://fonts.googleapis.com/css2?family=Recursive:wght@300;400;500;600;700;800;900&display=swap'); */

View File

@ -1,6 +1,6 @@
@import "@styles/animations.less";
@import "@styles/vars.less";
@import "@styles/fonts.css";
@import "@styles/fonts.less";
@import "@styles/fixments.less";
@import "@styles/mobile.less";
@import "@styles/splash.less";

View File

@ -20,5 +20,4 @@
@bottomBar_iconSize: 45px;
@topBar_height: 52px;
@modal_background_blur: 10px;
@modal_background_blur: 2px;

View File

@ -1,35 +1,53 @@
export default (uri, filename) => {
app.message.info("Downloading media...")
import axios from "axios"
import mime from "mime"
fetch(uri, {
method: "GET",
export default async (uri) => {
const key = `download-${uri}`
console.log(`[UTIL] Downloading ${uri}`)
try {
app.cores.notifications.new({
key: key,
title: "Downloading",
duration: 0,
type: "loading",
closable: false,
feedback: false,
})
.then((response) => response.blob())
.then((blob) => {
if (!filename) {
filename = uri.split("/").pop()
}
// Create blob link to download
const url = window.URL.createObjectURL(new Blob([blob]))
const metadata = await axios({
method: "HEAD",
url: uri,
})
const extension = mime.getExtension(metadata.headers["content-type"])
const filename = `${metadata.headers["x-amz-meta-file-hash"]}.${extension}`
const content = await axios({
method: "GET",
url: uri,
responseType: "blob",
})
const file = new File([content.data], filename, {
name: filename,
type: metadata.headers["content-type"],
})
const url = URL.createObjectURL(file)
const link = document.createElement("a")
link.href = url
link.setAttribute("download", filename)
// Append to html link element page
document.body.appendChild(link)
// Start download
link.download = file.name
link.click()
// Clean up and remove the link
link.parentNode.removeChild(link)
})
.catch((error) => {
setTimeout(() => {
app.cores.notifications.close(key)
}, 1000)
} catch (error) {
console.error(error)
app.message.error("Failed to download media")
})
app.cores.notifications.close(key)
}
}

View File

@ -0,0 +1,24 @@
import React from "react"
export default () => {
const enterPlayerAnimation = () => {
app.cores.style.applyVariant("dark")
app.cores.style.compactMode(true)
app.layout.toggleCenteredContent(false)
app.controls.toggleUIVisibility(false)
}
const exitPlayerAnimation = () => {
app.cores.style.applyInitialVariant()
app.cores.style.compactMode(false)
app.controls.toggleUIVisibility(true)
}
React.useEffect(() => {
enterPlayerAnimation()
return () => {
exitPlayerAnimation()
}
}, [])
}

View File

@ -0,0 +1,11 @@
export default {
name: "ChatMessage",
collection: "chats_messages",
schema: {
type: { type: String, required: true },
from_user_id: { type: String, required: true },
to_user_id: { type: String, required: true },
content: { type: String, required: true },
created_at: { type: Date, required: true },
}
}

View File

@ -0,0 +1,13 @@
export default {
name: "TrackLyric",
collection: "tracks_lyrics",
schema: {
track_id: {
type: String,
required: true
},
lrc: {
type: String,
}
}
}

View File

@ -0,0 +1,9 @@
export default {
name: "RecentChat",
collection: "recent_chats",
schema: {
type: { type: String, required: true },
user_id: { type: String, required: true },
chat_id: { type: String, required: true },
}
}

View File

@ -189,6 +189,7 @@ export default class Gateway {
},
onReload: async ({ id, service, cwd, }) => {
console.log(`[onReload] ${id} ${service}`)
let instance = this.instancePool.find((instance) => instance.id === id)
if (!instance) {
@ -209,7 +210,7 @@ export default class Gateway {
// try to unregister from proxy
this.proxy.unregisterAllFromService(id)
instance.instance.kill()
await instance.instance.kill("SIGINT")
instance.instance = await spawnService({
id,

View File

@ -13,7 +13,7 @@ export default async (socket, token, err) => {
return err(`auth:token_invalid`)
}
const userData = await User.findById(validation.data.user_id).catch((err) => {
let userData = await User.findById(validation.data.user_id).catch((err) => {
console.error(`[${socket.id}] failed to get user data caused by server error`, err)
return null
@ -23,6 +23,9 @@ export default async (socket, token, err) => {
return err(`auth:user_failed`)
}
userData = userData.toObject()
userData._id = userData._id.toString()
socket.userData = userData
socket.token = token
socket.session = validation.data

View File

@ -20,6 +20,7 @@
"http-proxy": "^1.18.1",
"linebridge": "^0.18.1",
"module-alias": "^2.2.3",
"nodejs-snowflake": "^2.0.1",
"signal-exit": "^4.1.0",
"spinnies": "^0.5.1",
"tree-kill": "^1.2.2"

View File

@ -1,259 +0,0 @@
import socketio from "socket.io"
import withWsAuth from "@middlewares/withWsAuth"
function generateFnHandler(fn, socket) {
return async (...args) => {
if (typeof socket === "undefined") {
socket = arguments[0]
}
try {
fn(socket, ...args)
} catch (error) {
console.error(`[HANDLER_ERROR] ${error.message} >`, error.stack)
if (typeof socket.emit !== "function") {
return false
}
return socket.emit("error", {
message: error.message,
})
}
}
}
class Room {
constructor(io, roomName) {
if (!io) {
throw new Error("io is required")
}
this.io = io
this.roomName = roomName
}
connections = []
limitations = {
maxMessageLength: 540,
}
events = {
"room:send:message": (socket, payload) => {
let { message } = payload
if (!message || typeof message !== "string") {
return socket.emit("error", {
message: "Invalid message",
})
}
if (message.length > this.limitations.maxMessageLength) {
message = message.substring(0, this.limitations.maxMessageLength)
}
this.io.to(this.roomName).emit("room:recive:message", {
timestamp: payload.timestamp ?? Date.now(),
content: String(message),
user: {
user_id: socket.userData._id,
username: socket.userData.username,
fullName: socket.userData.fullName,
avatar: socket.userData.avatar,
},
})
}
}
join = (socket) => {
if (socket.connectedRoom) {
console.warn(`[${socket.id}][@${socket.userData.username}] already connected to room ${socket.connectedRoom}`)
this.leave(socket)
}
socket.connectedRoom = this.roomName
// join room
socket.join(this.roomName)
// add to connections
this.connections.push(socket)
// emit to self
socket.emit("room:joined", {
room: this.roomName,
limitations: this.limitations,
connectedUsers: this.connections.map((socket_conn) => {
return socket_conn.userData._id
}),
})
// emit to others
this.io.to(this.roomName).emit("room:user:joined", {
user: {
user_id: socket.userData._id,
username: socket.userData.username,
fullName: socket.userData.fullName,
avatar: socket.userData.avatar,
}
})
for (const [event, fn] of Object.entries(this.events)) {
const handler = generateFnHandler(fn, socket)
if (!Array.isArray(socket.handlers)) {
socket.handlers = []
}
socket.handlers.push([event, handler])
socket.on(event, handler)
}
console.log(`[${socket.id}][@${socket.userData.username}] joined room ${this.roomName}`)
}
leave = (socket) => {
if (!socket.connectedRoom) {
console.warn(`[${socket.id}][@${socket.userData.username}] not connected to any room`)
return
}
if (socket.connectedRoom !== this.roomName) {
console.warn(`[${socket.id}][@${socket.userData.username}] not connected to room ${this.roomName}, cannot leave`)
return false
}
socket.leave(this.roomName)
this.connections.splice(this.connections.indexOf(socket), 1)
socket.emit("room:left", {
room: this.roomName,
})
this.io.to(this.roomName).emit("room:user:left", {
user: {
user_id: socket.userData._id,
username: socket.userData.username,
fullName: socket.userData.fullName,
avatar: socket.userData.avatar,
}
})
for (const [event, handler] of socket.handlers) {
socket.off(event, handler)
}
console.log(`[${socket.id}][@${socket.userData.username}] left room ${this.roomName}`)
}
}
class RoomsController {
constructor(io) {
if (!io) {
throw new Error("io is required")
}
this.io = io
}
rooms = []
checkRoomExists = (roomName) => {
return this.rooms.some((room) => room.roomName === roomName)
}
createRoom = async (roomName) => {
if (this.checkRoomExists(roomName)) {
throw new Error(`Room ${roomName} already exists`)
}
const room = new Room(this.io, roomName)
this.rooms.push(room)
return room
}
connectSocketToRoom = async (socket, roomName) => {
if (!this.checkRoomExists(roomName)) {
await this.createRoom(roomName)
}
const room = this.rooms.find((room) => room.roomName === roomName)
return room.join(socket)
}
disconnectSocketFromRoom = async (socket, roomName) => {
if (!this.checkRoomExists(roomName)) {
return false
}
const room = this.rooms.find((room) => room.roomName === roomName)
return room.leave(socket)
}
}
export default class ChatServer {
constructor(server) {
this.io = socketio(server, {
cors: {
origin: "*",
methods: ["GET", "POST"],
credentials: true,
}
})
if (global.ioAdapter) {
this.io.adapter(global.ioAdapter)
}
this.RoomsController = new RoomsController(this.io)
}
connectionPool = []
events = {
"connection": (socket) => {
console.log(`[${socket.id}][${socket.userData.username}] connected to hub.`)
this.connectionPool.push(socket)
socket.on("disconnect", () => this.events.disconnect)
// Rooms
socket.on("join:room", (data) => this.RoomsController.connectSocketToRoom(socket, data.room))
socket.on("leave:room", (data) => this.RoomsController.disconnectSocketFromRoom(socket, data?.room ?? socket.connectedRoom))
},
"disconnect": (socket) => {
console.log(`[${socket.id}][@${socket.userData.username}] disconnected to hub.`)
if (socket.connectedRoom) {
this.Rooms.leave(socket)
}
// remove from connection pool
this.connectionPool = this.connectionPool.filter((client) => client.id !== socket.id)
},
}
initialize = async () => {
this.io.use(withWsAuth)
Object.entries(this.events).forEach(([event, handler]) => {
this.io.on(event, (socket) => {
try {
handler(socket)
} catch (error) {
console.error(error)
}
})
})
}
}

View File

@ -9,8 +9,8 @@ import SharedMiddlewares from "@shared-middlewares"
class API extends Server {
static refName = "chats"
static useEngine = "hyper-express"
static wsRoutesPath = `${__dirname}/ws_routes`
static routesPath = `${__dirname}/routes`
static wsRoutesPath = `${__dirname}/routes_ws`
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3004
middlewares = {
@ -44,7 +44,11 @@ class API extends Server {
}
async onInitialize() {
this.contexts.rooms = new RoomsController(this.engine.io)
if (!this.engine.ws) {
throw new Error(`Engine WS not found!`)
}
this.contexts.rooms = new RoomsController(this.engine.ws.io)
await this.contexts.db.initialize()
await this.contexts.redis.initialize()

View File

@ -1,4 +1,7 @@
import buildFunctionHandler from "@utils/buildFunctionHandler"
import { Snowflake } from "nodejs-snowflake"
import { ChatMessage } from "@db_models"
export default class Room {
constructor(io, roomID, options) {
@ -27,10 +30,10 @@ export default class Room {
}
roomEvents = {
"room:change:owner": (client, payload) => {
"room:change:owner": async (client, payload) => {
throw new OperationError(500, "Not implemented")
},
"room:send:message": (client, payload) => {
"room:send:message": async (client, payload) => {
console.log(`[${this.roomID}] [@${client.userData.username}] sent message >`, payload)
let { message } = payload
@ -43,7 +46,12 @@ export default class Room {
message = message.substring(0, this.limitations.maxMessageLength)
}
const created_at = new Date().getTime()
const id = `msg:${client.userData._id}:${created_at}`
this.handlers.broadcastToMembers("room:message", {
_id: id,
timestamp: payload.timestamp ?? Date.now(),
content: String(message),
user: {
@ -53,6 +61,33 @@ export default class Room {
avatar: client.userData.avatar,
},
})
if (payload.route) {
const routeValues = payload.route.split(":")
console.log(routeValues)
if (routeValues.length > 0) {
const [type, to_id] = routeValues
switch (type) {
case "user": {
const doc = await ChatMessage.create({
type: type,
from_user_id: client.userData._id,
to_user_id: to_id,
content: message,
created_at: created_at,
})
console.log(doc)
}
default:
break;
}
}
}
}
}

View File

@ -0,0 +1,74 @@
import { User, ChatMessage } from "@db_models"
export default {
middlewares: ["withAuthentication"],
fn: async (req) => {
const { limit = 50, offset = 0, order = "asc" } = req.query
const id = req.params.chat_id
const [from_user_id, to_user_id] = [req.auth.session.user_id, id]
const query = {
from_user_id: {
$in: [
from_user_id,
to_user_id
]
},
to_user_id: {
$in: [
from_user_id,
to_user_id
]
},
}
let user_datas = await User.find({
_id: [
from_user_id,
to_user_id
]
})
user_datas = user_datas.map((user) => {
user = user.toObject()
if (!user) {
return {
_id: 0,
username: "Deleted User",
}
}
user._id = user._id.toString()
return user
})
let history = await ChatMessage.find(query)
.sort({ created_at: order === "desc" ? -1 : 1 })
.skip(offset)
.limit(limit)
history = history.map(async (item) => {
item = item.toObject()
item.user = user_datas.find((user) => {
return user._id === item.from_user_id
})
return item
})
history = await Promise.all(history)
return {
total: await ChatMessage.count(query),
offset: offset,
limit: limit,
order: order,
list: history
}
}
}

View File

@ -0,0 +1,32 @@
import { ChatMessage } from "@db_models"
export default async (socket, payload, engine) => {
const created_at = new Date().getTime()
const [from_user_id, to_user_id] = [socket.userData._id, payload.to_user_id]
const targetSocket = await engine.find.socketByUserId(payload.to_user_id)
const wsMessageObj = {
...payload,
created_at: created_at,
user: socket.userData,
_id: `msg:${from_user_id}:${created_at}`,
}
const doc = await ChatMessage.create({
type: "user",
from_user_id: from_user_id,
to_user_id: to_user_id,
content: payload.content,
created_at: created_at,
})
socket.emit("chat:receive:message", wsMessageObj)
if (targetSocket.emit) {
await targetSocket.emit("chat:receive:message", wsMessageObj)
}
return doc
}

View File

@ -0,0 +1,20 @@
export default async (socket, payload, engine) => {
const from_user_id = socket.userData._id
const { to_user_id, is_typing } = payload
const targetSocket = await engine.find.socketByUserId(to_user_id)
if (targetSocket) {
await targetSocket.emit("chat:state:typing", {
is_typing: is_typing
})
// socket.pendingFunctions.push("chats:state:typing")
// setTimeout(() => {
// socket.emit("chats:state:typing", {
// is_typing: false,
// })
// }, 5000)
}
}

View File

@ -11,16 +11,32 @@ export default {
"withAuthentication",
],
fn: async (req, res) => {
const providerType = req.headers["provider-type"]
const userPath = path.join(this.default.contexts.cache.constructor.cachePath, req.auth.session.user_id)
const tmpPath = path.resolve(userPath)
let build = await ChunkFileUpload(req, {
tmpDir: tmpPath,
const limits = {
maxFileSize: parseInt(this.default.contexts.limits.maxFileSizeInMB) * 1024 * 1024,
maxChunkSize: parseInt(this.default.contexts.limits.maxChunkSizeInMB) * 1024 * 1024,
useCompression: true,
useProvider: "standard",
}
const user = await req.auth.user()
if (user.roles.includes("admin")) {
// maxFileSize for admins 100GB
limits.maxFileSize = 100 * 1024 * 1024 * 1024
// optional compression for admins
limits.useCompression = req.headers["use-compression"] ?? false
limits.useProvider = req.headers["provider-type"] ?? "b2"
}
let build = await ChunkFileUpload(req, {
tmpDir: tmpPath,
...limits,
}).catch((err) => {
throw new OperationError(err.code, err.message)
})
@ -32,8 +48,8 @@ export default {
const result = await RemoteUpload({
parentDir: req.auth.session.user_id,
source: build.filePath,
service: providerType,
useCompression: req.headers["use-compression"] ?? true,
service: limits.useProvider,
useCompression: limits.useCompression,
cachePath: tmpPath,
})

View File

@ -7,6 +7,7 @@ const AllowedUpdateFields = [
"artist",
"type",
"public",
"list",
]
export default class Release {
@ -19,7 +20,7 @@ export default class Release {
explicit: payload.explicit,
type: payload.type,
public: payload.public,
items: payload.items,
list: payload.list,
public: payload.public,
})

View File

@ -6,12 +6,22 @@ import axios from "axios"
export default async (payload = {}) => {
requiredFields(["title", "source", "user_id"], payload)
const { data: stream, headers } = await axios({
let stream = null
let headers = null
try {
const sourceStream = await axios({
url: payload.source,
method: "GET",
responseType: "stream",
})
stream = sourceStream.data
headers = sourceStream.headers
} catch (error) {
throw new OperationError(500, `Failed to process fetching source: ${error.message}`)
}
const fileMetadata = await MusicMetadata.parseStream(stream, {
mimeType: headers["content-type"],
})

View File

@ -20,7 +20,9 @@ export default async (track_id, { limit = 50, offset = 0 } = {}) => {
}
}
const track = await Track.findById(track_id).catch(() => null)
const track = await Track.findOne({
_id: track_id
})
if (!track) {
throw new OperationError(404, "Track not found")

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