merge from local

This commit is contained in:
SrGooglo 2024-06-11 17:13:45 +00:00
parent d041729e3e
commit a373a27f5a
139 changed files with 6491 additions and 2221 deletions

21
.vscode/settings.json vendored
View File

@ -4,5 +4,24 @@
"docify.inlineComments": true,
"docify.moreExpressiveComments": true,
"docify.sidePanelReviewMode": false,
"docify.programmingLanguage": "javascript"
"docify.programmingLanguage": "javascript",
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#ff9396",
"activityBar.background": "#ff9396",
"activityBar.foreground": "#15202b",
"activityBar.inactiveForeground": "#15202b99",
"activityBarBadge.background": "#048000",
"activityBarBadge.foreground": "#e7e7e7",
"commandCenter.border": "#15202b99",
"sash.hoverBorder": "#ff9396",
"statusBar.background": "#ff6064",
"statusBar.foreground": "#15202b",
"statusBarItem.hoverBackground": "#ff2d32",
"statusBarItem.remoteBackground": "#ff6064",
"statusBarItem.remoteForeground": "#15202b",
"titleBar.activeBackground": "#ff6064",
"titleBar.activeForeground": "#15202b",
"titleBar.inactiveBackground": "#ff606499",
"titleBar.inactiveForeground": "#15202b99"
}
}

@ -1 +1 @@
Subproject commit 126bad9c1e21c0c7fcab60a22fc9d70bbbd9a999
Subproject commit 15f89af37c5bac086e8e1154f5d1b3da8967678b

@ -1 +1 @@
Subproject commit 6d553830ab4661ffab952253d77ccb0bfc1363d8
Subproject commit d2e6f1bc5856e3084d4fd068dec5d67ab2ef9d8d

View File

@ -1,5 +1,5 @@
{
"app:language": "en",
"app:language": "en_US",
"low_performance_mode": false,
"transcode_video_browser": false,
"forceMobileMode": false,

View File

@ -56,14 +56,14 @@ export default {
i18n: {
languages: [
{
locale: "en",
locale: "en_US",
name: "English"
},
{
locale: "es",
locale: "es_ES",
name: "Español"
}
],
defaultLocale: "en",
defaultLocale: "en_US",
}
}

View File

@ -0,0 +1,611 @@
{
"ab": "Abkhazian",
"ace": "Achinese",
"ach": "Acoli",
"ada": "Adangme",
"ady": "Adyghe",
"aa": "Afar",
"afh": "Afrihili",
"af": "Afrikaans",
"agq": "Aghem",
"ain": "Ainu",
"ak": "Akan",
"akk": "Akkadian",
"bss": "Akoose",
"akz": "Alabama",
"sq": "Albanian",
"ale": "Aleut",
"arq": "Algerian Arabic",
"am": "Amarik",
"en_US": "American English",
"ase": "American Sign Language",
"egy": "Ancient Egyptian",
"grc": "Ancient Greek",
"anp": "Angika",
"njo": "Ao Naga",
"ar": "Arabik",
"an": "Aragonese",
"arc": "Aramaic",
"aro": "Araona",
"arp": "Arapaho",
"arw": "Arawak",
"hy": "Armenian",
"rup": "Aromanian",
"frp": "Arpitan",
"as": "Assamese",
"ast": "Asturian",
"asa": "Asu",
"cch": "Atsam",
"en_AU": "Australian English",
"de_AT": "Austrian German",
"av": "Avaric",
"ae": "Avestan",
"awa": "Awadhi",
"ay": "Aymara",
"az": "Azerbaijani",
"bfq": "Badaga",
"ksf": "Bafia",
"bfd": "Bafut",
"bqi": "Bakhtiari",
"ban": "Balinese",
"bal": "Baluchi",
"bm": "Bambara",
"bax": "Bamun",
"bjn": "Banjar",
"bas": "Basaa",
"ba": "Bashkir",
"eu": "Basque",
"bbc": "Batak Toba",
"bar": "Bavarian",
"bej": "Beja",
"be": "Belarus kasa",
"bem": "Bemba",
"bez": "Bena",
"bn": "Bengali kasa",
"bew": "Betawi",
"my": "B\u025b\u025bmis kasa",
"bho": "Bhojpuri",
"bik": "Bikol",
"bin": "Bini",
"bpy": "Bishnupriya",
"bi": "Bislama",
"byn": "Blin",
"zbl": "Blissymbols",
"brx": "Bodo",
"en": "Bor\u0254fo",
"bs": "Bosnian",
"bg": "B\u0254lgeria kasa",
"brh": "Brahui",
"bra": "Braj",
"pt_BR": "Brazilian Portuguese",
"br": "Breton",
"en_GB": "British English",
"bug": "Buginese",
"bum": "Bulu",
"bua": "Buriat",
"cad": "Caddo",
"frc": "Cajun French",
"en_CA": "Canadian English",
"fr_CA": "Canadian French",
"yue": "Cantonese",
"cps": "Capiznon",
"car": "Carib",
"ca": "Catalan",
"cay": "Cayuga",
"ceb": "Cebuano",
"tzm": "Central Atlas Tamazight",
"dtp": "Central Dusun",
"ckb": "Central Kurdish",
"esu": "Central Yupik",
"shu": "Chadian Arabic",
"chg": "Chagatai",
"ch": "Chamorro",
"ce": "Chechen",
"chr": "Cherokee",
"chy": "Cheyenne",
"chb": "Chibcha",
"cgg": "Chiga",
"qug": "Chimborazo Highland Quichua",
"chn": "Chinook Jargon",
"chp": "Chipewyan",
"cho": "Choctaw",
"cu": "Church Slavic",
"chk": "Chuukese",
"cv": "Chuvash",
"nwc": "Classical Newari",
"syc": "Classical Syriac",
"ksh": "Colognian",
"swb": "Comorian",
"swc": "Congo Swahili",
"cop": "Coptic",
"kw": "Cornish",
"co": "Corsican",
"cr": "Cree",
"mus": "Creek",
"crh": "Crimean Turkish",
"hr": "Croatian",
"dak": "Dakota",
"da": "Danish",
"dar": "Dargwa",
"dzg": "Dazaga",
"del": "Delaware",
"nl": "D\u025b\u025bkye",
"din": "Dinka",
"dv": "Divehi",
"doi": "Dogri",
"dgr": "Dogrib",
"dua": "Duala",
"dyu": "Dyula",
"dz": "Dzongkha",
"frs": "Eastern Frisian",
"efi": "Efik",
"arz": "Egyptian Arabic",
"eka": "Ekajuk",
"elx": "Elamite",
"ebu": "Embu",
"egl": "Emilian",
"myv": "Erzya",
"eo": "Esperanto",
"et": "Estonian",
"pt_PT": "Portuguese",
"es_ES": "Español",
"ee": "Ewe",
"ewo": "Ewondo",
"ext": "Extremaduran",
"fan": "Fang",
"fat": "Fanti",
"fo": "Faroese",
"hif": "Fiji Hindi",
"fj": "Fijian",
"fil": "Filipino",
"fi": "Finnish",
"nl_BE": "Flemish",
"fon": "Fon",
"gur": "Frafra",
"fr": "Fr\u025bnkye",
"fur": "Friulian",
"ff": "Fulah",
"gaa": "Ga",
"gag": "Gagauz",
"gl": "Galician",
"gan": "Gan Chinese",
"lg": "Ganda",
"gay": "Gayo",
"gba": "Gbaya",
"gez": "Geez",
"ka": "Georgian",
"aln": "Gheg Albanian",
"bbj": "Ghomala",
"glk": "Gilaki",
"gil": "Gilbertese",
"gom": "Goan Konkani",
"gon": "Gondi",
"gor": "Gorontalo",
"got": "Gothic",
"grb": "Grebo",
"el": "Greek kasa",
"gn": "Guarani",
"gu": "Gujarati",
"guz": "Gusii",
"gwi": "Gwich\u02bcin",
"de": "Gyaaman",
"jv": "Gyabanis kasa",
"ja": "Gyapan kasa",
"hai": "Haida",
"ht": "Haitian",
"hak": "Hakka Chinese",
"hu": "Hangri kasa",
"ha": "Hausa",
"haw": "Hawaiian",
"he": "Hebrew",
"hz": "Herero",
"hil": "Hiligaynon",
"hi": "Hindi",
"ho": "Hiri Motu",
"hit": "Hittite",
"hmn": "Hmong",
"hup": "Hupa",
"iba": "Iban",
"ibb": "Ibibio",
"is": "Icelandic",
"io": "Ido",
"ig": "Igbo",
"ilo": "Iloko",
"smn": "Inari Sami",
"id": "Indonihyia kasa",
"izh": "Ingrian",
"inh": "Ingush",
"ia": "Interlingua",
"ie": "Interlingue",
"iu": "Inuktitut",
"ik": "Inupiaq",
"ga": "Irish",
"it": "Italy kasa",
"jam": "Jamaican Creole English",
"kaj": "Jju",
"dyo": "Jola-Fonyi",
"jrb": "Judeo-Arabic",
"jpr": "Judeo-Persian",
"jut": "Jutish",
"kbd": "Kabardian",
"kea": "Kabuverdianu",
"kab": "Kabyle",
"kac": "Kachin",
"kgp": "Kaingang",
"kkj": "Kako",
"kl": "Kalaallisut",
"kln": "Kalenjin",
"xal": "Kalmyk",
"kam": "Kamba",
"km": "Kambodia kasa",
"kbl": "Kanembu",
"kn": "Kannada",
"kr": "Kanuri",
"kaa": "Kara-Kalpak",
"krc": "Karachay-Balkar",
"krl": "Karelian",
"ks": "Kashmiri",
"csb": "Kashubian",
"kaw": "Kawi",
"kk": "Kazakh",
"ken": "Kenyang",
"kha": "Khasi",
"kho": "Khotanese",
"khw": "Khowar",
"ki": "Kikuyu",
"kmb": "Kimbundu",
"krj": "Kinaray-a",
"kiu": "Kirmanjki",
"tlh": "Klingon",
"bkm": "Kom",
"kv": "Komi",
"koi": "Komi-Permyak",
"kg": "Kongo",
"kok": "Konkani",
"ko": "Korea kasa",
"kfo": "Koro",
"kos": "Kosraean",
"avk": "Kotava",
"khq": "Koyra Chiini",
"ses": "Koyraboro Senni",
"kpe": "Kpelle",
"kri": "Krio",
"kj": "Kuanyama",
"kum": "Kumyk",
"ku": "Kurdish",
"kru": "Kurukh",
"kut": "Kutenai",
"nmg": "Kwasio",
"zh": "Kyaena kasa",
"cs": "Ky\u025bk kasa",
"ky": "Kyrgyz",
"quc": "K\u02bciche\u02bc",
"lad": "Ladino",
"lah": "Lahnda",
"lkt": "Lakota",
"lam": "Lamba",
"lag": "Langi",
"lo": "Lao",
"ltg": "Latgalian",
"la": "Latin",
"es_419": "Latin American Spanish",
"lv": "Latvian",
"lzz": "Laz",
"lez": "Lezghian",
"lij": "Ligurian",
"li": "Limburgish",
"ln": "Lingala",
"lfn": "Lingua Franca Nova",
"lzh": "Literary Chinese",
"lt": "Lithuanian",
"liv": "Livonian",
"jbo": "Lojban",
"lmo": "Lombard",
"nds": "Low German",
"sli": "Lower Silesian",
"dsb": "Lower Sorbian",
"loz": "Lozi",
"lu": "Luba-Katanga",
"lua": "Luba-Lulua",
"lui": "Luiseno",
"smj": "Lule Sami",
"lun": "Lunda",
"luo": "Luo",
"lb": "Luxembourgish",
"luy": "Luyia",
"mde": "Maba",
"mk": "Macedonian",
"jmc": "Machame",
"mad": "Madurese",
"maf": "Mafa",
"mag": "Magahi",
"vmf": "Main-Franconian",
"mai": "Maithili",
"mak": "Makasar",
"mgh": "Makhuwa-Meetto",
"kde": "Makonde",
"mg": "Malagasy",
"ms": "Malay kasa",
"ml": "Malayalam",
"mt": "Maltese",
"mnc": "Manchu",
"mdr": "Mandar",
"man": "Mandingo",
"mni": "Manipuri",
"gv": "Manx",
"mi": "Maori",
"arn": "Mapuche",
"mr": "Marathi",
"chm": "Mari",
"mh": "Marshallese",
"mwr": "Marwari",
"mas": "Masai",
"mzn": "Mazanderani",
"byv": "Medumba",
"men": "Mende",
"mwv": "Mentawai",
"mer": "Meru",
"mgo": "Meta\u02bc",
"es_MX": "Mexican Spanish",
"mic": "Micmac",
"dum": "Middle Dutch",
"enm": "Middle English",
"frm": "Middle French",
"gmh": "Middle High German",
"mga": "Middle Irish",
"nan": "Min Nan Chinese",
"min": "Minangkabau",
"xmf": "Mingrelian",
"mwl": "Mirandese",
"lus": "Mizo",
"ar_001": "Modern Standard Arabic",
"moh": "Mohawk",
"mdf": "Moksha",
"ro_MD": "Moldavian",
"lol": "Mongo",
"mn": "Mongolian",
"mfe": "Morisyen",
"ary": "Moroccan Arabic",
"mos": "Mossi",
"mul": "Multiple Languages",
"mua": "Mundang",
"ttt": "Muslim Tat",
"mye": "Myene",
"naq": "Nama",
"na": "Nauru",
"nv": "Navajo",
"ng": "Ndonga",
"nap": "Neapolitan",
"new": "Newari",
"ne": "N\u025bpal kasa",
"sba": "Ngambay",
"nnh": "Ngiemboon",
"jgo": "Ngomba",
"yrl": "Nheengatu",
"nia": "Nias",
"niu": "Niuean",
"zxx": "No linguistic content",
"nog": "Nogai",
"nd": "North Ndebele",
"frr": "Northern Frisian",
"se": "Northern Sami",
"nso": "Northern Sotho",
"no": "Norwegian",
"nb": "Norwegian Bokm\u00e5l",
"nn": "Norwegian Nynorsk",
"nov": "Novial",
"nus": "Nuer",
"nym": "Nyamwezi",
"ny": "Nyanja",
"nyn": "Nyankole",
"tog": "Nyasa Tonga",
"nyo": "Nyoro",
"nzi": "Nzima",
"nqo": "N\u02bcKo",
"oc": "Occitan",
"oj": "Ojibwa",
"ang": "Old English",
"fro": "Old French",
"goh": "Old High German",
"sga": "Old Irish",
"non": "Old Norse",
"peo": "Old Persian",
"pro": "Old Proven\u00e7al",
"or": "Oriya",
"om": "Oromo",
"osa": "Osage",
"os": "Ossetic",
"ota": "Ottoman Turkish",
"pal": "Pahlavi",
"pfl": "Palatine German",
"pau": "Palauan",
"pi": "Pali",
"pam": "Pampanga",
"pag": "Pangasinan",
"pap": "Papiamento",
"ps": "Pashto",
"pdc": "Pennsylvania German",
"fa": "P\u025b\u025bhyia kasa",
"phn": "Phoenician",
"pcd": "Picard",
"pms": "Piedmontese",
"pdt": "Plautdietsch",
"pon": "Pohnpeian",
"pnt": "Pontic",
"pl": "P\u0254land kasa",
"pt": "P\u0254\u0254tugal kasa",
"prg": "Prussian",
"pa": "Pungyabi kasa",
"qu": "Quechua",
"ru": "Rahyia kasa",
"raj": "Rajasthani",
"rap": "Rapanui",
"rar": "Rarotongan",
"rw": "Rewanda kasa",
"rif": "Riffian",
"rgn": "Romagnol",
"rm": "Romansh",
"rom": "Romany",
"rof": "Rombo",
"ro": "Romenia kasa",
"root": "Root",
"rtm": "Rotuman",
"rug": "Roviana",
"rn": "Rundi",
"rue": "Rusyn",
"rwk": "Rwa",
"ssy": "Saho",
"sah": "Sakha",
"sam": "Samaritan Aramaic",
"saq": "Samburu",
"sm": "Samoan",
"sgs": "Samogitian",
"sad": "Sandawe",
"sg": "Sango",
"sbp": "Sangu",
"sa": "Sanskrit",
"sat": "Santali",
"sc": "Sardinian",
"sas": "Sasak",
"sdc": "Sassarese Sardinian",
"stq": "Saterland Frisian",
"saz": "Saurashtra",
"sco": "Scots",
"gd": "Scottish Gaelic",
"sly": "Selayar",
"sel": "Selkup",
"seh": "Sena",
"see": "Seneca",
"sr": "Serbian",
"sh": "Serbo-Croatian",
"srr": "Serer",
"sei": "Seri",
"ksb": "Shambala",
"shn": "Shan",
"sn": "Shona",
"ii": "Sichuan Yi",
"scn": "Sicilian",
"sid": "Sidamo",
"bla": "Siksika",
"szl": "Silesian",
"zh_Hans": "Simplified Chinese",
"sd": "Sindhi",
"si": "Sinhala",
"sms": "Skolt Sami",
"den": "Slave",
"sk": "Slovak",
"sl": "Slovenian",
"xog": "Soga",
"sog": "Sogdien",
"so": "Somalia kasa",
"snk": "Soninke",
"azb": "South Azerbaijani",
"nr": "South Ndebele",
"alt": "Southern Altai",
"sma": "Southern Sami",
"st": "Southern Sotho",
"es": "Spain kasa",
"srn": "Sranan Tongo",
"zgh": "Standard Moroccan Tamazight",
"suk": "Sukuma",
"sux": "Sumerian",
"su": "Sundanese",
"sus": "Susu",
"sw": "Swahili",
"ss": "Swati",
"sv": "Sweden kasa",
"fr_CH": "Swiss French",
"gsw": "Swiss German",
"de_CH": "Swiss High German",
"syr": "Syriac",
"shi": "Tachelhit",
"th": "Taeland kasa",
"tl": "Tagalog",
"ty": "Tahitian",
"dav": "Taita",
"tg": "Tajik",
"tly": "Talysh",
"tmh": "Tamashek",
"ta": "Tamil kasa",
"trv": "Taroko",
"twq": "Tasawaq",
"tt": "Tatar",
"te": "Telugu",
"ter": "Tereno",
"teo": "Teso",
"tet": "Tetum",
"tr": "T\u025b\u025bki kasa",
"bo": "Tibetan",
"tig": "Tigre",
"ti": "Tigrinya",
"tem": "Timne",
"tiv": "Tiv",
"tli": "Tlingit",
"tpi": "Tok Pisin",
"tkl": "Tokelau",
"to": "Tongan",
"fit": "Tornedalen Finnish",
"zh_Hant": "Traditional Chinese",
"tkr": "Tsakhur",
"tsd": "Tsakonian",
"tsi": "Tsimshian",
"ts": "Tsonga",
"tn": "Tswana",
"tcy": "Tulu",
"tum": "Tumbuka",
"aeb": "Tunisian Arabic",
"tk": "Turkmen",
"tru": "Turoyo",
"tvl": "Tuvalu",
"tyv": "Tuvinian",
"tw": "Twi",
"kcg": "Tyap",
"udm": "Udmurt",
"uga": "Ugaritic",
"uk": "Ukren kasa",
"umb": "Umbundu",
"und": "Unknown Language",
"hsb": "Upper Sorbian",
"ur": "Urdu kasa",
"ug": "Uyghur",
"uz": "Uzbek",
"vai": "Vai",
"ve": "Venda",
"vec": "Venetian",
"vep": "Veps",
"vi": "Vi\u025btnam kasa",
"vo": "Volap\u00fck",
"vro": "V\u00f5ro",
"vot": "Votic",
"vun": "Vunjo",
"wa": "Walloon",
"wae": "Walser",
"war": "Waray",
"wbp": "Warlpiri",
"was": "Washo",
"guc": "Wayuu",
"cy": "Welsh",
"vls": "West Flemish",
"fy": "Western Frisian",
"mrj": "Western Mari",
"wal": "Wolaytta",
"wo": "Wolof",
"wuu": "Wu Chinese",
"xh": "Xhosa",
"hsn": "Xiang Chinese",
"yav": "Yangben",
"yao": "Yao",
"yap": "Yapese",
"ybb": "Yemba",
"yi": "Yiddish",
"yo": "Yoruba",
"zap": "Zapotec",
"dje": "Zarma",
"zza": "Zaza",
"zea": "Zeelandic",
"zen": "Zenaga",
"za": "Zhuang",
"gbz": "Zoroastrian Dari",
"zu": "Zulu",
"zun": "Zuni"
}

View File

@ -1,9 +1,15 @@
[
{
"id": "home",
"path": "/",
"title": "Home",
"icon": "Home"
},
{
"id": "timeline",
"path": "/",
"title": "Timeline",
"icon": "Home"
"icon": "MdTag"
},
{
"id": "tv",
@ -16,18 +22,5 @@
"path": "/music",
"title": "Music",
"icon": "MdMusicNote"
},
{
"id": "groups",
"path": "/groups",
"title": "Groups",
"icon": "MdGroups",
"disabled": true
},
{
"id": "Marketplace",
"path": "/marketplace",
"title": "Marketplace",
"icon": "Box"
}
]

View File

@ -6,13 +6,9 @@
"author": "RageStudio",
"description": "A prototype of a social network.",
"scripts": {
"start": "electron-forge start",
"build": "vite build",
"dev": "vite",
"dev:electron": "concurrently -k \"yarn dev\" \"yarn electron:dev\"",
"electron:dev": "cross-env IS_DEV=true electron-forge start",
"docker-compose:update_run": "docker-compose down && git pull && yarn build && docker-compose up -d --build",
"electron:build": "cross-env IS_DEV=false electron-builder -mwl --dir",
"preview": "vite preview"
},
"peerDependencies": {

View File

@ -1,6 +1,6 @@
{
"build": {
"devPath": "http://fr01.ragestudio.net:8000",
"devPath": "https://fr01.ragestudio.net:8000",
"distDir": "../dist"
},
"package": {

View File

@ -106,7 +106,7 @@ class ComtyApp extends React.Component {
}
},
openLoginForm: async (options = {}) => {
app.DrawerController.open("login", Login, {
app.layout.drawer.open("login", Login, {
defaultLocked: options.defaultLocked ?? false,
componentProps: {
sessionController: this.sessionController,
@ -120,7 +120,7 @@ class ComtyApp extends React.Component {
})
},
openRegisterForm: async (options = {}) => {
app.DrawerController.open("Register", UserRegister, {
app.layout.drawer.open("Register", UserRegister, {
defaultLocked: options.defaultLocked ?? false,
componentProps: {
sessionController: this.sessionController,
@ -144,7 +144,7 @@ class ComtyApp extends React.Component {
},
openSearcher: (options) => {
if (app.isMobile) {
return app.DrawerController.open("searcher", Searcher, {
return app.layout.drawer.open("searcher", Searcher, {
...options,
componentProps: {
renderResults: true,

View File

@ -23,7 +23,7 @@ export default () => {
React.useEffect(() => {
app.eventBus.on("style.update", handleStyleUpdate)
const activeSVG = app.cores.style.getValue("backgroundSVG")
const activeSVG = app.cores.style.getVar("backgroundSVG")
if (hasBackgroundSVG(activeSVG)) {
setActiveColor(true)

View File

@ -0,0 +1,58 @@
import React from "react"
import * as antd from "antd"
import Image from "@components/Image"
import UploadButton from "@components/UploadButton"
import "./index.less"
const CoverEditor = (props) => {
const { value, onChange, defaultUrl } = props
const [url, setUrl] = React.useState(value)
React.useEffect(() => {
setUrl(value)
}, [value])
React.useEffect(() => {
onChange(url)
}, [url])
React.useEffect(() => {
if (!url) {
setUrl(defaultUrl)
}
}, [])
return <div className="cover-editor">
<div className="cover-editor-preview">
<Image
src={url}
/>
</div>
<div className="cover-editor-actions">
<UploadButton
onSuccess={(uid, response) => {
setUrl(response.url)
}}
/>
<antd.Button
type="primary"
onClick={() => {
setUrl(defaultUrl)
}}
>
Reset
</antd.Button>
{
props.extraActions
}
</div>
</div>
}
export default CoverEditor

View File

@ -0,0 +1,45 @@
.cover-editor {
display: flex;
flex-direction: column;
width: 100%;
gap: 10px;
.cover-editor-preview {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
padding: 7px;
background-color: var(--background-color-accent);
border-radius: 12px;
.lazy-load-image-background {
max-width: 200px;
max-height: 200px;
img {
height: 100%;
width: 100%;
border-radius: 12px;
object-fit: contain;
}
}
}
.cover-editor-actions {
display: flex;
flex-direction: row;
gap: 20px;
}
}

View File

@ -113,20 +113,6 @@ class Login extends React.Component {
app.location.push("/apr")
}
onClickRegister = () => {
if (this.props.locked) {
this.props.unlock()
}
if (typeof this.props.close === "function") {
this.props.close()
}
app.controls.openRegisterForm({
defaultLocked: this.props.locked
})
}
toggleLoading = (to) => {
if (typeof to === "undefined") {
to = !this.state.loading
@ -351,10 +337,6 @@ class Login extends React.Component {
<div className="field" onClick={this.onClickForgotPassword}>
<a>Forgot your password?</a>
</div>
<div className="field" onClick={this.onClickRegister}>
<a>You need a account?</a>
</div>
</div>
</div>
}

View File

@ -5,11 +5,12 @@ import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import fuse from "fuse.js"
import useWsEvents from "@hooks/useWsEvents"
import { WithPlayerContext } from "@contexts/WithPlayerContext"
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
import useWsEvents from "@hooks/useWsEvents"
import checkUserIdIsSelf from "@utils/checkUserIdIsSelf"
import LoadMore from "@components/LoadMore"
import { Icons } from "@components/Icons"
import MusicTrack from "@components/Music/Track"
@ -75,7 +76,7 @@ const MoreMenuHandlers = {
export default (props) => {
const [playlist, setPlaylist] = React.useState(props.playlist)
const [searchResults, setSearchResults] = React.useState(null)
const [owningPlaylist, setOwningPlaylist] = React.useState(app.cores.permissions.checkUserIdIsSelf(props.playlist?.user_id))
const [owningPlaylist, setOwningPlaylist] = React.useState(checkUserIdIsSelf(props.playlist?.user_id))
const moreMenuItems = React.useMemo(() => {
const items = [{
@ -84,7 +85,7 @@ export default (props) => {
}]
if (!playlist.type || playlist.type === "playlist") {
if (app.cores.permissions.checkUserIdIsSelf(playlist.user_id)) {
if (checkUserIdIsSelf(playlist.user_id)) {
items.push({
key: "delete",
label: "Delete",
@ -221,7 +222,7 @@ export default (props) => {
React.useEffect(() => {
setPlaylist(props.playlist)
setOwningPlaylist(app.cores.permissions.checkUserIdIsSelf(props.playlist?.user_id))
setOwningPlaylist(checkUserIdIsSelf(props.playlist?.user_id))
}, [props.playlist])
if (!playlist) {

View File

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

View File

@ -0,0 +1,60 @@
import React from "react"
import * as antd from "antd"
import axios from "axios"
const LyricsTextView = (props) => {
const { lang, track } = props
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState(null)
const [lyrics, setLyrics] = React.useState(null)
async function getLyrics(resource_url) {
setError(null)
setLoading(true)
setLyrics(null)
const data = await axios({
method: "get",
url: resource_url,
responseType: "text"
}).catch((err) => {
console.error(err)
setError(err)
return null
})
if (data) {
setLyrics(data.data)
}
setLoading(false)
}
React.useEffect(() => {
getLyrics(lang.value)
}, [lang])
if (!lang) {
return null
}
if (error) {
return <antd.Result
status="warning"
title="Failed"
subTitle={error.message}
/>
}
if (loading) {
return <antd.Skeleton active />
}
return <div>
<p>{lyrics}</p>
</div>
}
export default LyricsTextView

View File

@ -0,0 +1,55 @@
import React from "react"
import * as antd from "antd"
import ReleaseItem from "@components/MusicStudio/ReleaseItem"
import MusicModel from "@models/music"
import "./index.less"
const MyReleasesList = () => {
const [L_MyReleases, R_MyReleases, E_MyReleases, M_MyReleases] = app.cores.api.useRequest(MusicModel.getMyReleases, {
offset: 0,
limit: 100,
})
async function onClickReleaseItem(release) {
app.location.push(`/studio/music/${release._id}`)
}
return <div className="music-studio-page-content">
<div className="music-studio-page-header">
<h1>Your Releases</h1>
</div>
{
L_MyReleases && !E_MyReleases && <antd.Skeleton active />
}
{
E_MyReleases && <antd.Result
status="warning"
title="Failed to retrieve releases"
subTitle={E_MyReleases.message}
/>
}
{
!L_MyReleases && !E_MyReleases && R_MyReleases && R_MyReleases.items.length === 0 && <antd.Empty />
}
{
!L_MyReleases && !E_MyReleases && R_MyReleases && R_MyReleases.items.length > 0 && <div className="music-studio-page-releases-list">
{
R_MyReleases.items.map((item) => {
return <ReleaseItem
key={item._id}
release={item}
onClick={onClickReleaseItem}
/>
})
}
</div>
}
</div>
}
export default MyReleasesList

View File

@ -1,8 +1,8 @@
.music-dashboard {
.music-studio-page-releases-list {
display: flex;
flex-direction: column;
width: 100%;
.music-dashboard_header {}
gap: 10px;
}

View File

@ -0,0 +1,107 @@
import React from "react"
import * as antd from "antd"
import { Icons } from "@components/Icons"
import MusicModel from "@models/music"
import Tabs from "./tabs"
import "./index.less"
const ReleaseEditor = (props) => {
const { release_id } = props
const basicInfoRef = React.useRef()
const [selectedTab, setSelectedTab] = React.useState("info")
const [L_Release, R_Release, E_Release, F_Release] = release_id !== "new" ? app.cores.api.useRequest(MusicModel.getReleaseData, release_id) : [false, false, false, false]
async function handleSubmit() {
basicInfoRef.current.submit()
}
async function onFinish(values) {
console.log(values)
}
async function canFinish() {
return true
}
if (E_Release) {
return <antd.Result
status="warning"
title="Error"
subTitle={E_Release.message}
/>
}
if (L_Release) {
return <antd.Skeleton active />
}
const Tab = Tabs.find(({ key }) => key === selectedTab)
return <div className="music-studio-release-editor">
<div className="music-studio-release-editor-menu">
<antd.Menu
onClick={(e) => setSelectedTab(e.key)}
selectedKeys={[selectedTab]}
items={Tabs}
mode="vertical"
/>
<div className="music-studio-release-editor-menu-actions">
<antd.Button
type="primary"
onClick={handleSubmit}
icon={<Icons.Save />}
disabled={L_Release || !canFinish()}
>
Save
</antd.Button>
{
release_id !== "new" ? <antd.Button
icon={<Icons.IoMdTrash />}
disabled={L_Release}
>
Delete
</antd.Button> : null
}
{
release_id !== "new" ? <antd.Button
icon={<Icons.MdLink />}
onClick={() => app.location.push(`/music/release/${R_Release._id}`)}
>
Go to release
</antd.Button> : null
}
</div>
</div>
<div className="music-studio-release-editor-content">
{
!Tab && <antd.Result
status="error"
title="Error"
subTitle="Tab not found"
/>
}
{
Tab && React.createElement(Tab.render, {
release: R_Release,
onFinish: onFinish,
references: {
basic: basicInfoRef
}
})
}
</div>
</div>
}
export default ReleaseEditor

View File

@ -0,0 +1,93 @@
.music-studio-release-editor {
display: flex;
flex-direction: row;
width: 100%;
padding: 20px;
gap: 20px;
.music-studio-release-editor-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 20px;
.title {
font-size: 1.7rem;
font-family: "Space Grotesk", sans-serif;
}
}
.music-studio-release-editor-menu {
display: flex;
flex-direction: column;
gap: 20px;
align-items: center;
.ant-btn {
width: 100%;
}
.ant-menu {
background-color: var(--background-color-accent) !important;
border-radius: 12px;
padding: 8px;
gap: 5px;
.ant-menu-item {
padding: 5px 10px !important;
}
.ant-menu-item-selected {
background-color: var(--background-color-primary-2) !important;
}
}
.music-studio-release-editor-menu-actions {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
}
.music-studio-release-editor-content {
display: flex;
flex-direction: column;
width: 100%;
.music-studio-release-editor-tab {
display: flex;
flex-direction: column;
gap: 10px;
h1 {
margin: 0;
}
.ant-form-item {
margin-bottom: 10px;
}
label {
height: fit-content;
span {
font-weight: 600;
}
}
}
}
}

View File

@ -0,0 +1,9 @@
import React from "react"
const ReleaseAdvanced = (props) => {
return <div>
<h1>Advanced</h1>
</div>
}
export default ReleaseAdvanced

View File

@ -0,0 +1,105 @@
import React from "react"
import * as antd from "antd"
import { Icons } from "@components/Icons"
import CoverEditor from "@components/CoverEditor"
const ReleasesTypes = [
{
value: "single",
label: "Single",
icon: <Icons.MdMusicNote />,
},
{
value: "ep",
label: "Episode",
icon: <Icons.MdAlbum />,
},
{
value: "album",
label: "Album",
icon: <Icons.MdAlbum />,
},
{
value: "compilation",
label: "Compilation",
icon: <Icons.MdAlbum />,
}
]
const BasicInformation = (props) => {
const { release, onFinish } = props
return <div className="music-studio-release-editor-tab">
<h1>Release Information</h1>
<antd.Form
name="basic"
layout="vertical"
ref={props.references.basic}
onFinish={onFinish}
requiredMark={false}
>
<antd.Form.Item
label=""
name="cover"
rules={[{ required: true, message: "Input a cover for the release" }]}
initialValue={release?.cover}
>
<CoverEditor
defaultUrl="https://storage.ragestudio.net/comty-static-assets/default_song.png"
/>
</antd.Form.Item>
{
release._id && <antd.Form.Item
label={<><Icons.MdTag /> <span>ID</span></>}
name="_id"
initialValue={release._id}
disabled
>
<antd.Input
placeholder="Release ID"
disabled
/>
</antd.Form.Item>
}
<antd.Form.Item
label={<><Icons.MdMusicNote /> <span>Title</span></>}
name="title"
rules={[{ required: true, message: "Input a title for the release" }]}
initialValue={release?.title}
>
<antd.Input
placeholder="Release title"
maxLength={128}
showCount
/>
</antd.Form.Item>
<antd.Form.Item
label={<><Icons.MdAlbum /> <span>Type</span></>}
name="type"
rules={[{ required: true, message: "Select a type for the release" }]}
initialValue={release?.type}
>
<antd.Select
placeholder="Release type"
options={ReleasesTypes}
/>
</antd.Form.Item>
<antd.Form.Item
label={<><Icons.MdPublic /> <span>Public</span></>}
name="public"
initialValue={release?.public}
>
<antd.Switch />
</antd.Form.Item>
</antd.Form>
</div>
}
export default BasicInformation

View File

@ -0,0 +1,249 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"
import { Icons } from "@components/Icons"
import TrackEditor from "@components/MusicStudio/TrackEditor"
import "./index.less"
const UploadHint = (props) => {
return <div className="uploadHint">
<Icons.MdPlaylistAdd />
<p>Upload your tracks</p>
<p>Drag and drop your tracks here or click this box to start uploading files.</p>
</div>
}
const TrackListItem = (props) => {
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState(null)
const { track } = props
async function onClickEditTrack() {
app.layout.drawer.open("track_editor", TrackEditor, {
type: "drawer",
props: {
width: "600px",
headerStyle: {
display: "none",
}
},
componentProps: {
track,
onSave: (newTrackData) => {
console.log("Saving track", newTrackData)
},
},
})
}
return <Draggable
key={track._id}
draggableId={track._id}
index={props.index}
>
{
(provided, snapshot) => {
return <div
className={classnames(
"music-studio-release-editor-tracks-list-item",
{
["loading"]: loading,
["failed"]: !!error
}
)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div className="music-studio-release-editor-tracks-list-item-index">
<span>{props.index + 1}</span>
</div>
<span>{track.title}</span>
<div className="music-studio-release-editor-tracks-list-item-actions">
<antd.Button
type="ghost"
icon={<Icons.Edit2 />}
onClick={onClickEditTrack}
/>
<div
{...provided.dragHandleProps}
className="music-studio-release-editor-tracks-list-item-dragger"
>
<Icons.MdDragIndicator />
</div>
</div>
</div>
}
}
</Draggable>
}
const ReleaseTracks = (props) => {
const { release } = props
const [list, setList] = React.useState(release.list ?? [])
const [pendingTracksUpload, setPendingTracksUpload] = React.useState([])
async function onTrackUploaderChange (change) {
switch (change.file.status) {
case "uploading": {
if (!pendingTracksUpload.includes(change.file.uid)) {
pendingTracksUpload.push(change.file.uid)
}
setList((prev) => {
return [
...prev,
]
})
break
}
case "done": {
// remove pending file
this.setState({
pendingTracksUpload: this.state.pendingTracksUpload.filter((uid) => uid !== change.file.uid)
})
// update file url in the track info
const track = this.state.trackList.find((file) => file.uid === change.file.uid)
if (track) {
track.source = change.file.response.url
track.status = "done"
}
this.setState({
trackList: this.state.trackList
})
break
}
case "error": {
// remove pending file
this.handleTrackRemove(change.file.uid)
// open a dialog to show the error and ask user to retry
antd.Modal.error({
title: "Upload failed",
content: "An error occurred while uploading the file. You want to retry?",
cancelText: "No",
okText: "Retry",
onOk: () => {
this.handleUploadTrack(change)
},
onCancel: () => {
this.handleTrackRemove(change.file.uid)
}
})
}
case "removed": {
this.handleTrackRemove(change.file.uid)
}
default: {
break
}
}
}
async function handleUploadTrack (req) {
const response = await app.cores.remoteStorage.uploadFile(req.file, {
onProgress: this.handleFileProgress,
service: "premium-cdn"
}).catch((error) => {
console.error(error)
antd.message.error(error)
req.onError(error)
return false
})
if (response) {
req.onSuccess(response)
}
}
async function onTrackDragEnd(result) {
console.log(result)
if (!result.destination) {
return
}
setList((prev) => {
const trackList = [...prev]
const [removed] = trackList.splice(result.source.index, 1)
trackList.splice(result.destination.index, 0, removed)
return trackList
})
}
return <div className="music-studio-release-editor-tab">
<h1>Tracks</h1>
<div>
<antd.Upload
className="uploader"
customRequest={handleUploadTrack}
onChange={onTrackUploaderChange}
showUploadList={false}
accept="audio/*"
multiple
>
{
list.length === 0 ?
<UploadHint /> : <antd.Button
className="uploadMoreButton"
icon={<Icons.Plus />}
/>
}
</antd.Upload>
<DragDropContext
onDragEnd={onTrackDragEnd}
>
<Droppable
droppableId="droppable"
>
{(provided, snapshot) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="music-studio-release-editor-tracks-list"
>
{
list.length === 0 && <antd.Result
status="info"
title="No tracks"
/>
}
{
list.map((track, index) => {
return <TrackListItem
index={index}
track={track}
/>
})
}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
</div>
}
export default ReleaseTracks

View File

@ -0,0 +1,52 @@
.music-studio-release-editor-tracks-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.music-studio-release-editor-tracks-list-item {
position: relative;
display: flex;
flex-direction: row;
padding: 10px;
gap: 10px;
border-radius: 12px;
background-color: var(--background-color-accent);
.music-studio-release-editor-tracks-list-item-actions {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 5px;
svg {
margin: 0;
}
.music-studio-release-editor-tracks-list-item-dragger {
display: flex;
align-items: center;
justify-content: center;
svg {
font-size: 1rem;
}
}
}
}

View File

@ -0,0 +1,26 @@
import { Icons, createIconRender } from "@components/Icons"
import BasicInformation from "./BasicInformation"
import Tracks from "./Tracks"
import Advanced from "./Advanced"
export default [
{
key: "info",
label: "Info",
icon: <Icons.MdInfo />,
render: BasicInformation,
},
{
key: "tracks",
label: "Tracks",
icon: <Icons.MdLibraryMusic />,
render: Tracks,
},
{
key: "advanced",
label: "Advanced",
icon: <Icons.MdSettings />,
render: Advanced,
}
]

View File

@ -0,0 +1,51 @@
import React from "react"
import { Icons } from "@components/Icons"
import Image from "@components/Image"
import "./index.less"
const ReleaseItem = (props) => {
const { release, onClick } = props
async function handleOnClick() {
if (typeof onClick === "function") {
return onClick(release)
}
}
return <div
id={release._id}
className="music-studio-page-release"
onClick={handleOnClick}
>
<div className="music-studio-page-release-title">
<Image
src={release.cover}
/>
{release.title}
</div>
<div
className="music-studio-page-release-info"
>
<div className="music-studio-page-release-info-field">
<Icons.IoMdMusicalNote />
{release.type}
</div>
<div className="music-studio-page-release-info-field">
<Icons.MdTag />
{release._id}
</div>
{/* <div className="music-studio-page-release-info-field">
<Icons.IoMdEye />
{release.analytics?.listen_count ?? 0}
</div> */}
</div>
</div>
}
export default ReleaseItem

View File

@ -0,0 +1,71 @@
.music-studio-page-release {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
padding: 7px;
gap: 7px;
background-color: var(--background-color-accent);
border-radius: 12px;
transition: all 150ms ease-in-out;
&:hover {
cursor: pointer;
background-color: var(--background-color-accent-hover);
outline: 2px solid var(--border-color);
}
.music-studio-page-release-title {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
.lazy-load-image-background {
width: 40px;
height: 40px;
border-radius: 8px;
overflow: hidden;
img {
width: 100%;
height: 100%;
}
}
}
.music-studio-page-release-info {
display: flex;
flex-direction: row;
gap: 10px;
text-transform: uppercase;
font-size: 12px;
.music-studio-page-release-info-field {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
svg {
margin: 0;
}
}
}
}

View File

@ -0,0 +1,208 @@
import React from "react"
import * as antd from "antd"
import CoverEditor from "@components/CoverEditor"
import { Icons } from "@components/Icons"
import LyricsEditor from "@components/MusicStudio/LyricsEditor"
import VideoEditor from "@components/MusicStudio/VideoEditor"
import "./index.less"
const TrackEditor = (props) => {
const [track, setTrack] = React.useState(props.track ?? {})
async function handleChange(key, value) {
setTrack((prev) => {
return {
...prev,
[key]: value
}
})
}
async function openLyricsEditor() {
app.layout.drawer.open("lyrics_editor", LyricsEditor, {
type: "drawer",
props: {
width: "600px",
headerStyle: {
display: "none",
}
},
componentProps: {
track,
onSave: (lyrics) => {
console.log("Saving lyrics for track >", lyrics)
},
},
})
}
async function openVideoEditor() {
app.layout.drawer.open("video_editor", VideoEditor, {
type: "drawer",
props: {
width: "600px",
headerStyle: {
display: "none",
}
},
componentProps: {
track,
onSave: (video) => {
console.log("Saving video for track", video)
},
},
})
}
async function onClose() {
if (typeof props.close === "function") {
props.close()
}
}
async function onSave() {
await props.onSave(track)
if (typeof props.close === "function") {
props.close()
}
}
return <div className="track-editor">
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdImage />
<span>Cover</span>
</div>
<CoverEditor
value={track.cover}
onChange={(url) => handleChange("cover", url)}
extraActions={[
<antd.Button>
Use Parent
</antd.Button>
]}
/>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdOutlineMusicNote />
<span>Title</span>
</div>
<antd.Input
value={track.title}
placeholder="Track title"
onChange={(e) => handleChange("title", e.target.value)}
/>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.User />
<span>Artist</span>
</div>
<antd.Input
value={track.artists.join(", ")}
placeholder="Artist"
onChange={(e) => handleChange("artist", e.target.value)}
/>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdAlbum />
<span>Album</span>
</div>
<antd.Input
value={track.album}
placeholder="Album"
onChange={(e) => handleChange("album", e.target.value)}
/>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdExplicit />
<span>Explicit</span>
</div>
<antd.Switch
checked={track.explicit}
onChange={(value) => handleChange("explicit", value)}
/>
</div>
<antd.Divider
style={{
margin: "5px 0",
}}
/>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.TbMovie />
<span>Edit Video</span>
</div>
<antd.Button
onClick={openVideoEditor}
>
Edit
</antd.Button>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdTextFormat />
<span>Edit Lyrics</span>
</div>
<antd.Button
onClick={openLyricsEditor}
>
Edit
</antd.Button>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdTimeline />
<span>Timestamps</span>
</div>
<antd.Button
disabled
>
Edit
</antd.Button>
</div>
<div className="track-editor-actions">
<antd.Button
type="text"
icon={<Icons.MdClose />}
onClick={onClose}
>
Cancel
</antd.Button>
<antd.Button
type="primary"
icon={<Icons.MdCheck />}
onClick={onSave}
>
Save
</antd.Button>
</div>
</div>
}
export default TrackEditor

View File

@ -0,0 +1,57 @@
.track-editor {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
.track-editor-actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
align-self: center;
gap: 10px;
}
.track-editor-field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
width: 100%;
.track-editor-field-header {
display: inline-flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
width: 100%;
h3 {
font-size: 1.2rem;
}
}
.track-editor-field-actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
}
}
}

View File

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

View File

@ -8,6 +8,31 @@ import NavMenu from "./components/NavMenu"
import "./index.less"
export class Tab extends React.Component {
state = {
error: null
}
// handle on error
componentDidCatch(err) {
this.setState({ error: err })
}
render() {
if (this.state.error) {
return <antd.Result
status="error"
title="Error"
subTitle={this.state.error}
/>
}
return <>
{this.props.children}
</>
}
}
export const Panel = (props) => {
return <div
{...props.props ?? {}}

View File

@ -2,6 +2,8 @@ import React from "react"
import { Dropdown } from "antd"
import { Icons } from "@components/Icons"
import checkUserIdIsSelf from "@utils/checkUserIdIsSelf"
import SaveButton from "./saveButton"
import LikeButton from "./likeButton"
import RepliesButton from "./replyButton"
@ -110,7 +112,7 @@ export default (props) => {
trigger={["click"]}
onOpenChange={(open) => {
if (open && props.user_id) {
const isSelf = app.cores.permissions.checkUserIdIsSelf(props.user_id)
const isSelf = checkUserIdIsSelf(props.user_id)
setIsSelf(isSelf)
}

View File

@ -2,6 +2,7 @@ import React from "react"
import { DateTime } from "luxon"
import { Tag } from "antd"
import TimeAgo from "@components/TimeAgo"
import Image from "@components/Image"
import { Icons } from "@components/Icons"
@ -10,37 +11,10 @@ import PostReplieView from "@components/PostReplieView"
import "./index.less"
const PostCardHeader = (props) => {
const [timeAgo, setTimeAgo] = React.useState(0)
const goToProfile = () => {
app.navigation.goToAccount(props.postData.user?.username)
}
const updateTimeAgo = () => {
let createdAt = props.postData.timestamp ?? props.postData.created_at ?? ""
const timeAgo = DateTime.fromISO(
createdAt,
{
locale: app.cores.settings.get("language")
}
).toRelative()
setTimeAgo(timeAgo)
}
React.useEffect(() => {
updateTimeAgo()
const interval = setInterval(() => {
updateTimeAgo()
}, 1000 * 60 * 5)
return () => {
clearInterval(interval)
}
}, [])
return <div className="post-header" onDoubleClick={props.onDoubleClick}>
{
!props.disableReplyTag && props.postData.reply_to && <div
@ -88,7 +62,9 @@ const PostCardHeader = (props) => {
</h1>
<span className="post-header-user-info-timeago">
{timeAgo}
<TimeAgo
time={props.postData.timestamp ?? props.postData.created_at}
/>
</span>
</div>
</div>

View File

@ -0,0 +1,132 @@
import React from "react"
import * as antd from "antd"
import { Translation } from "react-i18next"
import { Icons } from "@components/Icons"
import PlaylistItem from "@components/Music/PlaylistItem"
import "./index.less"
const ReleasesList = (props) => {
const hopNumber = props.hopsPerPage ?? 6
const [offset, setOffset] = React.useState(0)
const [ended, setEnded] = React.useState(false)
const [loading, result, error, makeRequest] = app.cores.api.useRequest(props.fetchMethod, {
limit: hopNumber,
trim: offset
})
const onClickPrev = () => {
if (offset === 0) {
return
}
setOffset((value) => {
const newOffset = value - hopNumber
// check if newOffset is NaN
if (newOffset !== newOffset) {
return false
}
if (typeof makeRequest === "function") {
makeRequest({
trim: newOffset,
limit: hopNumber,
})
}
return newOffset
})
}
const onClickNext = () => {
if (ended) {
return
}
setOffset((value) => {
const newOffset = value + hopNumber
// check if newOffset is NaN
if (newOffset !== newOffset) {
return false
}
if (typeof makeRequest === "function") {
makeRequest({
trim: newOffset,
limit: hopNumber,
})
}
return newOffset
})
}
React.useEffect(() => {
if (result) {
if (typeof result.has_more !== "undefined") {
setEnded(!result.has_more)
} else {
setEnded(result.items.length < hopNumber)
}
}
}, [result])
if (error) {
console.error(error)
return <div className="playlistExplorer_section">
<antd.Result
status="warning"
title="Failed to load"
subTitle="We are sorry, but we could not load this requests. Please try again later."
/>
</div>
}
return <div className="music-releases-list">
<div className="music-releases-list-header">
<h1>
{
props.headerIcon
}
<Translation>
{(t) => t(props.headerTitle)}
</Translation>
</h1>
<div className="music-releases-list-actions">
<antd.Button
icon={<Icons.MdChevronLeft />}
onClick={onClickPrev}
disabled={offset === 0 || loading}
/>
<antd.Button
icon={<Icons.MdChevronRight />}
onClick={onClickNext}
disabled={ended || loading}
/>
</div>
</div>
<div className="music-releases-list-items">
{
loading && <antd.Skeleton active />
}
{
!loading && result.items.map((playlist, index) => {
return <PlaylistItem
key={index}
playlist={playlist}
/>
})
}
</div>
</div>
}
export default ReleasesList

View File

@ -0,0 +1,52 @@
.music-releases-list {
display: flex;
flex-direction: column;
overflow-x: visible;
.music-releases-list-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 20px;
h1 {
font-size: 1.5rem;
margin: 0;
}
.music-releases-list-actions {
display: flex;
flex-direction: row;
gap: 10px;
align-self: center;
margin-left: auto;
}
}
.music-releases-list-items {
display: grid;
grid-gap: 20px;
grid-template-columns: repeat(3, minmax(0, 1fr));
min-width: 372px !important;
@media (min-width: 2000px) {
grid-template-columns: repeat(4, 1fr);
}
@media (min-width: 2300px) {
grid-template-columns: repeat(5, 1fr);
}
.playlistItem {
justify-self: center;
}
}
}

View File

@ -0,0 +1,33 @@
import React from "react"
import { DateTime } from "luxon"
const TimeAgo = (props) => {
const [calculationInterval, setCalculationInterval] = React.useState(null)
const [text, setText] = React.useState("")
async function calculateRelative() {
const timeAgo = DateTime.fromISO(
props.time,
{
locale: app.cores.settings.get("language")
}
).toRelative()
setText(timeAgo)
}
React.useEffect(() => {
setCalculationInterval(setInterval(calculateRelative, props.interval ?? 3000))
calculateRelative()
return () => {
clearInterval(calculationInterval)
}
}, [])
return text
}
export default TimeAgo

View File

@ -54,7 +54,7 @@ const UserLink = (props) => {
const handleOnClick = () => {
if (!hasHref) {
if (app.isMobile) {
app.DrawerController.open("link_viewer", UserLinkViewer, {
app.layout.drawer.open("link_viewer", UserLinkViewer, {
componentProps: {
link: link,
decorator: decorator

View File

@ -30,7 +30,7 @@ export default class APICore extends Core {
listenEvent(key, handler, instance = "default") {
if (!this.client.sockets[instance]) {
console.error(`[API] Websocket instance ${instance} not found`)
this.console.error(`[API] Websocket instance ${instance} not found`)
return false
}
@ -40,7 +40,7 @@ export default class APICore extends Core {
unlistenEvent(key, handler, instance = "default") {
if (!this.client.sockets[instance]) {
console.error(`[API] Websocket instance ${instance} not found`)
this.console.error(`[API] Websocket instance ${instance} not found`)
return false
}

View File

@ -161,7 +161,7 @@ export default class NFC extends Core {
if (this.subscribers.length === 0) {
if (tag.message.records?.length > 0) {
// open dialog
app.DrawerController.open("nfc_card_dialog", TapShareDialog, {
app.layout.drawer.open("nfc_card_dialog", TapShareDialog, {
componentProps: {
tag: tag,
}
@ -187,7 +187,7 @@ export default class NFC extends Core {
if (this.subscribers.length === 0 && tag.message?.records) {
if (tag.message.records?.length > 0) {
// open dialog
app.DrawerController.open("nfc_card_dialog", TapShareDialog, {
app.layout.drawer.open("nfc_card_dialog", TapShareDialog, {
componentProps: {
tag: tag,
}

View File

@ -1,61 +0,0 @@
import Core from "evite/src/core"
import UserModel from "@models/user"
import SessionModel from "@models/session"
export default class PermissionsCore extends Core {
static namespace = "permissions"
static dependencies = ["api"]
public = {
getRoles: this.getRoles,
hasAdmin: this.hasAdmin,
checkUserIdIsSelf: this.checkUserIdIsSelf,
hasPermission: this.hasPermission,
}
async hasAdmin() {
return await UserModel.haveAdmin()
}
checkUserIdIsSelf(user_id) {
return SessionModel.user_id === user_id
}
async getRoles() {
return await UserModel.selfRoles()
}
async hasPermission(permission, adminPreference = false) {
if (adminPreference) {
const admin = await this.hasAdmin()
if (admin) {
return true
}
}
let query = []
if (Array.isArray(permission)) {
query = permission
} else {
query = [permission]
}
// create a promise and check if the user has all the permission in the query
const result = await Promise.all(query.map(async (permission) => {
const hasPermission = await UserModel.haveRole(permission)
return hasPermission
}))
// if the user has all the permission in the query, return true
if (result.every((hasPermission) => hasPermission)) {
return true
}
return false
}
}

View File

@ -0,0 +1,127 @@
export default class TrackInstance {
constructor(player, manifest) {
if (!player) {
throw new Error("Player core is required")
}
if (typeof manifest === "undefined") {
throw new Error("Manifest is required")
}
this.player = player
this.manifest = manifest
return this
}
audio = null
contextElement = null
abortController = new AbortController()
attachedProcessors = []
waitUpdateTimeout = null
resolveManifest = async () => {
if (typeof this.manifest === "string") {
this.manifest = {
src: this.manifest,
}
}
if (this.manifest.service) {
if (!this.player.service_providers.has(manifest.service)) {
throw new Error(`Service ${manifest.service} is not supported`)
}
// try to resolve source file
if (this.manifest.service !== "inherit" && !this.manifest.source) {
this.manifest = await this.player.service_providers.resolve(this.manifest.service, this.manifest)
}
}
if (!this.manifest.source) {
throw new Error("Manifest `source` is required")
}
if (!this.manifest.metadata) {
this.manifest.metadata = {}
}
if (!this.manifest.metadata.title) {
this.manifest.metadata.title = this.manifest.source.split("/").pop()
}
return this.manifest
}
initialize = async () => {
this.manifest = await this.resolveManifest()
this.audio = new Audio(this.manifest.source)
this.audio.signal = this.abortController.signal
this.audio.crossOrigin = "anonymous"
this.audio.preload = "metadata"
for (const [key, value] of Object.entries(this.mediaEvents)) {
this.audio.addEventListener(key, value)
}
this.contextElement = this.player.audioContext.createMediaElementSource(this.audio)
return this
}
mediaEvents = {
"ended": () => {
this.player.next()
},
"loadeddata": () => {
this.player.state.loading = false
},
"loadedmetadata": () => {
// TODO: Detect a livestream and change mode
// if (instance.media.duration === Infinity) {
// instance.manifest.stream = true
// this.state.livestream_mode = true
// }
},
"play": () => {
this.player.state.playback_status = "playing"
},
"playing": () => {
this.player.state.loading = false
this.player.state.playback_status = "playing"
if (typeof this.waitUpdateTimeout !== "undefined") {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
},
"pause": () => {
this.player.state.playback_status = "paused"
},
// "durationchange": (duration) => {
// },
"waiting": () => {
if (this.waitUpdateTimeout) {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
// if takes more than 150ms to load, update loading state
this.waitUpdateTimeout = setTimeout(() => {
this.player.state.loading = true
}, 150)
},
"seeked": () => {
this.player.eventBus.emit(`player.seeked`, this.audio.currentTime)
},
}
}

View File

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

View File

@ -3,17 +3,16 @@ import EventEmitter from "evite/src/internals/EventEmitter"
import { Observable } from "object-observer"
import { FastAverageColor } from "fast-average-color"
import MusicModel from "comty.js/models/music"
import ToolBarPlayer from "@components/Player/ToolBarPlayer"
import BackgroundMediaPlayer from "@components/Player/BackgroundMediaPlayer"
import AudioPlayerStorage from "./player.storage"
import TrackInstanceClass from "./classes/TrackInstance"
import defaultAudioProccessors from "./processors"
import MediaSession from "./mediaSession"
import ServicesHandlers from "./services"
import ServiceProviders from "./services"
export default class Player extends Core {
static dependencies = [
@ -33,6 +32,8 @@ export default class Player extends Core {
// buffer & precomputation
static maxManifestPrecompute = 3
service_providers = new ServiceProviders()
native_controls = new MediaSession()
currentDomWindow = null
@ -94,7 +95,6 @@ export default class Player extends Core {
seek: this.seek.bind(this),
minimize: this.toggleMinimize.bind(this),
collapse: this.toggleCollapse.bind(this),
toggleCurrentTrackLike: this.toggleCurrentTrackLike.bind(this),
state: new Proxy(this.state, {
get: (target, prop) => {
return target[prop]
@ -112,6 +112,9 @@ export default class Player extends Core {
}
}),
gradualFadeMs: Player.gradualFadeMs,
trackInstance: () => {
return this.track_instance
}
}
internalEvents = {
@ -181,8 +184,6 @@ export default class Player extends Core {
}
for await (const processor of this.audioProcessors) {
this.console.log(`Initializing audio processor ${processor.constructor.name}`, processor)
if (typeof processor._init === "function") {
try {
await processor._init(this.audioContext)
@ -277,7 +278,7 @@ export default class Player extends Core {
}
if (!instance._preloaded) {
instance.media.preload = "metadata"
instance.audio.preload = "metadata"
instance._preloaded = true
}
@ -294,8 +295,8 @@ export default class Player extends Core {
}
// stop playback
if (this.track_instance.media) {
this.track_instance.media.pause()
if (this.track_instance.audio) {
this.track_instance.audio.pause()
}
// reset track_instance
@ -305,154 +306,10 @@ export default class Player extends Core {
this.state.livestream_mode = false
}
async createInstance(manifest) {
if (!manifest) {
this.console.error("Manifest is required")
return false
}
if (typeof manifest === "string") {
manifest = {
src: manifest,
}
}
// check if manifest has `manifest` property, if is and not inherit or missing source, resolve
if (manifest.service) {
if (!ServicesHandlers[manifest.service]) {
this.console.error(`Service ${manifest.service} is not supported`)
return false
}
if (manifest.service !== "inherit" && !manifest.source) {
const resolver = ServicesHandlers[manifest.service].resolve
if (!resolver) {
this.console.error(`Resolving for service [${manifest.service}] is not supported`)
return false
}
manifest = await resolver(manifest)
}
}
if (!manifest.src && !manifest.source) {
this.console.error("Manifest source is required")
return false
}
const source = manifest.src ?? manifest.source
if (!manifest.metadata) {
manifest.metadata = {}
}
// if title is not set, use the audio source filename
if (!manifest.metadata.title) {
manifest.metadata.title = source.split("/").pop()
}
let instance = {
manifest: manifest,
attachedProcessors: [],
abortController: new AbortController(),
source: source,
media: new Audio(source),
duration: null,
seek: 0,
track: null,
}
instance.media.signal = instance.abortController.signal
instance.media.crossOrigin = "anonymous"
instance.media.preload = "none"
instance.media.loop = this.state.playback_mode === "repeat"
instance.media.volume = this.state.volume
// handle on end
instance.media.addEventListener("ended", () => {
this.next()
})
instance.media.addEventListener("loadeddata", () => {
this.state.loading = false
})
// update playback status
instance.media.addEventListener("play", () => {
this.state.playback_status = "playing"
})
instance.media.addEventListener("playing", () => {
this.state.loading = false
this.state.playback_status = "playing"
if (this.waitUpdateTimeout) {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
})
instance.media.addEventListener("pause", () => {
this.state.playback_status = "paused"
})
instance.media.addEventListener("durationchange", (duration) => {
if (instance.media.paused) {
return false
}
instance.duration = duration
})
instance.media.addEventListener("waiting", () => {
if (instance.media.paused) {
return false
}
if (this.waitUpdateTimeout) {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
// if takes more than 150ms to load, update loading state
this.waitUpdateTimeout = setTimeout(() => {
this.state.loading = true
}, 150)
})
instance.media.addEventListener("seeked", () => {
instance.seek = instance.media.currentTime
if (this.state.sync_mode) {
// useMusicSync("music:player:seek", {
// position: instance.seek,
// state: this.state,
// })
}
this.eventBus.emit(`player.seeked`, instance.seek)
})
instance.media.addEventListener("loadedmetadata", () => {
if (instance.media.duration === Infinity) {
instance.manifest.stream = true
this.state.livestream_mode = true
}
}, { once: true })
instance.track = this.audioContext.createMediaElementSource(instance.media)
return instance
}
async attachProcessorsToInstance(instance) {
for await (const [index, processor] of this.audioProcessors.entries()) {
if (processor.constructor.node_bypass === true) {
instance.track.connect(processor.processor)
instance.contextElement.connect(processor.processor)
processor.processor.connect(this.audioContext.destination)
@ -515,35 +372,32 @@ export default class Player extends Core {
this.track_instance = await this.preloadAudioInstance(instance)
// reconstruct audio src if is not set
if (this.track_instance.media.src !== instance.source) {
this.track_instance.media.src = instance.source
if (this.track_instance.audio.src !== instance.manifest.source) {
this.track_instance.audio.src = instance.manifest.source
}
// set time to 0
this.track_instance.media.currentTime = 0
this.track_instance.audio.currentTime = 0
if (params.time >= 0) {
this.track_instance.media.currentTime = params.time
this.track_instance.audio.currentTime = params.time
}
if (params.volume >= 0) {
this.track_instance.gainNode.gain.value = params.volume
} else {
this.track_instance.gainNode.gain.value = this.state.volume
}
this.track_instance.media.muted = this.state.muted
this.track_instance.media.loop = this.state.playback_mode === "repeat"
this.track_instance.audio.muted = this.state.muted
this.track_instance.audio.loop = this.state.playback_mode === "repeat"
this.track_instance.gainNode.gain.value = this.state.volume
// try to preload next audio
// TODO: Use a better way to preload queues
if (this.track_next_instances.length > 0) {
this.preloadAudioInstance(1)
}
// play
await this.track_instance.media.play()
await this.track_instance.audio.play()
this.console.log(this.track_instance)
this.console.debug(`Playing track >`, this.track_instance)
// update manifest
this.state.track_manifest = instance.manifest
@ -572,45 +426,31 @@ export default class Player extends Core {
this.track_prev_instances = []
this.track_next_instances = []
const isPlaylist = Array.isArray(manifest)
let playlist = Array.isArray(manifest) ? manifest : [manifest]
if (isPlaylist) {
let playlist = manifest
if (playlist.length === 0) {
this.console.warn(`[PLAYER] Playlist is empty, aborting...`)
return false
}
if (playlist.some((item) => typeof item === "string")) {
this.console.log("Resolving missing manifests by ids...")
playlist = await ServicesHandlers.default.resolveMany(playlist)
}
playlist = playlist.slice(startIndex)
for await (const [index, _manifest] of playlist.entries()) {
const instance = await this.createInstance(_manifest)
this.track_next_instances.push(instance)
if (index === 0) {
this.play(this.track_next_instances[0], {
time: time ?? 0
})
}
}
return playlist
if (playlist.length === 0) {
this.console.warn(`Playlist is empty, aborting...`)
return false
}
const instance = await this.createInstance(manifest)
if (playlist.some((item) => typeof item === "string")) {
playlist = await this.service_providers.resolveMany(playlist)
}
this.track_next_instances.push(instance)
playlist = playlist.slice(startIndex)
this.play(this.track_next_instances[0], {
time: time ?? 0
})
for await (const [index, _manifest] of playlist.entries()) {
let instance = new TrackInstanceClass(this, _manifest)
instance = await instance.initialize()
this.track_next_instances.push(instance)
if (index === 0) {
this.play(this.track_next_instances[0], {
time: time ?? 0
})
}
}
return manifest
}
@ -627,7 +467,7 @@ export default class Player extends Core {
}
if (this.track_next_instances.length === 0) {
this.console.log(`[PLAYER] No more tracks to play, stopping...`)
this.console.log(`No more tracks to play, stopping...`)
return this.stop()
}
@ -683,7 +523,7 @@ export default class Player extends Core {
)
setTimeout(() => {
this.track_instance.media.pause()
this.track_instance.audio.pause()
resolve()
}, Player.gradualFadeMs)
@ -705,7 +545,7 @@ export default class Player extends Core {
// ensure audio elemeto starts from 0 volume
this.track_instance.gainNode.gain.value = 0.0001
this.track_instance.media.play().then(() => {
this.track_instance.audio.play().then(() => {
resolve()
})
@ -743,7 +583,7 @@ export default class Player extends Core {
if (typeof to === "boolean") {
this.state.muted = to
this.track_instance.media.muted = to
this.track_instance.audio.muted = to
}
return this.state.muted
@ -783,13 +623,13 @@ export default class Player extends Core {
}
seek(time, { sync = false } = {}) {
if (!this.track_instance || !this.track_instance.media) {
if (!this.track_instance || !this.track_instance.audio) {
return false
}
// if time not provided, return current time
if (typeof time === "undefined") {
return this.track_instance.media.currentTime
return this.track_instance.audio.currentTime
}
if (this.state.control_locked && !sync) {
@ -797,9 +637,12 @@ export default class Player extends Core {
return false
}
// if time is provided, seek to that time
if (typeof time === "number") {
this.track_instance.media.currentTime = time
this.console.log(`Seeking to ${time} | Duration: ${this.track_instance.audio.duration}`)
this.track_instance.audio.currentTime = time
return time
}
@ -813,7 +656,7 @@ export default class Player extends Core {
this.state.playback_mode = mode
if (this.track_instance) {
this.track_instance.media.loop = this.state.playback_mode === "repeat"
this.track_instance.audio.loop = this.state.playback_mode === "repeat"
}
AudioPlayerStorage.set("mode", mode)
@ -826,7 +669,7 @@ export default class Player extends Core {
return false
}
return this.track_instance.media.duration
return this.track_instance.audio.duration
}
loop(to) {
@ -837,8 +680,8 @@ export default class Player extends Core {
this.state.loop = to ?? !this.state.loop
if (this.track_instance.media) {
this.track_instance.media.loop = this.state.loop
if (this.track_instance.audio) {
this.track_instance.audio.loop = this.state.loop
}
return this.state.loop
@ -897,45 +740,6 @@ export default class Player extends Core {
return this.mute(to)
}
async getTracksByIds(list) {
if (!Array.isArray(list)) {
this.console.warn("List must be an array")
return false
}
let ids = []
list.forEach((item) => {
if (typeof item === "string") {
ids.push(item)
}
})
if (ids.length === 0) {
return list
}
const fetchedTracks = await MusicModel.getTracksData(ids).catch((err) => {
this.console.error(err)
return false
})
if (!fetchedTracks) {
return list
}
// replace fetched tracks with the ones in the list
fetchedTracks.forEach((fetchedTrack) => {
const index = list.findIndex((item) => item === fetchedTrack._id)
if (index !== -1) {
list[index] = fetchedTrack
}
})
return list
}
async setSampleRate(to) {
// must be a integer
if (typeof to !== "number") {
@ -973,38 +777,4 @@ export default class Player extends Core {
})
})
}
async toggleCurrentTrackLike(to, manifest) {
let isCurrent = !!!manifest
if (typeof manifest === "undefined") {
manifest = this.track_instance.manifest
}
if (!manifest) {
this.console.error("Track instance or manifest not found")
return false
}
if (typeof to !== "boolean") {
this.console.warn("Like must be a boolean")
return false
}
const service = manifest.service ?? "default"
if (!ServicesHandlers[service].toggleLike) {
this.console.error(`Service [${service}] does not support like actions`)
return false
}
const result = await ServicesHandlers[service].toggleLike(manifest, to)
if (isCurrent) {
this.track_instance.manifest.liked = to
this.state.track_manifest.liked = to
}
return result
}
}

View File

@ -72,7 +72,7 @@ export default class ProcessorNode {
prevNode.processor._last.connect(this.processor)
} else {
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is the first node, connecting to the media source...`)
instance.track.connect(this.processor)
instance.contextElement.connect(this.processor)
}
// now, check if it has a next node

View File

@ -1,21 +1,59 @@
import MusicModel from "comty.js/models/music"
export default {
"default": {
resolve: async (track_id) => {
return await MusicModel.getTrackData(track_id)
},
resolveMany: async (track_ids, options) => {
const response = await MusicModel.getTrackData(track_ids, options)
class ComtyMusicService {
static id = "default"
if (response.list) {
return response
}
resolve = async (track_id) => {
return await MusicModel.getTrackData(track_id)
}
return [response]
},
toggleLike: async (manifest, to) => {
return await MusicModel.toggleTrackLike(manifest, to)
resolveMany = async (track_ids, options) => {
const response = await MusicModel.getTrackData(track_ids, options)
if (response.list) {
return response
}
return [response]
}
toggleTrackLike = async (manifest, to) => {
return await MusicModel.toggleTrackLike(manifest, to)
}
}
export default class ServiceProviders {
providers = [
new ComtyMusicService()
]
findProvider(providerId) {
return this.providers.find((provider) => provider.constructor.id === providerId)
}
register(provider) {
this.providers.push(provider)
}
// operations
resolve = async (providerId, manifest) => {
const provider = await this.findProvider(providerId)
if (!provider) {
console.error(`Failed to resolve manifest, provider [${providerId}] not registered`)
return manifest
}
return await provider.resolve(manifest)
}
resolveMany = async (manifests) => {
manifests = manifests.map(async (manifest) => {
return await this.resolve(manifest.service ?? "default", manifest)
})
manifests = await Promise.all(manifests)
return manifests
}
}

View File

@ -55,8 +55,6 @@ export default class SFXCore extends Core {
src: [path],
})
}
this.console.log(this.soundsPool)
}
async play(name, options = {}) {

View File

@ -11,22 +11,32 @@ const variantToAlgorithm = {
dark: theme.darkAlgorithm,
}
const ClientPrefersDark = () => window.matchMedia("(prefers-color-scheme: dark)")
function variantKeyToColor(key) {
if (key == "auto") {
if (ClientPrefersDark().matches) {
return "dark"
}
return "light"
}
return key
}
export class ThemeProvider extends React.Component {
state = {
useAlgorigthm: app.cores.style.currentVariant ?? "dark",
useCompactMode: app.cores.style.getValue("compact-mode"),
useAlgorigthm: variantKeyToColor(app.cores.style.currentVariantKey),
useCompactMode: app.cores.style.getVar("compact-mode"),
}
handleUpdate = (update) => {
console.log("[THEME] Update", update)
if (update.themeVariant) {
this.setState({
useAlgorigthm: update.themeVariant
})
}
this.setState({
useAlgorigthm: variantKeyToColor(app.cores.style.currentVariantKey),
useCompactMode: update["compact-mode"]
})
}
@ -51,7 +61,7 @@ export class ThemeProvider extends React.Component {
return <ConfigProvider
theme={{
token: {
...app.cores.style.getValue(),
...app.cores.style.getVar(),
},
algorithm: themeAlgorithms,
}}
@ -69,8 +79,8 @@ export default class StyleCore extends Core {
static dependencies = ["settings"]
static themeManifestStorageKey = "theme"
static modificationStorageKey = "themeModifications"
static modificationStorageKey = "theme-modifications"
static defaultVariantKey = "auto"
static get rootVariables() {
let attributes = document.documentElement.getAttribute("style").trim().split(";")
@ -87,142 +97,105 @@ export default class StyleCore extends Core {
return Object.fromEntries(attributes)
}
static get storagedTheme() {
return store.get(StyleCore.themeManifestStorageKey)
static get storagedVariantKey() {
return app.cores.settings.get("style:theme_variant")
}
static get storagedVariant() {
return app.cores.settings.get("style:darkmode") ? "dark" : "light"
static set storagedVariantKey(key) {
return app.cores.settings.set("style:theme_variant", key)
}
isOnTemporalVariant = false
// modifications
static get storagedModifications() {
return store.get(StyleCore.modificationStorageKey) ?? {}
}
static set storagedModifications(modifications) {
return store.set(StyleCore.modificationStorageKey, modifications)
}
static get storagedModifications() {
return store.get(StyleCore.modificationStorageKey) ?? {}
}
public = {
theme: null,
mutation: null,
currentVariantKey: null,
static get variant() {
if (window.app.cores.settings.is("style:auto_darkmode", true)) {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark"
}
}
getVar: (...args) => this.getVar(...args),
getDefaultVar: (...args) => this.getDefaultVar(...args),
getStoragedVariantKey: () => StyleCore.storagedVariantKey,
return StyleCore.storagedVariant
applyStyles: (...args) => this.applyStyles(...args),
applyVariant: (...args) => this.applyVariant(...args),
applyTemporalVariant: (...args) => this.applyTemporalVariant(...args),
mutateTheme: (...args) => this.mutateTheme(...args),
resetToDefault: () => this.resetToDefault(),
toggleCompactMode: () => this.toggleCompactMode(),
}
async onInitialize() {
if (StyleCore.storagedTheme) {
// TODO: Start remote theme loader
} else {
this.public.theme = config.defaultTheme
}
this.public.theme = config.defaultTheme
const modifications = StyleCore.storagedModifications
// override with static vars
if (this.public.theme.defaultVars) {
this.update(this.public.theme.defaultVars)
this.applyStyles(this.public.theme.defaultVars)
}
// override theme with modifications
if (modifications) {
this.update(modifications)
this.applyStyles(modifications)
}
// apply variation
this.applyVariant(StyleCore.variant)
// handle auto prefered color scheme
window.matchMedia("(prefers-color-scheme: light)").addListener(() => {
this.console.log(`[THEME] Auto color scheme changed`)
this.applyVariant(StyleCore.variant)
})
this.applyVariant(StyleCore.storagedVariantKey ?? StyleCore.defaultVariantKey)
// if mobile set fontScale to 1
if (app.isMobile) {
this.update({
this.applyStyles({
fontScale: 1
})
}
}
onEvents = {
"style:auto_darkmode": (value) => {
if (value === true) {
return this.applyVariant(StyleCore.variant)
ClientPrefersDark().addEventListener("change", (event) => {
this.console.log("[PREFERS-DARK] Change >", event.matches)
if (this.isOnTemporalVariant) {
return false
}
return this.applyVariant(StyleCore.variant)
}
if (event.matches) {
this.applyVariant("dark")
} else {
this.applyVariant("light")
}
})
}
public = {
theme: null,
mutation: null,
currentVariant: "dark",
getValue: (...args) => this.getValue(...args),
setDefault: () => this.setDefault(),
update: (...args) => this.update(...args),
applyVariant: (...args) => this.applyVariant(...args),
applyInitialVariant: () => this.applyVariant(StyleCore.variant),
compactMode: (value = !window.app.cores.settings.get("style.compactMode")) => {
if (value) {
return this.update({
layoutMargin: 0,
layoutPadding: 0,
})
}
return this.update({
layoutMargin: this.getValue("layoutMargin"),
layoutPadding: this.getValue("layoutPadding"),
})
},
modify: (value) => {
this.public.update(value)
this.applyVariant(this.public.mutation.themeVariant ?? this.public.currentVariant)
StyleCore.storagedModifications = this.public.mutation
},
defaultVar: (key) => {
if (!key) {
return this.public.theme.defaultVars
}
return this.public.theme.defaultVars[key]
},
storagedVariant: StyleCore.storagedVariant,
storagedModifications: StyleCore.storagedModifications,
}
getValue(key) {
getVar(key) {
if (typeof key === "undefined") {
return {
...this.public.theme.defaultVars,
...StyleCore.storagedModifications
...StyleCore.storagedModifications,
}
}
return StyleCore.storagedModifications[key] || this.public.theme.defaultVars[key]
}
setDefault() {
store.remove(StyleCore.themeManifestStorageKey)
store.remove(StyleCore.modificationStorageKey)
getDefaultVar(key) {
if (!key) {
return this.public.theme.defaultVars
}
app.cores.settings.set("colorPrimary", this.public.theme.defaultVars.colorPrimary)
this.onInitialize()
return this.public.theme.defaultVars[key]
}
update(update) {
applyStyles(update) {
if (typeof update !== "object") {
this.console.error("Invalid update, must be an object")
return false
}
@ -241,18 +214,62 @@ export default class StyleCore extends Core {
})
}
applyVariant(variant = (this.public.theme.defaultVariant ?? "light")) {
const values = this.public.theme.variants[variant]
applyVariant = (variantKey = (this.public.theme.defaultVariant ?? "light"), save = true) => {
if (save) {
StyleCore.storagedVariantKey = variantKey
this.public.currentVariantKey = variantKey
}
this.isOnTemporalVariant = false
this.console.log(`Input variant key [${variantKey}]`)
const color = variantKeyToColor(variantKey)
this.console.log(`Applying variant [${color}]`)
const values = this.public.theme.variants[color]
if (!values) {
this.console.error(`Variant [${variant}] not found`)
this.console.error(`Variant [${color}] not found`)
return false
}
values.themeVariant = variant
this.applyStyles(values)
}
this.public.currentVariant = variant
applyTemporalVariant = (variantKey) => {
this.applyVariant(variantKey, false)
this.update(values)
this.isOnTemporalVariant = true
}
mutateTheme(update) {
this.applyStyles(update)
this.applyVariant(this.public.currentVariantKey)
StyleCore.storagedModifications = this.public.mutation
}
toggleCompactMode(value = !window.app.cores.settings.get("style.compactMode")) {
if (value === true) {
return this.applyStyles({
layoutMargin: 0,
layoutPadding: 0,
})
}
return this.applyStyles({
layoutMargin: this.getVar("layoutMargin"),
layoutPadding: this.getVar("layoutPadding"),
})
}
resetToDefault() {
store.remove(StyleCore.modificationStorageKey)
app.cores.settings.set("colorPrimary", this.public.theme.defaultVars.colorPrimary)
this.onInitialize()
}
}

View File

@ -9,7 +9,7 @@ export default (to_user_id) => {
const [isRemoteTyping, setIsRemoteTyping] = React.useState(false)
const [timeoutOffTypingEvent, setTimeoutOffTypingEvent] = React.useState(null)
async function sendMessage(message) {
emitTypingEvent(false)

View File

@ -0,0 +1,39 @@
import React from "react"
const useClickNavById = (navigators = {}, itemFlagId = "div") => {
const ref = React.useRef(null)
async function onClick(e) {
const element = e.target.closest(itemFlagId ?? "div")
if (!element) {
console.error("Element not found")
return false
}
const id = element?.id
if (!id) {
console.error("Element id not found")
return false
}
const location = navigators[id]
if (!location) {
console.error("Location not found")
return false
}
app.location.push(location)
}
return [
ref,
{
onClick
}
]
}
export default useClickNavById

View File

@ -0,0 +1,59 @@
import React from "react"
let timer = null
const useHideOnMouseStop = ({
delay = 2000,
hideCursor = false,
initialHide = false,
showOnlyOnContainerHover = false,
}) => {
const [hide, setHide] = React.useState(initialHide)
const mountedRef = React.useRef(false)
const [hover, setHover] = React.useState(false)
const toggleVisibility = React.useCallback((hide, cursor) => {
setHide(hide)
if (hideCursor) {
document.body.style.cursor = cursor
}
}, [hideCursor])
const onMouseEnter = React.useCallback(() => setHover(true), [setHover])
const onMouseLeave = React.useCallback(() => setHover(false), [setHover])
const onMouseMove = React.useCallback(() => {
clearTimeout(timer)
if (hide && mountedRef.current) {
if (showOnlyOnContainerHover && hover) {
toggleVisibility(!hide, "default")
} else if (!showOnlyOnContainerHover) {
toggleVisibility(!hide, "default")
}
}
timer = setTimeout(() => {
if (!hover && mountedRef.current) {
toggleVisibility(true, "none")
}
}, delay)
}, [hide, hover, setHide])
React.useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
React.useEffect(() => {
window.addEventListener("mousemove", onMouseMove)
return () => {
window.removeEventListener("mousemove", onMouseMove)
}
}, [onMouseMove])
return [hide, onMouseEnter, onMouseLeave]
}
export default useHideOnMouseStop

View File

@ -2,15 +2,16 @@ import React from "react"
export default () => {
const enterPlayerAnimation = () => {
app.cores.style.applyVariant("dark")
app.cores.style.compactMode(true)
app.cores.style.applyTemporalVariant("dark")
app.cores.style.toggleCompactMode(true)
app.layout.toggleCenteredContent(false)
app.controls.toggleUIVisibility(false)
}
const exitPlayerAnimation = () => {
app.cores.style.applyInitialVariant()
app.cores.style.compactMode(false)
app.cores.style.applyVariant(app.cores.style.getStoragedVariantKey())
app.cores.style.toggleCompactMode(false)
app.layout.toggleCenteredContent(true)
app.controls.toggleUIVisibility(true)
}

View File

@ -31,10 +31,10 @@ const tourSteps = [
]
const openPlayerView = () => {
app.DrawerController.open("player", PlayerView)
app.layout.drawer.open("player", PlayerView)
}
const openCreator = () => {
app.DrawerController.open("creator", CreatorView, {
app.layout.drawer.open("creator", CreatorView, {
props: {
bodyStyle: {
minHeight: "unset",
@ -336,7 +336,7 @@ export class BottomBar extends React.Component {
</div>
}
const heightValue = this.state.visible ? Number(app.cores.style.defaultVar("bottom-bar-height").replace("px", "")) : 0
const heightValue = this.state.visible ? Number(app.cores.style.getDefaultVar("bottom-bar-height").replace("px", "")) : 0
return <>
{

View File

@ -13,7 +13,7 @@ export default class DrawerController extends React.Component {
drawers: [],
}
window.app["DrawerController"] = {
app.layout.drawer = {
open: this.open,
close: this.close,
closeAll: this.closeAll,

View File

@ -11,17 +11,13 @@ import sidebarItems from "@config/sidebar"
import "./index.less"
const extraItems = [
{
id: "insiders",
title: "Insiders",
icon: "MdToken",
roles: ["insider"],
path: "/insiders",
}
]
const onClickHandlers = {
addons: () => {
window.app.location.push("/addons")
},
studio: () => {
window.app.location.push("/studio")
},
settings: () => {
window.app.navigation.goToSettings()
},
@ -99,11 +95,29 @@ const BottomMenuDefaultItems = [
const ActionMenuItems = [
{
key: "account",
key: "profile",
label: <>
<Icons.User />
<Translation>
{t => t("Account")}
{t => t("Profile")}
</Translation>
</>,
},
{
key: "studio",
label: <>
<Icons.MdHardware />
<Translation>
{t => t("Studio")}
</Translation>
</>,
},
{
key: "addons",
label: <>
<Icons.Box />
<Translation>
{t => t("Addons")}
</Translation>
</>,
},
@ -256,8 +270,6 @@ export default class Sidebar extends React.Component {
}
componentDidMount = async () => {
this.computeExtraItems()
for (const [event, handler] of Object.entries(this.events)) {
app.eventBus.on(event, handler)
}
@ -279,28 +291,6 @@ export default class Sidebar extends React.Component {
//delete app.layout.sidebar
}
computeExtraItems = async () => {
const roles = await app.cores.permissions.getRoles()
const resultItems = []
if (roles.includes("admin")) {
resultItems.push(...extraItems)
} else {
extraItems.forEach((item) => {
item.roles.every((role) => {
if (roles.includes(role)) {
resultItems.push(item)
}
})
})
}
this.setState({
topItems: generateTopItems(resultItems)
})
}
handleClick = (e) => {
if (e.item.props.ignore_click === "true") {
return
@ -470,7 +460,6 @@ export default class Sidebar extends React.Component {
mode="inline"
onClick={this.handleClick}
items={this.getBottomItems()}
/>
</div>
</div>

View File

@ -55,26 +55,28 @@ export default class ToolsBar extends React.Component {
}
render() {
return <Motion style={{
x: spring(this.state.visible ? 0 : 100),
width: spring(this.state.visible ? 100 : 0),
}}>
return <Motion
style={{
x: spring(this.state.visible ? 0 : 100),
width: spring(this.state.visible ? 100 : 0),
}}
>
{({ x, width }) => {
return <div
className="tools-bar-wrapper"
style={{
width: `${width}%`,
transform: `translateX(${x}%)`,
}}
className={classnames(
"tools-bar-wrapper",
{
visible: this.state.visible,
}
)}
>
<div
id="tools_bar"
className={classnames(
"tools-bar",
{
visible: this.state.visible,
}
)}
className="tools-bar"
>
{/* <div className="card" id="trendings">
<div className="header">

View File

@ -6,15 +6,22 @@
top: 0;
right: 0;
max-width: 420px;
min-width: 320px;
height: 100vh;
height: 100dvh;
padding: 10px;
max-width: 420px;
z-index: 150;
padding: 10px;
.visible {
min-width: 320px;
}
&:not(.visible) {
min-width: 0;
padding: 0;
}
}
.tools-bar {
@ -29,13 +36,13 @@
border-radius: @sidebar_borderRadius;
box-shadow: @card-shadow;
padding: 10px;
background-color: var(--background-color-accent);
gap: 20px;
&.visible {
padding: 10px;
}
flex: 0;
.card {
display: flex;

View File

@ -85,7 +85,7 @@ export default (props) => {
}
}, [render])
const heightValue = visible ? Number(app.cores.style.defaultVar("top-bar-height").replace("px", "")) : 0
const heightValue = visible ? Number(app.cores.style.getDefaultVar("top-bar-height").replace("px", "")) : 0
return <Motion style={{
y: spring(visible ? 0 : 300,),

View File

@ -7,14 +7,14 @@ import Image from "@components/Image"
import "./index.less"
const FieldItem = (props) => {
return <div className="marketplace-field-item">
<div className="marketplace-field-item-image">
return <div className="addons-field-item">
<div className="addons-field-item-image">
<Image
src={props.image}
/>
</div>
<div className="marketplace-field-item-info">
<div className="addons-field-item-info">
<h1>
{props.title}
</h1>
@ -26,16 +26,16 @@ const FieldItem = (props) => {
</div>
}
const ExtensionsBrowser = () => {
return <div className="marketplace-field">
<div className="marketplace-field-header">
const AddonsBrowser = () => {
return <div className="addons-field">
<div className="addons-field-header">
<h1>
<Icons.MdCode />
Extensions
Addons
</h1>
</div>
<div className="marketplace-field-slider">
<div className="addons-field-slider">
<FieldItem
title="Example Extension"
description="Description"
@ -60,22 +60,22 @@ const ExtensionsBrowser = () => {
</div>
}
const Marketplace = () => {
return <div className="marketplace">
<div className="marketplace-header">
<div className="marketplace-header-card">
const addons = () => {
return <div className="addons-page">
<div className="addons-header">
<div className="addons-header-card">
<h1>
Marketplace
Addons
</h1>
</div>
<SearchButton />
</div>
<ExtensionsBrowser />
<ExtensionsBrowser />
<ExtensionsBrowser />
<AddonsBrowser />
<AddonsBrowser />
<AddonsBrowser />
</div>
}
export default Marketplace
export default addons

View File

@ -1,4 +1,4 @@
.marketplace {
.addons-page {
display: flex;
flex-direction: column;
@ -6,7 +6,7 @@
width: 100%;
.marketplace-header {
.addons-header {
display: flex;
flex-direction: row;
@ -16,7 +16,7 @@
width: 100%;
.marketplace-header-card {
.addons-header-card {
display: flex;
flex-direction: row;
@ -37,20 +37,20 @@
}
}
.marketplace-field {
.addons-field {
display: flex;
flex-direction: column;
.marketplace-field-header {}
.addons-field-header {}
.marketplace-field-slider {
.addons-field-slider {
display: flex;
flex-direction: row;
gap: 20px;
}
.marketplace-field-item {
.addons-field-item {
position: relative;
display: flex;
@ -67,7 +67,7 @@
padding: 10px;
.marketplace-field-item-image {
.addons-field-item-image {
width: 100%;
height: 60%;
@ -90,7 +90,7 @@
}
}
.marketplace-field-item-info {
.addons-field-item-info {
display: flex;
flex-direction: column;

View File

@ -332,7 +332,7 @@ const FileListItem = (props) => {
export default (props) => {
const onClickEditTrack = (track) => {
app.DrawerController.open("track_editor", FileItemEditor, {
app.layout.drawer.open("track_editor", FileItemEditor, {
type: "drawer",
props: {
width: "30vw",

View File

@ -1,8 +1,10 @@
import React from "react"
import { Tag } from "antd"
import { Tag, Button } from "antd"
import classnames from "classnames"
import Marquee from "react-fast-marquee"
import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
import { Icons } from "@components/Icons"
import Controls from "@components/Player/Controls"
@ -49,6 +51,7 @@ const PlayerController = React.forwardRef((props, ref) => {
const titleRef = React.useRef()
const [hide, onMouseEnter, onMouseLeave] = useHideOnMouseStop({ delay: 3000, hideCursor: true })
const [titleIsOverflown, setTitleIsOverflown] = React.useState(false)
const [currentTime, setCurrentTime] = React.useState(0)
@ -61,6 +64,7 @@ const PlayerController = React.forwardRef((props, ref) => {
setDraggingTime(false)
app.cores.player.seek(seekTime)
syncPlayback()
}
async function syncPlayback() {
@ -87,12 +91,24 @@ const PlayerController = React.forwardRef((props, ref) => {
React.useEffect(() => {
setTitleIsOverflown(isOverflown(titleRef.current))
setTrackDuration(app.cores.player.duration())
console.log(context.track_manifest)
}, [context.track_manifest])
React.useEffect(() => {
syncPlayback()
}, [])
const isStopped = context.playback_status === "stopped"
return <div
className="lyrics-player-controller-wrapper"
className={classnames(
"lyrics-player-controller-wrapper",
{
["hidden"]: hide,
}
)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div className="lyrics-player-controller">
<div className="lyrics-player-controller-info">
@ -174,7 +190,6 @@ const PlayerController = React.forwardRef((props, ref) => {
<div className="lyrics-player-controller-tags">
{
context.track_manifest?.metadata.lossless && <Tag
color="geekblue"
icon={<Icons.TbWaveSine />}
bordered={false}
>
@ -188,6 +203,22 @@ const PlayerController = React.forwardRef((props, ref) => {
Explicit
</Tag>
}
{
props.lyrics?.sync_audio_at && <Tag
bordered={false}
icon={<Icons.TbMovie />}
>
Video
</Tag>
}
{
props.lyrics?.available_langs && <Button
icon={<Icons.MdTranslate />}
type={props.translationEnabled ? "primary" : "default"}
onClick={() => props.toggleTranslationEnabled()}
size="small"
/>
}
</div>
</div>
</div>

View File

@ -54,6 +54,8 @@ const LyricsText = React.forwardRef((props, textRef) => {
if (currentLineIndex === 0) {
setVisible(false)
} else {
setVisible(true)
console.log(`Scrolling to line ${currentLineIndex}`)
// find line element by id
const lineElement = textRef.current.querySelector(`#lyrics-line-${currentLineIndex}`)
@ -63,24 +65,26 @@ const LyricsText = React.forwardRef((props, textRef) => {
behavior: "smooth",
block: "center",
})
} else {
// scroll to top
textRef.current.scrollTop = 0
}
}
}, [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()
if (typeof lyrics?.lrc !== "undefined") {
if (context.playback_status === "playing") {
startSyncInterval()
} else {
if (syncInterval) {
clearInterval(syncInterval)
}
}
} else {
clearInterval(syncInterval)
}
}, [context.playback_status])
//* Handle when lyrics object change
@ -96,6 +100,12 @@ const LyricsText = React.forwardRef((props, textRef) => {
}
}, [lyrics])
React.useEffect(() => {
setVisible(false)
clearInterval(syncInterval)
setCurrentLineIndex(0)
}, [context.track_manifest])
React.useEffect(() => {
return () => {
clearInterval(syncInterval)

View File

@ -1,5 +1,7 @@
import React from "react"
import classnames from "classnames"
import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
import { Context } from "@contexts/WithPlayerContext"
const maxLatencyInMs = 55
@ -32,6 +34,13 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
// 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") {
if (!videoRef.current) {
clearInterval(syncInterval)
setSyncInterval(null)
setCurrentVideoLatency(0)
return false
}
const currentTrackTime = app.cores.player.seek()
const currentVideoTime = videoRef.current.currentTime - (lyrics.sync_audio_at_ms / 1000)
@ -55,7 +64,7 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
}
function startSyncInterval() {
setSyncInterval(setInterval(syncPlayback, 100))
setSyncInterval(setInterval(syncPlayback, 300))
}
React.useEffect(() => {
@ -97,6 +106,17 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
}
}, [context.playback_status])
React.useEffect(() => {
if (context.loading === true && context.playback_status === "playing") {
videoRef.current.pause()
}
if (context.loading === false && context.playback_status === "playing") {
videoRef.current.play()
}
}, [context.loading])
//* Handle when lyrics object change
React.useEffect(() => {
if (lyrics) {
@ -141,17 +161,24 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
}, [])
return <>
<div className="videoDebugOverlay">
<div>
<p>Maximun latency</p>
<p>{maxLatencyInMs}ms</p>
{
props.lyrics?.sync_audio_at && <div
className={classnames(
"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>
<div>
<p>Video Latency</p>
<p>{(currentVideoLatency * 1000).toFixed(2)}ms</p>
</div>
{syncingVideo ? <p>Syncing video...</p> : null}
</div>
}
<video
className="lyrics-video"

View File

@ -1,7 +1,7 @@
import React from "react"
import classnames from "classnames"
import useMaxScreen from "@utils/useMaxScreen"
import useMaxScreen from "@hooks/useMaxScreen"
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
import MusicService from "@models/music"
@ -14,23 +14,42 @@ import "./index.less"
const EnchancedLyrics = (props) => {
const context = React.useContext(Context)
const [initialized, setInitialized] = React.useState(false)
const [lyrics, setLyrics] = React.useState(null)
const [translationEnabled, setTranslationEnabled] = React.useState(false)
const videoRef = React.useRef()
const textRef = React.useRef()
async function loadLyrics(track_id) {
const result = await MusicService.getTrackLyrics(track_id)
const result = await MusicService.getTrackLyrics(track_id, {
preferTranslation: translationEnabled,
})
if (result) {
setLyrics(result)
}
}
async function toggleTranslationEnabled(to) {
setTranslationEnabled((prev) => {
return to ?? !prev
})
}
useMaxScreen()
React.useEffect((prev) => {
if (initialized) {
loadLyrics(context.track_manifest._id)
}
}, [translationEnabled])
//* Handle when context change track_manifest
React.useEffect(() => {
setLyrics(null)
if (context.track_manifest) {
loadLyrics(context.track_manifest._id)
}
@ -41,6 +60,10 @@ const EnchancedLyrics = (props) => {
console.log(lyrics)
}, [lyrics])
React.useEffect(() => {
setInitialized(true)
}, [])
return <div
className={classnames(
"lyrics",
@ -57,10 +80,13 @@ const EnchancedLyrics = (props) => {
<LyricsText
ref={textRef}
lyrics={lyrics}
translationEnabled={translationEnabled}
/>
<PlayerController
lyrics={lyrics}
translationEnabled={translationEnabled}
toggleTranslationEnabled={toggleTranslationEnabled}
/>
</div>
}

View File

@ -77,6 +77,12 @@
padding: 60px;
transition: all 150ms ease-in-out;
&.hidden {
opacity: 0;
}
.lyrics-player-controller {
position: relative;
@ -256,5 +262,11 @@
width: 200px;
height: fit-content;
transition: all 150ms ease-in-out;
&.hidden {
opacity: 0;
}
}
}

View File

@ -2,12 +2,13 @@ import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { Icons } from "@components/Icons"
import UserPreview from "@components/UserPreview"
import useChat from "@hooks/useChat"
import ChatsService from "@models/chats"
import lodash from "lodash"
import ChatsService from "@models/chats"
import UserService from "@models/user"
import "./index.less"
@ -19,7 +20,16 @@ const ChatPage = (props) => {
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 [L_User, R_User, E_User, M_User] = app.cores.api.useRequest(
UserService.data,
{
user_id: to_user_id
}
)
const [L_History, R_History, E_History, M_History] = app.cores.api.useRequest(
ChatsService.getChatHistory,
to_user_id
)
const {
sendMessage,
@ -30,7 +40,12 @@ const ChatPage = (props) => {
isRemoteTyping,
} = useChat(to_user_id)
async function submitMessage() {
console.log(R_User)
async function submitMessage(e) {
e.preventDefault()
if (!currentText) {
return false
}
@ -88,12 +103,8 @@ const ChatPage = (props) => {
>
<div className="chat-page-header">
<UserPreview
user_id={to_user_id}
user={R_User}
/>
{
isRemoteTyping && <p>Typing...</p>
}
</div>
<div
@ -147,13 +158,27 @@ const ChatPage = (props) => {
<div className="chat-page-input-wrapper">
<div className="chat-page-input">
<antd.Input
<antd.Input.TextArea
placeholder="Enter message"
value={currentText}
onChange={onInputChange}
onPressEnter={submitMessage}
autoSize
maxLength={1024}
maxRows={3}
/>
<antd.Button
type="primary"
icon={<Icons.Send />}
onClick={submitMessage}
/>
</div>
{
isRemoteTyping && R_User && <div className="chat-page-remote-typing">
<span>{R_User.username} is typing...</span>
</div>
}
</div>
</div>
}

View File

@ -93,16 +93,64 @@
}
.chat-page-input-wrapper {
position: absolute;
position: relative;
bottom: 0;
left: 0;
width: 100%;
.chat-page-remote-typing {
position: absolute;
bottom: 0;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
transform: translateY(120%);
padding: 4px 10px;
background-color: var(--background-color-accent);
border-radius: 12px;
}
.chat-page-input {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
border: 2px var(--border-color) solid;
border-radius: 12px;
overflow: hidden;
background-color: var(--background-color-accent);
.ant-input {
border: 0;
border-radius: 0;
background-color: transparent;
box-shadow: none !important;
}
.ant-btn {
border: 0;
border-radius: 0;
height: 40px;
width: 40px;
border-radius: 12px;
}
}
}
}

View File

@ -1,12 +1,82 @@
import React from "react"
import * as antd from "antd"
import ChatsService from "@models/chats"
import TimeAgo from "@components/TimeAgo"
import Image from "@components/Image"
import "./index.less"
const ChatPreview = (props) => {
const { chat } = props
const previewUserId = chat.from_user_id === app.userData._id ? chat.to_user_id : chat.from_user_id
return <div
className="chat-preview"
onClick={() => {
app.location.push(`/messages/${previewUserId}`)
}}
>
<div className="chat-preview-image">
<Image
src={chat.user.avatar}
/>
</div>
<div className="chat-preview-content">
<div className="chat-preview-username">
@{chat.user.username}
</div>
<div className="chat-preview-text" >
<p>
{chat.content}
</p>
</div>
</div>
<div className="chat-preview-date">
<span>
<TimeAgo
time={chat.created_at}
/>
</span>
</div>
</div>
}
const MessagesPage = (props) => {
const [L_Recent, R_Recent, E_Recent, M_Recent] = app.cores.api.useRequest(ChatsService.getRecentChats)
console.log(R_Recent, E_Recent)
if (E_Recent) {
return <antd.Result
status="warning"
title="Error"
subTitle={E_Recent.message}
/>
}
if (L_Recent) {
return <antd.Skeleton active />
}
return <div
className="messages-page"
>
<h1>Recent Messages</h1>
{
R_Recent.map((chat) => {
return <ChatPreview
key={chat._id}
chat={chat}
/>
})
}
</div>
}

View File

@ -0,0 +1,78 @@
.messages-page {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.chat-preview {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
gap: 10px;
padding: 10px;
background-color: var(--background-color-accent);
border-radius: 12px;
p {
margin: 0;
}
.chat-preview-image {
display: flex;
flex-direction: column;
flex-grow: 1;
img {
width: 30px;
height: 30px;
border-radius: 12px;
}
}
.chat-preview-content {
display: flex;
flex-direction: column;
width: 100%;
gap: 5px;
flex-grow: 1;
overflow: hidden;
.chat-preview-text {
display: flex;
flex-direction: column;
width: 100%;
p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.chat-preview-date {
display: flex;
flex-direction: column;
span {
white-space: nowrap;
font-size: 0.8rem;
}
}
}

View File

@ -1,15 +0,0 @@
import React from "react"
import "./index.less"
export default (props) => {
return <div className="music-dashboard">
<div className="music-dashboard_header">
<h1>Your Dashboard</h1>
</div>
<div className="music-dashboard_content">
</div>
</div>
}

View File

@ -1,206 +0,0 @@
import React from "react"
import * as antd from "antd"
import { Icons } from "@components/Icons"
import ImageViewer from "@components/ImageViewer"
import Searcher from "@components/Searcher"
import ReleaseCreator from "../../creator"
import MusicModel from "@models/music"
import "./index.less"
const ReleaseItem = (props) => {
const { key, release } = props
return <div
className="music_panel_releases_item"
key={key}
id={key}
>
<div
className="music_panel_releases_info"
>
<div
className="music_panel_releases_info_cover"
>
<ImageViewer
src={release.cover ?? release.thumbnail ?? "/assets/no_song.png"}
/>
</div>
<div
className="music_panel_releases_info_data"
>
<h1>
{release.title}
</h1>
{
release.description && <h4>
{release.description}
</h4>
}
<div className="music_panel_releases_info_extra">
{
release.public
? <>
<Icons.MdOutlinePublic />
<span>
Public
</span>
</>
: <>
<Icons.MdOutlineLock />
<span>
Private
</span>
</>
}
</div>
</div>
</div>
<div
className="music_panel_releases_actions"
>
<antd.Button
onClick={props.onClickNavigate}
>
Open
</antd.Button>
<antd.Button
onClick={props.onClickEditTrack}
icon={<Icons.Edit />}
>
Modify
</antd.Button>
</div>
</div>
}
const openReleaseCreator = ({
release_id = null,
onModification = () => { }
} = {}) => {
console.log("Opening release creator", release_id)
app.DrawerController.open("release_creator", ReleaseCreator, {
type: "drawer",
props: {
title: <h2
style={{
margin: 0,
}}
>
<Icons.MdOutlineQueueMusic />
Creator
</h2>,
width: "fit-content",
},
componentProps: {
release_id: release_id,
onModification: onModification,
}
})
}
const navigateToRelease = (release_id) => {
return app.location.push(`/play/${release_id}`)
}
export default (props) => {
const [searchResults, setSearchResults] = React.useState(null)
const [L_Releases, R_Releases, E_Releases, M_Releases] = app.cores.api.useRequest(MusicModel.getMyReleases)
if (E_Releases) {
console.error(E_Releases)
return <antd.Result
status="warning"
title="Failed to load"
subTitle="We are sorry, but we could not load your releases. Please try again later."
/>
}
if (L_Releases) {
return <antd.Skeleton active />
}
return <div
className="music_panel_creator"
>
<div className="music_panel_releases_header">
<h1>
<Icons.Music />
Your releases
</h1>
<div className="music_panel_releases_header_actions">
<antd.Button
onClick={() => openReleaseCreator({
onModification: M_Releases,
})}
icon={<Icons.Plus />}
type="primary"
disabled={app.isMobile}
>
New release
</antd.Button>
</div>
</div>
<Searcher
small
renderResults={false}
model={MusicModel.getMyReleases}
onSearchResult={setSearchResults}
onEmpty={() => setSearchResults(null)}
/>
<div className="music_panel_releases_list">
{
searchResults?.items && searchResults.items.length === 0 && <antd.Result
status="info"
title="No results"
subTitle="We are sorry, but we could not find any results for your search."
/>
}
{
searchResults?.items && searchResults.items.length > 0 && searchResults.items.map((release) => {
return <ReleaseItem
key={release._id}
release={release}
onClickEditTrack={() => openReleaseCreator({
release_id: release._id,
onModification: M_Releases,
})}
onClickNavigate={() => navigateToRelease(release._id)}
/>
})
}
{
!searchResults && R_Releases.items.length === 0 && <antd.Result
status="info"
title="No releases"
subTitle="You don't have any releases yet."
/>
}
{
!searchResults && R_Releases.items.map((release) => {
return <ReleaseItem
key={release._id}
release={release}
onClickEditTrack={() => openReleaseCreator({
release_id: release._id,
onModification: M_Releases,
})}
onClickNavigate={() => navigateToRelease(release._id)}
/>
})
}
</div>
</div>
}

View File

@ -1,149 +0,0 @@
.music_panel_creator {
display: flex;
flex-direction: column;
width: 100%;
gap: 20px;
overflow-x: hidden;
overflow-y: overlay;
.music_panel_releases_header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
h1 {
margin: 0;
}
.music_panel_releases_header_actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
.ant-btn {
margin-left: 10px;
&:first-child {
margin-left: 0;
}
}
}
}
.music_panel_releases_list {
display: flex;
flex-direction: column;
width: 100%;
padding-bottom: 20px;
.music_panel_releases_item {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
background-color: var(--background-color-accent);
border-radius: 8px;
overflow: hidden;
padding: 10px;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
.music_panel_releases_info {
display: flex;
flex-direction: row;
height: 100%;
max-width: 65%;
.music_panel_releases_info_cover {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-right: 10px;
border-radius: 8px;
img {
width: 100px;
height: 100px;
border-radius: 8px;
object-fit: cover;
}
}
.music_panel_releases_info_data {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
max-width: 80%;
h1 {
margin: 0 !important;
overflow: hidden;
text-overflow: clip;
}
h4 {
margin: 0 !important;
overflow: hidden;
white-space: pre-wrap;
}
.music_panel_releases_info_extra {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
color: var(--text-color);
}
}
}
.music_panel_releases_actions {
display: flex;
flex-direction: column;
margin-left: 10px;
.ant-btn {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
}
}
}
}

View File

@ -9,6 +9,8 @@ import { Icons, createIconRender } from "@components/Icons"
import MusicTrack from "@components/Music/Track"
import PlaylistItem from "@components/Music/PlaylistItem"
import ReleasesList from "@components/ReleasesList"
import FeedModel from "@models/feed"
import MusicModel from "@models/music"
@ -62,133 +64,12 @@ const MusicNavbar = (props) => {
useUrlQuery
renderResults={false}
model={MusicModel.search}
modelParams={{
useTidal: app.cores.sync.getActiveLinkedServices().tidal,
}}
onSearchResult={props.setSearchResults}
onEmpty={() => props.setSearchResults(false)}
/>
</div>
}
const ReleasesList = (props) => {
const hopNumber = props.hopsPerPage ?? 6
const [offset, setOffset] = React.useState(0)
const [ended, setEnded] = React.useState(false)
const [loading, result, error, makeRequest] = app.cores.api.useRequest(props.fetchMethod, {
limit: hopNumber,
trim: offset
})
const onClickPrev = () => {
if (offset === 0) {
return
}
setOffset((value) => {
const newOffset = value - hopNumber
// check if newOffset is NaN
if (newOffset !== newOffset) {
return false
}
if (typeof makeRequest === "function") {
makeRequest({
trim: newOffset,
limit: hopNumber,
})
}
return newOffset
})
}
const onClickNext = () => {
if (ended) {
return
}
setOffset((value) => {
const newOffset = value + hopNumber
// check if newOffset is NaN
if (newOffset !== newOffset) {
return false
}
if (typeof makeRequest === "function") {
makeRequest({
trim: newOffset,
limit: hopNumber,
})
}
return newOffset
})
}
React.useEffect(() => {
if (result) {
setEnded(result.length < hopNumber)
}
}, [result])
if (error) {
console.error(error)
return <div className="playlistExplorer_section">
<antd.Result
status="warning"
title="Failed to load"
subTitle="We are sorry, but we could not load this requests. Please try again later."
/>
</div>
}
return <div className="playlistExplorer_section">
<div className="playlistExplorer_section_header">
<h1>
{
props.headerIcon
}
<Translation>
{(t) => t(props.headerTitle)}
</Translation>
</h1>
<div className="playlistExplorer_section_header_actions">
<antd.Button
icon={<Icons.MdChevronLeft />}
onClick={onClickPrev}
disabled={offset === 0 || loading}
/>
<antd.Button
icon={<Icons.MdChevronRight />}
onClick={onClickNext}
disabled={ended || loading}
/>
</div>
</div>
<div className="playlistExplorer_section_list">
{
loading && <antd.Skeleton active />
}
{
!loading && result.items.map((playlist, index) => {
return <PlaylistItem
key={index}
playlist={playlist}
/>
})
}
</div>
</div>
}
const ResultGroupsDecorators = {
"playlists": {
icon: "MdPlaylistPlay",

View File

@ -150,59 +150,6 @@ html {
overflow-x: visible;
}
.playlistExplorer_section {
display: flex;
flex-direction: column;
overflow-x: visible;
.playlistExplorer_section_header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 20px;
h1 {
font-size: 1.5rem;
margin: 0;
}
.playlistExplorer_section_header_actions {
display: flex;
flex-direction: row;
gap: 10px;
align-self: center;
margin-left: auto;
}
}
.playlistExplorer_section_list {
display: grid;
grid-gap: 20px;
grid-template-columns: repeat(3, minmax(0, 1fr));
min-width: 372px !important;
@media (min-width: 2000px) {
grid-template-columns: repeat(4, 1fr);
}
@media (min-width: 2300px) {
grid-template-columns: repeat(5, 1fr);
}
.playlistItem {
justify-self: center;
}
}
}
}
.music-explorer_search_results {

View File

@ -1,6 +1,6 @@
import LibraryTab from "./components/library"
import FavoritesTab from "./components/favorites"
import ExploreTab from "./components/explore"
import LibraryTab from "./library"
import FavoritesTab from "./favorites"
import ExploreTab from "./explore"
export default [
{

View File

@ -1,7 +1,6 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { Translation } from "react-i18next"
import { SliderPicker } from "react-color"
@ -9,6 +8,19 @@ import { Icons, createIconRender } from "@components/Icons"
import PerformanceLog from "@classes/PerformanceLog"
import "./index.less"
function shouldUseHorizontalLayout(type) {
switch (type) {
case "switch":
return true
case "button":
return true
default:
return false
}
}
export const SettingsComponents = {
button: {
component: antd.Button,
@ -400,8 +412,10 @@ export default class SettingItemComponent extends React.PureComponent {
className={classnames(
"setting_item",
{
["usePadding"]: this.props.setting.usePadding ?? true
})}
["usePadding"]: this.props.setting.usePadding ?? true,
["useHorizontal"]: this.props.setting.layout ?? shouldUseHorizontalLayout(String(this.props.setting.component).toLowerCase())
})
}
>
<div className="setting_item_header">
<div className="setting_item_info">
@ -414,7 +428,9 @@ export default class SettingItemComponent extends React.PureComponent {
{(t) => t(this.props.setting.title ?? this.props.setting.id)}
</Translation>
</h1>
{this.props.setting.experimental && <antd.Tag> Experimental </antd.Tag>}
{
this.props.setting.experimental && <antd.Tag> Experimental </antd.Tag>
}
</div>
<div className="setting_item_header_description">
<p>

View File

@ -0,0 +1,121 @@
.setting_item {
display: flex;
flex-direction: column;
width: 100%;
padding: 0 15px;
&.useHorizontal {
flex-direction: row;
justify-content: space-between;
gap: 50px;
.setting_item_content {
width: fit-content;
}
}
.uploadButton {
background-color: var(--background-color-primary);
border: 1px solid var(--border-color);
}
.setting_item_header {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
.setting_item_info {
display: flex;
flex-direction: column;
gap: 7px;
width: 100%;
.setting_item_header_title {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
color: var(--background-color-contrast);
h1 {
font-size: 1rem;
margin: 0;
color: var(--background-color-contrast);
}
}
.setting_item_header_description {
p {
color: var(--background-color-contrast);
font-size: 0.7rem;
margin: 0;
}
}
}
.setting_item_header_actions {
display: inline-flex;
align-items: center;
gap: 10px;
.ant-btn {
background-color: var(--background-color-primary-2);
}
.ant-btn:not([disabled]) {
&:hover {
color: var(--colorPrimary);
border: 1px solid var(--colorPrimary);
}
}
}
}
.setting_item_content {
display: flex;
flex-direction: column;
--ignore-dragger: true;
padding: 6px 20px;
width: 100%;
h1,
h2,
h3,
h4,
h5,
h6,
h3,
p,
span {
color: var(--background-color-contrast);
}
button {
width: min-content;
}
.ant-btn.ant-btn-icon-only {
width: 32px;
}
.ant-select {}
}
}

View File

@ -122,6 +122,14 @@ export default () => {
})
}
React.useEffect(() => {
app.layout.tools_bar.toggleVisibility(false)
return () => {
app.layout.tools_bar.toggleVisibility(true)
}
}, [])
return <div className="settings_wrapper">
<div className="settings_menu">
<antd.Menu

View File

@ -46,7 +46,7 @@
width: 700px;
gap: 20px;
gap: 10px;
.settings_content_group {
position: relative;
@ -61,7 +61,7 @@
padding: 20px;
gap: 20px;
gap: 15px;
.settings_content_group_header {
position: relative;
@ -86,106 +86,6 @@
gap: 30px;
width: 100%;
.setting_item {
display: flex;
flex-direction: column;
width: 100%;
padding: 0 20px;
.uploadButton{
background-color: var(--background-color-primary);
border: 1px solid var(--border-color);
}
.setting_item_header {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
.setting_item_info {
display: flex;
flex-direction: column;
width: 100%;
.setting_item_header_title {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
color: var(--background-color-contrast);
h1 {
font-size: 1rem;
margin: 0;
color: var(--background-color-contrast);
}
}
.setting_item_header_description {
p {
color: var(--background-color-contrast);
font-size: 0.7rem;
margin: 0;
}
}
}
.setting_item_header_actions {
display: inline-flex;
align-items: center;
gap: 10px;
.ant-btn {
background-color: var(--background-color-primary-2);
}
.ant-btn:not([disabled]) {
&:hover {
color: var(--colorPrimary);
border: 1px solid var(--colorPrimary);
}
}
}
}
.setting_item_content {
display: flex;
flex-direction: column;
--ignore-dragger: true;
padding: 6px 20px;
width: 100%;
h1,h2,h3,h4,h5,h6,h3,p,span {
color: var(--background-color-contrast);
}
button {
width: min-content;
}
.ant-btn.ant-btn-icon-only {
width: 32px;
}
.ant-select {}
}
}
}
}

View File

@ -0,0 +1,55 @@
import React from "react"
import { Icons } from "@components/Icons"
import useClickNavById from "@hooks/useClickNavById"
import "./index.less"
const SelectorPaths = {
"music": "/studio/music",
"tv": "/studio/tv",
"marketplace": "/studio/marketplace",
}
const StudioPage = () => {
const [navigatorRef, navigatorProps] = useClickNavById(SelectorPaths, ".studio-page-selectors-item")
return <div className="studio-page">
<div className="studio-page-header">
<h1>Studio</h1>
</div>
<div
className="studio-page-selectors"
ref={navigatorRef}
{...navigatorProps}
>
<div
id="music"
className="studio-page-selectors-item"
>
<Icons.MdLibraryMusic />
<span>Music</span>
</div>
<div
id="tv"
className="studio-page-selectors-item"
>
<Icons.MdTv />
<span>TV</span>
</div>
<div
id="marketplace"
className="studio-page-selectors-item"
>
<Icons.MdCode />
<span>Marketplace</span>
</div>
</div>
</div>
}
export default StudioPage

View File

@ -0,0 +1,78 @@
@studio-page-selectors-item-size: 100px;
.studio-page {
display: flex;
flex-direction: column;
gap: 20px;
.studio-page-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 10px 20px;
gap: 10px;
background-color: var(--background-color-accent);
border-radius: 12px;
font-family: "Space Grotesk", sans-serif;
font-size: 1.5rem;
margin: 0;
h1 {
margin: 0;
}
}
.studio-page-selectors {
display: flex;
flex-direction: row;
gap: 10px;
width: 100px;
height: 100px;
.studio-page-selectors-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
max-width: @studio-page-selectors-item-size;
max-height: @studio-page-selectors-item-size;
min-height: @studio-page-selectors-item-size;
min-width: @studio-page-selectors-item-size;
padding: 20px;
gap: 10px;
border: 2px var(--border-color) solid;
border-radius: 12px;
cursor: pointer;
transition: all 150ms ease-in-out;
&:hover {
background-color: var(--background-color-accent);
}
svg,
span {
margin: 0;
}
svg {
font-size: 1.3rem;
}
}
}
}

View File

@ -0,0 +1,13 @@
import React from "react"
import ReleaseEditor from "@components/MusicStudio/ReleaseEditor"
const ReleaseEditorPage = (props) => {
const { release_id } = props.params
return <ReleaseEditor
release_id={release_id}
/>
}
export default ReleaseEditorPage

View File

@ -0,0 +1,32 @@
import React from "react"
import * as antd from "antd"
import { Icons } from "@components/Icons"
import MyReleasesList from "@components/MusicStudio/MyReleasesList"
import "./index.less"
const MusicStudioPage = (props) => {
return <div
className="music-studio-page"
>
<div className="music-studio-page-header">
<h1>Music Studio</h1>
<antd.Button
type="primary"
icon={<Icons.PlusCircle />}
onClick={() => {
app.location.push("/studio/music/new")
}}
>
New Release
</antd.Button>
</div>
<MyReleasesList />
</div>
}
export default MusicStudioPage

View File

@ -0,0 +1,25 @@
.music-studio-page {
display: flex;
flex-direction: column;
width: 100%;
gap: 20px;
.music-studio-page-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.music-studio-page-content {
display: flex;
flex-direction: column;
width: 100%;
}
}

View File

@ -256,15 +256,16 @@ export default class StreamViewer extends React.Component {
}
enterPlayerAnimation = () => {
app.cores.style.applyVariant("dark")
app.cores.style.compactMode(true)
app.cores.style.applyTemporalVariant("dark")
app.cores.style.toggleCompactMode(true)
app.layout.toggleCenteredContent(false)
app.controls.toggleUIVisibility(false)
}
exitPlayerAnimation = () => {
app.cores.style.applyInitialVariant()
app.cores.style.compactMode(false)
app.cores.style.applyVariant(app.cores.style.currentVariantKey)
app.cores.style.toggleCompactMode(false)
app.layout.toggleCenteredContent(true)
app.controls.toggleUIVisibility(true)
}

View File

@ -0,0 +1,40 @@
export default {
id: "accessibility",
icon: "MdAccessibilityNew",
label: "Accessibility",
group: "app",
order: 4,
settings: [
{
id: "haptics:enabled",
storaged: true,
group: "Accessibility",
component: "Switch",
icon: "MdVibration",
title: "Haptic Feedback",
description: "Enable haptic feedback on touch events.",
desktop: false
},
{
id: "longPressDelay",
storaged: true,
group: "Accessibility",
component: "Slider",
icon: "MdTimer",
title: "Long press delay",
description: "Set the delay before long press trigger is activated.",
props: {
min: 300,
max: 2000,
step: 100,
marks: {
300: "0.3s",
600: "0.6s",
1000: "1s",
1500: "1.5s",
2000: "2s",
}
}
},
]
}

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