Merge pull request #158 from ragestudio/dev

This commit is contained in:
srgooglo 2025-07-04 15:51:19 +02:00 committed by GitHub
commit dedf1856a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
225 changed files with 9151 additions and 6252 deletions

3
.gitignore vendored
View File

@ -44,3 +44,6 @@
# .vscode folder
.vscode/
# zed folder
.zed/

@ -1 +1 @@
Subproject commit cbb45df2ef42205022e38e4c7d33a001e162c383
Subproject commit 0dd24fee4a231dbb41d5b14ff92b67d8f14cb5a2

@ -1 +1 @@
Subproject commit fa61273d5b4b40a22d97c7773321d8ca6c985fd7
Subproject commit 1c4291928a3286a6c1d5ed0a4c3f7ac3eb87d45d

View File

@ -8,5 +8,6 @@ yarn-error.log
out/
.ssl
src/pages/_debug
public/oss-licenses.json
/**/**/src/cores/@*

View File

@ -0,0 +1,52 @@
[
"The code elves are on strike.",
"Our hamster-powered server needs a snack.",
"404: Excuse not found.",
"It worked on my machine!",
"The internet gnomes are at it again.",
"A wizard did it.",
"We ran out of coffee.",
"The app tripped over a semicolon.",
"Gremlins in the wires.",
"Someone forgot to feed the bugs.",
"The cloud is feeling moody.",
"Our AI is taking a nap.",
"The server went out for pizza.",
"Cosmic rays flipped a bit.",
"The code is allergic to Mondays.",
"The database is on vacation.",
"The error is a feature, not a bug.",
"The app is playing hide and seek.",
"Our code monkey is on a banana break.",
"The server is stuck in traffic.",
"The app is updating its status.",
"The error is shy, please try again.",
"The code is practicing social distancing.",
"The server is meditating.",
"The app is on a coffee break.",
"The error is lost in translation.",
"The code is feeling existential.",
"The server is chasing butterflies.",
"The app is stuck in a time loop.",
"The error is on vacation.",
"The code is having an identity crisis.",
"The server is out to lunch.",
"The app is waiting for a sign.",
"The error is hiding from QA.",
"The code is stuck in traffic.",
"The server is updating its horoscope.",
"The app is busy counting sheep.",
"The error is on a coffee run.",
"The code is taking a power nap.",
"The server is playing hide and seek.",
"The app is lost in thought.",
"The error is practicing yoga.",
"The code is on a coffee detox.",
"The server is binge-watching cat videos.",
"The app is updating its playlist.",
"The error is out of office.",
"The code is on a lunch break.",
"The server is dreaming in binary.",
"The app is waiting for inspiration.",
"The error is writing its memoirs."
]

View File

@ -1,611 +1,8 @@
{
"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"
"en": "English",
"es": "Español",
"fr": "Français",
"de": "Deutsch",
"it": "Italiano",
"pt": "Português"
}

View File

@ -0,0 +1,6 @@
[
"https://randomfox.ca/images/121.jpg",
"https://storage.ragestudio.net/comty-cdn/627d4b628cf4b82edd0864ff/b8147f7038c1dd698746f3d45d6812ffdd3d3836c5edb121518d301aa88f4cee",
"https://storage.ragestudio.net/comty-cdn/627d4b628cf4b82edd0864ff/cdcf52344f121f097ac3d363cd9ccc7169b2b7b419a914bd74786940f357bc3a",
"https://storage.ragestudio.net/comty-cdn/627d4b628cf4b82edd0864ff/e90ee040c9531a39397c7af7fb0f3422255beb3115a9c505649faa0edaeb455a"
]

View File

@ -14,6 +14,11 @@ export default [
useLayout: "default",
public: true,
},
{
path: "/tv/*",
useLayout: "default",
centeredContent: false,
},
{
path: "/featured-event/*",
useLayout: "default",
@ -31,7 +36,7 @@ export default [
{
path: "/music/*",
useLayout: "default",
centeredContent: true,
centeredContent: false,
},
{
path: "/nfc/*",

View File

@ -14,4 +14,10 @@ export default defineConfig([
languageOptions: { globals: globals.browser },
},
pluginReact.configs.flat.recommended,
{
rules: {
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
},
},
])

View File

@ -1,6 +1,6 @@
{
"name": "@comty/app",
"version": "1.43.0@alpha",
"version": "1.44.0@alpha",
"license": "ComtyLicense",
"main": "electron/main",
"type": "module",
@ -34,8 +34,9 @@
"axios": "^1.7.7",
"bear-react-carousel": "^4.0.10-alpha.0",
"classnames": "2.3.1",
"comty.js": "^0.67.0",
"comty.js": "^0.68.0",
"d3": "^7.9.0",
"dashjs": "^5.0.3",
"dompurify": "^3.0.0",
"fast-average-color": "^9.2.0",
"fuse.js": "6.5.3",
@ -66,12 +67,11 @@
"react-modal-image": "^2.6.0",
"react-player": "^2.16.0",
"react-rnd": "^10.4.14",
"react-router-dom": "^6.26.2",
"react-router": "^7.6.2",
"react-transition-group": "^4.4.5",
"react-useanimations": "^2.10.0",
"remark-gfm": "^3.0.1",
"rxjs": "^7.5.5",
"shaka-player": "^4.14.12",
"store": "^2.0.12",
"swapy": "^1.0.5",
"ua-parser-js": "^1.0.36",

View File

@ -1,5 +1,10 @@
import React from "react"
import { Runtime } from "@ragestudio/vessel"
import * as Router from "@ragestudio/vessel/router"
import routesDeclarations from "@config/routes"
import onPageMount from "@hooks/onPageMount"
import { Helmet } from "react-helmet"
import * as Sentry from "@sentry/browser"
import { invoke } from "@tauri-apps/api/tauri"
@ -14,7 +19,6 @@ import DesktopTopBar from "@components/DesktopTopBar"
import { ThemeProvider } from "@cores/style/style.core.jsx"
import Layout from "./layout"
import * as Router from "./router"
import StaticMethods from "./statics/methods"
import StaticEvents from "./statics/events"
@ -117,19 +121,19 @@ class ComtyApp extends React.Component {
/>
<meta property="og:title" content={config.app.siteName} />
</Helmet>
<Router.InternalRouter>
<ThemeProvider>
{window.__TAURI__ && <DesktopTopBar />}
<Layout
user={this.auth.user}
staticRenders={ComtyApp.staticRenders}
>
<Layout staticRenders={ComtyApp.staticRenders}>
{this.state.firstInitialized && (
<Router.PageRender />
<Router.Render
declarations={routesDeclarations}
staticRenders={ComtyApp.staticRenders}
onPageMount={onPageMount}
/>
)}
</Layout>
</ThemeProvider>
</Router.InternalRouter>
</React.Fragment>
)
}

View File

@ -70,6 +70,11 @@ export default class AuthManager {
},
}
handleUserDataUpdate = (data) => {
this.state.user = data
app.eventBus.emit("self:user:update", data)
}
initialize = async () => {
const token = await SessionModel.token
@ -87,6 +92,8 @@ export default class AuthManager {
app.userData = user
this.state.user = user
return user
}
flush = async () => {

View File

@ -0,0 +1,154 @@
import React from "react"
import { useRouteError } from "react-router"
import { Flex, Button } from "antd"
import Image from "@components/Image"
import excuses from "@config/excuses"
import randomErrorImages from "@config/randomErrorImages"
const detailsPreStyle = {
overflow: "hidden",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
userSelect: "text",
backgroundColor: "var(--background-color-accent)",
padding: "7px",
borderRadius: "5px",
}
const PageErrorBoundary = (props) => {
const error = useRouteError()
const errorId = React.useCallback(
() => `error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
[],
)
const errorInfo = error?.errorInfo
const handleRetry = () => {}
const handleReload = () => {
window.location.reload()
}
const handleGoHome = () => {
if (window.app?.location?.push) {
window.app.location.push("/")
} else {
window.location.href = "/"
}
}
const copyErrorDetails = () => {
const errorDetails = {
errorId: errorId,
message: error?.message,
stack: error?.stack,
componentStack: errorInfo?.componentStack,
path: props.path,
url: window.location.href,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
}
const errorText = JSON.stringify(errorDetails, null, 2)
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(errorText).then(() => {
app.message.success("Details copied to clipboard")
})
}
}
return (
<Flex
vertical
gap={20}
style={{
overflow: "hidden",
width: "100%",
}}
>
<Flex horizontal gap={20} align="center">
<Image
src={
randomErrorImages[
Math.floor(Math.random() * randomErrorImages.length)
]
}
style={{
width: 128,
height: 128,
objectFit: "cover",
overflowClipMargin: "unset",
}}
wrapperProps={{
style: {
borderRadius: "12px",
overflow: "hidden",
},
}}
/>
<Flex vertical gap={10}>
<h2 style={{ margin: 0, fontSize: "1.2rem" }}>
Something went wrong
</h2>
<span>
<strong>Path:</strong> {props.path || "Unknown"}
</span>
<span style={{ fontSize: "0.9rem", opacity: 0.8 }}>
ID: {errorId}
</span>
</Flex>
</Flex>
<Flex vertical>
<strong>Message:</strong>
<pre style={detailsPreStyle}>
{error?.message || "Unknown error"}
</pre>
</Flex>
<Flex vertical gap={10}>
<details>
<summary style={{ cursor: "pointer", fontWeight: "bold" }}>
Error Stack
</summary>
<pre style={detailsPreStyle}>
{error?.stack || "No stack trace available"}
</pre>
</details>
<details>
<summary style={{ cursor: "pointer", fontWeight: "bold" }}>
Component Stack
</summary>
<pre style={detailsPreStyle}>
{errorInfo?.componentStack ||
"No component stack available"}
</pre>
</details>
<details open>
<summary style={{ cursor: "pointer", fontWeight: "bold" }}>
Excuse
</summary>
<pre style={detailsPreStyle}>
{excuses[Math.floor(Math.random() * excuses.length)]}
</pre>
</details>
</Flex>
<Flex horizontal gap={10}>
<Button onClick={handleRetry}>🔄 Retry</Button>
<Button onClick={handleReload}>🔄 Reload Page</Button>
<Button onClick={copyErrorDetails}>
📋 Copy Error Details
</Button>
<Button onClick={handleGoHome}>🏠 Go Home</Button>
</Flex>
</Flex>
)
}
export default PageErrorBoundary

View File

@ -1,26 +1,28 @@
import React from "react"
import { Button } from "antd"
import classnames from "classnames"
import "./index.less"
export default (props) => {
return <div className="followButton">
const FollowButton = (props) => {
return (
<div className="followButton">
<div className="counter">
{props.count}
{props.self && " Followers"}
</div>
{
!props.self && <Button
{!props.self && (
<Button
type="ghost"
onClick={props.onClick}
className={classnames(
"btn",
{ ["followed"]: props.followed }
)}
className={classnames("btn", {
["followed"]: props.followed,
})}
>
<span>{props.followed ? "Following" : "Follow"}</span>
</Button>
}
)}
</div>
)
}
export default FollowButton

View File

@ -1,53 +1,39 @@
import React from "react"
import * as antd from "antd"
import { Icons } from "@components/Icons"
import LoadMore from "@components/LoadMore"
import UserPreview from "@components/UserPreview"
import FollowsModel from "@models/follows"
import "./index.less"
const FollowerItem = ({
follower,
onClick,
index
}) => {
return <div
className="follower"
onClick={onClick}
key={index}
>
<div className="avatar">
<antd.Avatar shape="square" src={follower.avatar} />
</div>
<div className="names">
<div>
<h2>
{follower.fullName ?? follower.username}
</h2>
</div>
<div>
<span>
@{follower.username}
</span>
</div>
</div>
</div>
}
const FollowerItem = React.memo(({ data }) => {
return <UserPreview user={data} />
})
export default (props) => {
FollowerItem.displayName = "FollowerItem"
const FollowersList = (props) => {
const [loading, setLoading] = React.useState(false)
const [followers, setFollowers] = React.useState(props.followers ?? [])
const [hasMore, setHasMore] = React.useState(true)
const goToProfile = (username) => {
app.navigation.goToAccount(username)
}
const page = React.useRef(0)
const userId = React.useRef(props.user_id)
const loadFollowers = async () => {
const loadFollowers = React.useCallback(async () => {
setLoading(true)
console.log(`Loading Followers for [${props.user_id}]...`)
console.log(
`Loading Followers for [${userId.current}] page [${page.current}]`,
)
const followers = await FollowsModel.getFollowers(props.user_id, true).catch((err) => {
const followers = await FollowsModel.getFollowers(userId.current, {
fetchData: true,
limit: 10,
page: page.current,
}).catch((err) => {
console.error(err)
app.message.error("Failed to fetch followers")
@ -57,45 +43,56 @@ export default (props) => {
setLoading(false)
if (followers) {
console.log(`Loaded Followers: [${followers.length}] >`, followers)
setFollowers(followers)
console.log(`Loaded Followers :`, followers)
setFollowers((prev) => {
return [...prev, ...followers.items]
})
if (followers.has_more) {
setHasMore(true)
} else {
setHasMore(false)
}
}
}, [userId.current])
const onLoadMore = React.useCallback(() => {
page.current += 1
loadFollowers()
}, [userId.current])
React.useEffect(() => {
if (!props.followers) {
if (props.user_id) {
userId.current = props.user_id
page.current = 0
setFollowers([])
setHasMore(true)
loadFollowers()
}
}
}, [])
}, [props.user_id])
if (loading) {
return <antd.Skeleton active />
}
if (followers.length === 0) {
return <antd.Result
icon={<Icons.FiUserX style={{ fontSize: "50px" }} />}
>
<h2>
It's seems this user has no followers, yet.
</h2>
<h3>
Maybe you can help them out?
</h3>
if (!loading && followers.length === 0) {
return (
<antd.Result icon={<Icons.FiUserX style={{ fontSize: "50px" }} />}>
<h2>It's seems this user has no followers, yet.</h2>
<h3>Maybe you can help them out?</h3>
</antd.Result>
)
}
return <div className="followersList">
{
followers.map((follower, index) => {
return <FollowerItem
index={index}
follower={follower}
onClick={() => goToProfile(follower.username)}
/>
})
}
</div>
return (
<LoadMore
className="followersList"
onBottom={onLoadMore}
hasMore={hasMore}
>
{followers.map((data) => {
return <FollowerItem key={data._id} data={data} />
})}
</LoadMore>
)
}
export default FollowersList

View File

@ -33,37 +33,18 @@
}
.followersList {
.follower {
display: inline-flex;
align-items: center;
width: 100%;
margin-bottom: 10px;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border-color);
cursor: pointer;
h2 {
margin: 0;
font-size: 22px;
line-height: 26px;
}
>div {
margin-right: 10px;
}
.names {
display: flex;
flex-direction: column;
}
}
.follower:last-child {
margin-bottom: 0;
gap: 10px;
.userPreview {
background-color: var(--background-color-primary);
border-radius: 8px;
padding: 5px 10px;
width: 100%;
}
}

View File

@ -102,6 +102,9 @@ export default class LiveChat extends React.Component {
}
leaveSocketRoom = () => {
if (!this.socket) {
return false
}
if (this.state.connectionEnd) {
return false
}

View File

@ -3,22 +3,27 @@ import classnames from "classnames"
import "./index.less"
export default React.forwardRef((props, ref) => {
const LoadMore = React.forwardRef((props, ref) => {
const {
className,
children,
hasMore,
hasMore = false,
loadingComponent,
noResultComponent,
contentProps = {},
} = props
const nodeRef = React.useRef(null)
let observer = null
const insideViewportCb = (entries) => {
const { fetching, onBottom } = props
const { fetching, onBottom, hasMore } = props
entries.forEach(element => {
if (!hasMore) {
return false
}
entries.forEach((element) => {
if (element.intersectionRatio > 0 && !fetching) {
onBottom()
}
@ -27,10 +32,8 @@ export default React.forwardRef((props, ref) => {
React.useEffect(() => {
try {
const node = document.getElementById("bottom")
observer = new IntersectionObserver(insideViewportCb)
observer.observe(node)
observer.observe(nodeRef.current)
} catch (err) {
console.log("err in finding node", err)
}
@ -41,16 +44,14 @@ export default React.forwardRef((props, ref) => {
}
}, [])
return <div
ref={ref}
className={classnames(className)}
{...contentProps}
>
return (
<div ref={ref} className={classnames(className)} {...contentProps}>
{children}
<div style={{ clear: "both" }} />
{/* <div style={{ clear: "both" }} /> */}
<div
ref={nodeRef}
id="bottom"
className="bottom"
style={{ display: hasMore ? "block" : "none" }}
@ -58,4 +59,9 @@ export default React.forwardRef((props, ref) => {
{loadingComponent && React.createElement(loadingComponent)}
</div>
</div>
)
})
LoadMore.displayName = "LoadMore"
export default LoadMore

View File

@ -1,9 +1,10 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { WithPlayerContext } from "@contexts/WithPlayerContext"
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
import {
WithPlayerContext,
usePlayerStateContext,
} from "@contexts/WithPlayerContext"
import LoadMore from "@components/LoadMore"
import { Icons } from "@components/Icons"
@ -24,6 +25,8 @@ const TrackList = ({
hasMore,
noHeader = false,
}) => {
const [{ track_manifest, playback_status }] = usePlayerStateContext()
const showListHeader = !noHeader && (tracks.length > 0 || searchResults)
if (!searchResults && tracks.length === 0) {
@ -62,7 +65,7 @@ const TrackList = ({
key={item._id}
order={item._id} // Consider using index if order matters
track={item}
onPlay={() => onTrackClick(item)}
onPlay={onTrackClick}
changeState={(update) =>
onTrackStateChange(item._id, update)
}
@ -76,19 +79,19 @@ const TrackList = ({
onBottom={onLoadMore}
hasMore={hasMore}
>
<WithPlayerContext>
{tracks.map((item, index) => (
<MusicTrack
key={item._id} // Use unique ID for key
key={item._id}
order={index + 1}
track={item}
onPlay={() => onTrackClick(item)}
changeState={(update) =>
onTrackStateChange(item._id, update)
onPlay={onTrackClick}
isCurrent={item._id === track_manifest?._id}
isPlaying={
item._id === track_manifest?._id &&
playback_status === "playing"
}
/>
))}
</WithPlayerContext>
</LoadMore>
)}
</div>

View File

@ -2,140 +2,37 @@ import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import RGBStringToValues from "@utils/rgbToValues"
import ImageViewer from "@components/ImageViewer"
import { Icons } from "@components/Icons"
import MusicModel from "@models/music"
import MenuItemsBase from "./menuItems"
import MenuHandlers from "./menuHandlers"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import RGBStringToValues from "@utils/rgbToValues"
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
import "./index.less"
const handlers = {
like: async (ctx, track) => {
await MusicModel.toggleItemFavourite("track", track._id, true)
function secondsToIsoTime(seconds) {
const minutes = Math.floor(seconds / 60)
ctx.changeState({
liked: true,
})
ctx.closeMenu()
},
unlike: async (ctx, track) => {
await MusicModel.toggleItemFavourite("track", track._id, false)
ctx.changeState({
liked: false,
})
ctx.closeMenu()
},
add_to_playlist: async (ctx, track) => {},
add_to_queue: async (ctx, track) => {
await app.cores.player.queue.add(track)
},
play_next: async (ctx, track) => {
await app.cores.player.queue.add(track, { next: true })
},
return `${minutes}:${Math.floor(seconds % 60)
.toString()
.padStart(2, "0")}`
}
const Track = (props) => {
const [{ loading, track_manifest, playback_status }] =
usePlayerStateContext()
const Track = React.memo((props) => {
const playlist_ctx = React.useContext(PlaylistContext)
const [moreMenuOpened, setMoreMenuOpened] = React.useState(false)
const [liked, setLiked] = React.useState(props.track.liked)
const isCurrent = track_manifest?._id === props.track._id
const isPlaying = isCurrent && playback_status === "playing"
const trackDuration = React.useMemo(() => {
return props.track?.metadata?.duration ?? props.track?.duration
}, [props.track])
const handleClickPlayBtn = React.useCallback(() => {
if (typeof props.onPlay === "function") {
return props.onPlay(props.track)
}
if (typeof props.onClickPlayBtn === "function") {
props.onClickPlayBtn(props.track)
}
if (!isCurrent) {
app.cores.player.start(props.track)
} else {
app.cores.player.playback.toggle()
}
})
const handleOnClickItem = React.useCallback(() => {
if (props.onClick) {
props.onClick(props.track)
}
if (app.isMobile) {
handleClickPlayBtn()
}
})
const handleMoreMenuOpen = () => {
if (app.isMobile) {
return
}
return setMoreMenuOpened((prev) => {
return !prev
})
}
const handleMoreMenuItemClick = (e) => {
const { key } = e
if (typeof handlers[key] === "function") {
return handlers[key](
{
closeMenu: () => {
setMoreMenuOpened(false)
},
changeState: props.changeState,
},
props.track,
)
}
}
const moreMenuItems = React.useMemo(() => {
const items = [
{
key: "like",
icon: <Icons.MdFavorite />,
label: "Like",
},
{
key: "share",
icon: <Icons.MdShare />,
label: "Share",
disabled: true,
},
{
key: "add_to_playlist",
icon: <Icons.MdPlaylistAdd />,
label: "Add to playlist",
disabled: true,
},
{
type: "divider",
},
{
key: "add_to_queue",
icon: <Icons.MdQueueMusic />,
label: "Add to queue",
},
{
key: "play_next",
icon: <Icons.MdSkipNext />,
label: "Play next",
},
]
const menuItems = React.useMemo(() => {
const items = [...MenuItemsBase]
if (props.track.liked) {
items[0] = {
@ -162,19 +59,68 @@ const Track = (props) => {
return items
}, [props.track])
const handleClickPlayBtn = React.useCallback(() => {
if (typeof props.onPlay === "function") {
return props.onPlay(props.track)
}
if (typeof props.onClickPlayBtn === "function") {
props.onClickPlayBtn(props.track)
}
if (!props.isCurrent) {
app.cores.player.start(props.track)
} else {
app.cores.player.playback.toggle()
}
}, [props.isCurrent])
const handleOnClickItem = React.useCallback(() => {
if (props.onClick) {
props.onClick(props.track)
}
if (app.isMobile) {
handleClickPlayBtn()
}
}, [props.track])
const handleMoreMenuOpen = React.useCallback(() => {
if (app.isMobile) {
return
}
return setMoreMenuOpened((prev) => {
return !prev
})
}, [])
const handleMoreMenuItemClick = React.useCallback(
(e) => {
const { key } = e
if (typeof MenuHandlers[key] === "function") {
return MenuHandlers[key](
{
close: () => {
setMoreMenuOpened(false)
},
setLiked: setLiked,
},
props.track,
)
}
},
[props.track],
)
return (
<div
id={props.track._id}
className={classnames("music-track", {
["current"]: isCurrent,
["playing"]: isPlaying,
["loading"]: isCurrent && loading,
["current"]: props.isCurrent,
["playing"]: props.isPlaying,
})}
style={{
"--cover_average-color": RGBStringToValues(
track_manifest?.cover_analysis?.rgb,
),
}}
>
<div className="music-track_background" />
@ -193,7 +139,7 @@ const Track = (props) => {
type="primary"
shape="circle"
icon={
isPlaying ? (
props.isPlaying ? (
<Icons.MdPause />
) : (
<Icons.MdPlayArrow />
@ -214,13 +160,19 @@ const Track = (props) => {
className="music-track_details"
onClick={handleOnClickItem}
>
<div className="music-track_title">
<span>
<div className="music-track_titles">
<span className="music-track_title">
{props.track.service === "tidal" && (
<Icons.SiTidal />
)}
{props.track.title}
</span>
{props.track.version && (
<span className="music-track_version">
({props.track.version})
</span>
)}
</div>
<div className="music-track_artist">
<span>
@ -233,24 +185,31 @@ const Track = (props) => {
</div>
<div className="music-track_actions">
{trackDuration && (
<div className="music-track_play_duration">
<Icons.FiClock />
{secondsToIsoTime(trackDuration)}
</div>
)}
<antd.Dropdown
menu={{
items: moreMenuItems,
items: menuItems,
onClick: handleMoreMenuItemClick,
}}
onOpenChange={handleMoreMenuOpen}
open={moreMenuOpened}
trigger={["click"]}
>
<antd.Button
type="ghost"
size="large"
icon={<Icons.IoMdMore />}
/>
<div className="music-track_more-menu">
<Icons.IoMdMore />
</div>
</antd.Dropdown>
</div>
</div>
)
}
})
Track.displayName = "Track"
export default Track

View File

@ -49,6 +49,11 @@ html {
.music-track_orderIndex {
opacity: 0;
}
.music-track_play_duration {
opacity: 1;
width: fit-content;
}
}
}
}
@ -113,6 +118,13 @@ html {
.music-track_play {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
transition: all 150ms ease-in-out;
cursor: pointer;
@ -200,13 +212,31 @@ html {
display: flex;
flex-direction: column;
width: 100%;
width: 75%;
color: var(--text-color);
.music-track_titles {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
.music-track_title {
display: flex;
flex-direction: row;
align-items: center;
font-size: 1rem;
//font-family: "Space Grotesk", sans-serif;
gap: 5px;
}
.music-track_version {
font-size: 0.8rem;
opacity: 0.8;
}
}
.music-track_artist {
@ -225,10 +255,27 @@ html {
align-items: center;
justify-content: center;
gap: 10px;
gap: 5px;
margin-left: auto;
color: var(--text-color);
.music-track_play_duration {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 0.8rem;
}
.music-track_more-menu {
padding: 10px;
cursor: pointer;
}
}
}

View File

@ -0,0 +1,31 @@
import MusicModel from "@models/music"
export default {
like: async (ctx, track) => {
await MusicModel.toggleItemFavourite("track", track._id, true)
ctx.changeState({
liked: true,
})
ctx.close()
},
unlike: async (ctx, track) => {
await MusicModel.toggleItemFavourite("track", track._id, false)
ctx.changeState({
liked: false,
})
ctx.close()
},
add_to_playlist: async (ctx, track) => {},
add_to_queue: async (ctx, track) => {
await app.cores.player.queue.add(track)
},
play_next: async (ctx, track) => {
await app.cores.player.queue.add(track, { next: true })
},
copy_id: (ctx, track) => {
console.log("copy_id", track)
navigator.clipboard.writeText(track._id)
},
}

View File

@ -0,0 +1,42 @@
import { Icons } from "@components/Icons"
export default [
{
key: "like",
icon: <Icons.MdFavorite />,
label: "Like",
},
{
key: "share",
icon: <Icons.MdShare />,
label: "Share",
disabled: true,
},
{
key: "add_to_playlist",
icon: <Icons.MdPlaylistAdd />,
label: "Add to playlist",
disabled: true,
},
{
type: "divider",
},
{
key: "add_to_queue",
icon: <Icons.MdQueueMusic />,
label: "Add to queue",
},
{
key: "play_next",
icon: <Icons.MdSkipNext />,
label: "Play next",
},
{
type: "divider",
},
{
key: "copy_id",
icon: <Icons.MdLink />,
label: "Copy ID",
},
]

View File

@ -1,101 +0,0 @@
import React from "react"
import * as antd from "antd"
import LyricsTextView from "@components/MusicStudio/LyricsTextView"
import UploadButton from "@components/UploadButton"
import { Icons } from "@components/Icons"
import Languages from "@config/languages"
import "./index.less"
const LanguagesMap = Object.entries(Languages).map(([key, value]) => {
return {
label: value,
value: key,
}
})
const LyricsEditor = (props) => {
const { langs = {} } = props
const [selectedLang, setSelectedLang] = React.useState("original")
function handleChange(key, value) {
if (typeof props.onChange !== "function") {
return false
}
props.onChange(key, value)
}
function updateCurrentLang(url) {
handleChange("langs", {
...langs,
[selectedLang]: url,
})
}
return (
<div className="lyrics-editor">
<div className="flex-row align-center justify-space-between gap-10">
<h1>
<Icons.MdOutlineMusicNote />
Lyrics
</h1>
<div className="flex-row aling-center gap-5">
<span>Language:</span>
<antd.Select
showSearch
style={{ width: "220px" }}
placeholder="Select a language"
value={selectedLang}
options={[
...LanguagesMap,
{
label: "Original",
value: "original",
},
]}
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label.toLowerCase() ?? "").includes(
input.toLowerCase(),
)
}
filterSort={(optionA, optionB) =>
(optionA?.label.toLowerCase() ?? "")
.toLowerCase()
.localeCompare(
(
optionB?.label.toLowerCase() ?? ""
).toLowerCase(),
)
}
onChange={setSelectedLang}
/>
{selectedLang && (
<UploadButton
onSuccess={(file_uid, data) => {
updateCurrentLang(data.url)
}}
accept={["text/*"]}
/>
)}
</div>
</div>
{!langs[selectedLang] && (
<span>No lyrics uploaded for this language</span>
)}
{langs[selectedLang] && (
<LyricsTextView lrcURL={langs[selectedLang]} />
)}
</div>
)
}
export default LyricsEditor

View File

@ -1,11 +0,0 @@
.lyrics-editor {
display: flex;
flex-direction: column;
gap: 20px;
padding: 15px;
border-radius: 12px;
background-color: var(--background-color-accent);
}

View File

@ -1,107 +0,0 @@
import React from "react"
import * as antd from "antd"
import dayjs from "dayjs"
import customParseFormat from "dayjs/plugin/customParseFormat"
import UploadButton from "@components/UploadButton"
import { Icons } from "@components/Icons"
import VideoPlayer from "@components/VideoPlayer"
import "./index.less"
dayjs.extend(customParseFormat)
const VideoEditor = (props) => {
function handleChange(key, value) {
if (typeof props.onChange !== "function") {
return false
}
props.onChange(key, value)
}
return (
<div className="video-editor">
<h1>
<Icons.MdVideocam />
Video
</h1>
{!props.videoSourceURL && (
<antd.Empty
image={<Icons.MdVideocam />}
description="No video"
/>
)}
{props.videoSourceURL && (
<div className="video-editor-preview">
<VideoPlayer
controls={[
"play",
"current-time",
"seek-time",
"duration",
"progress",
"settings",
]}
src={props.videoSourceURL}
/>
</div>
)}
<div className="flex-column align-start gap10">
<div className="flex-row align-center gap10">
<span>
<Icons.MdAccessTime />
Start video sync at
</span>
<code>{props.startSyncAt ?? "not set"}</code>
</div>
<div className="flex-row align-center gap10">
<span>Set to:</span>
<antd.TimePicker
showNow={false}
defaultValue={
props.startSyncAt &&
dayjs(props.startSyncAt, "mm:ss:SSS")
}
format={"mm:ss:SSS"}
onChange={(time, str) => {
handleChange("startSyncAt", str)
}}
/>
</div>
</div>
<div className="video-editor-actions">
<UploadButton
onSuccess={(id, response) => {
handleChange("videoSourceURL", response.url)
}}
accept={["video/*"]}
headers={{
transformations: "mq-hls",
}}
disabled={props.loading}
>
Upload video
</UploadButton>
or
<antd.Input
placeholder="Set a video HLS URL"
onChange={(e) => {
handleChange("videoSourceURL", e.target.value)
}}
value={props.videoSourceURL}
disabled={props.loading}
/>
</div>
</div>
)
}
export default VideoEditor

View File

@ -1,25 +0,0 @@
.video-editor {
display: flex;
flex-direction: column;
gap: 20px;
padding: 15px;
border-radius: 12px;
background-color: var(--background-color-accent);
.video-editor-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.video-editor-preview {
width: 100%;
height: 350px;
}
}

View File

@ -1,122 +0,0 @@
import React from "react"
import { Skeleton } from "antd"
import VideoEditor from "./components/VideoEditor"
import LyricsEditor from "./components/LyricsEditor"
import MusicModel from "@models/music"
import ReleaseEditorStateContext from "@contexts/MusicReleaseEditor"
import "./index.less"
class EnhancedLyricsEditor extends React.Component {
static contextType = ReleaseEditorStateContext
state = {
data: {},
loading: true,
submitting: false,
videoOptions: {},
lyricsOptions: {}
}
componentDidMount = async () => {
this.setState({
loading: true
})
this.context.setCustomPageActions([
{
label: "Save",
icon: "FiSave",
onClick: this.submitChanges,
}
])
const data = await MusicModel.getTrackLyrics(this.props.track._id).catch((err) => {
return null
})
if (data) {
this.setState({
videoOptions: {
videoSourceURL: data.video_source,
startSyncAt: data.sync_audio_at
},
lyricsOptions: {
langs: data.lrc
}
})
}
this.setState({
loading: false
})
}
submitChanges = async () => {
this.setState({
submitting: true
})
console.log(`Submitting changes with values >`, {
...this.state.videoOptions,
...this.state.lyricsOptions
})
await MusicModel.putTrackLyrics(this.props.track._id, {
video_source: this.state.videoOptions.videoSourceURL,
sync_audio_at: this.state.videoOptions.startSyncAt,
lrc: this.state.lyricsOptions.langs
}).catch((err) => {
console.error(err)
app.message.error("Failed to update enhanced lyrics")
})
app.message.success("Lyrics updated")
this.setState({
submitting: false
})
}
render() {
if (this.state.loading) {
return <Skeleton active />
}
return <div className="enhanced_lyrics_editor-wrapper">
<h1>{this.props.track.title}</h1>
<VideoEditor
loading={this.state.submitting}
videoSourceURL={this.state.videoOptions.videoSourceURL}
startSyncAt={this.state.videoOptions.startSyncAt}
onChange={(key, value) => {
this.setState({
videoOptions: {
...this.state.videoOptions,
[key]: value
}
})
}}
/>
<LyricsEditor
loading={this.state.submitting}
langs={this.state.lyricsOptions.langs}
onChange={(key, value) => {
this.setState({
lyricsOptions: {
...this.state.lyricsOptions,
[key]: value
}
})
}}
/>
</div>
}
}
export default EnhancedLyricsEditor

View File

@ -1,6 +0,0 @@
.enhanced_lyrics_editor-wrapper {
display: flex;
flex-direction: column;
gap: 20px;
}

View File

@ -1,75 +0,0 @@
import React from "react"
import * as antd from "antd"
import axios from "axios"
import "./index.less"
const LyricsTextView = (props) => {
const { lrcURL } = 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.split("\n"))
}
setLoading(false)
}
React.useEffect(() => {
getLyrics(lrcURL)
}, [lrcURL])
if (!lrcURL) {
return null
}
if (error) {
return <antd.Result
status="warning"
title="Failed"
subTitle={error.message}
/>
}
if (loading) {
return <antd.Skeleton active />
}
if (!lyrics) {
return <p>No lyrics provided</p>
}
return <div className="lyrics-text-view">
{
lyrics?.map((line, index) => {
return <div
key={index}
className="lyrics-text-view-line"
>
{line}
</div>
})
}
</div>
}
export default LyricsTextView

View File

@ -1,15 +0,0 @@
.lyrics-text-view {
display: flex;
flex-direction: column;
gap: 10px;
.lyrics-text-view-line {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
}

View File

@ -1,55 +0,0 @@
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,332 +0,0 @@
import React from "react"
import * as antd from "antd"
import { Icons, createIconRender } from "@components/Icons"
import MusicModel from "@models/music"
import compareObjectsByProperties from "@utils/compareObjectsByProperties"
import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey"
import TrackManifest from "@cores/player/classes/TrackManifest"
import {
DefaultReleaseEditorState,
ReleaseEditorStateContext,
} from "@contexts/MusicReleaseEditor"
import Tabs from "./tabs"
import "./index.less"
const ReleaseEditor = (props) => {
const { release_id } = props
const basicInfoRef = React.useRef()
const [submitting, setSubmitting] = React.useState(false)
const [loading, setLoading] = React.useState(true)
const [submitError, setSubmitError] = React.useState(null)
const [loadError, setLoadError] = React.useState(null)
const [globalState, setGlobalState] = React.useState(
DefaultReleaseEditorState,
)
const [initialValues, setInitialValues] = React.useState({})
const [customPage, setCustomPage] = React.useState(null)
const [customPageActions, setCustomPageActions] = React.useState([])
const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
defaultKey: "info",
queryKey: "tab",
})
async function initialize() {
setLoading(true)
setLoadError(null)
if (release_id !== "new") {
try {
let releaseData = await MusicModel.getReleaseData(release_id)
if (Array.isArray(releaseData.items)) {
releaseData.items = releaseData.items.map((item) => {
return new TrackManifest(item)
})
}
setGlobalState({
...globalState,
...releaseData,
})
setInitialValues(releaseData)
} catch (error) {
setLoadError(error)
}
}
setLoading(false)
}
function hasChanges() {
const stagedChanges = {
title: globalState.title,
type: globalState.type,
public: globalState.public,
cover: globalState.cover,
items: globalState.items,
}
return !compareObjectsByProperties(
stagedChanges,
initialValues,
Object.keys(stagedChanges),
)
}
async function renderCustomPage(page, actions) {
setCustomPage(page ?? null)
setCustomPageActions(actions ?? [])
}
async function handleSubmit() {
setSubmitting(true)
setSubmitError(null)
try {
console.log("Submitting Tracks")
// first sumbit tracks
const tracks = await MusicModel.putTrack({
items: globalState.items,
})
console.log("Submitting release")
// then submit release
const result = await MusicModel.putRelease({
_id: globalState._id,
title: globalState.title,
description: globalState.description,
public: globalState.public,
cover: globalState.cover,
explicit: globalState.explicit,
type: globalState.type,
items: tracks.items.map((item) => item._id),
})
app.location.push(`/studio/music/${result._id}`)
} catch (error) {
console.error(error)
app.message.error(error.message)
setSubmitError(error)
setSubmitting(false)
return false
}
setSubmitting(false)
app.message.success("Release saved")
}
async function handleDelete() {
app.layout.modal.confirm({
headerText: "Are you sure you want to delete this release?",
descriptionText: "This action cannot be undone.",
onConfirm: async () => {
await MusicModel.deleteRelease(globalState._id)
app.location.push(
window.location.pathname.split("/").slice(0, -1).join("/"),
)
},
})
}
function canFinish() {
return hasChanges()
}
React.useEffect(() => {
initialize()
}, [])
if (loadError) {
return (
<antd.Result
status="warning"
title="Error"
subTitle={loadError.message}
/>
)
}
if (loading) {
return <antd.Skeleton active />
}
const Tab = Tabs.find(({ key }) => key === selectedTab)
const CustomPageProps = {
close: () => {
renderCustomPage(null, null)
},
}
return (
<ReleaseEditorStateContext.Provider
value={{
...globalState,
setGlobalState,
renderCustomPage,
setCustomPageActions,
}}
>
<div className="music-studio-release-editor">
{customPage && (
<div className="music-studio-release-editor-custom-page">
{customPage.header && (
<div className="music-studio-release-editor-custom-page-header">
<div className="music-studio-release-editor-custom-page-header-title">
<antd.Button
icon={<Icons.IoIosArrowBack />}
onClick={() =>
renderCustomPage(null, null)
}
/>
<h2>{customPage.header}</h2>
</div>
{Array.isArray(customPageActions) &&
customPageActions.map((action, index) => {
return (
<antd.Button
key={index}
type={action.type}
icon={createIconRender(
action.icon,
)}
onClick={async () => {
if (
typeof action.onClick ===
"function"
) {
await action.onClick()
}
if (action.fireEvent) {
app.eventBus.emit(
action.fireEvent,
)
}
}}
disabled={action.disabled}
>
{action.label}
</antd.Button>
)
})}
</div>
)}
{customPage.content &&
(React.isValidElement(customPage.content)
? React.cloneElement(customPage.content, {
...CustomPageProps,
...customPage.props,
})
: React.createElement(customPage.content, {
...CustomPageProps,
...customPage.props,
}))}
</div>
)}
{!customPage && (
<>
<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={
release_id !== "new" ? (
<Icons.FiSave />
) : (
<Icons.MdSend />
)
}
disabled={
submitting || loading || !canFinish()
}
loading={submitting}
>
{release_id !== "new" ? "Save" : "Release"}
</antd.Button>
{release_id !== "new" ? (
<antd.Button
icon={<Icons.IoMdTrash />}
disabled={loading}
onClick={handleDelete}
>
Delete
</antd.Button>
) : null}
{release_id !== "new" ? (
<antd.Button
icon={<Icons.MdLink />}
onClick={() =>
app.location.push(
`/music/list/${globalState._id}`,
)
}
>
Go to release
</antd.Button>
) : null}
</div>
</div>
<div className="music-studio-release-editor-content">
{submitError && (
<antd.Alert
message={submitError.message}
type="error"
/>
)}
{!Tab && (
<antd.Result
status="error"
title="Error"
subTitle="Tab not found"
/>
)}
{Tab &&
React.createElement(Tab.render, {
release: globalState,
state: globalState,
setState: setGlobalState,
references: {
basic: basicInfoRef,
},
})}
</div>
</>
)}
</div>
</ReleaseEditorStateContext.Provider>
)
}
export default ReleaseEditor

View File

@ -1,136 +0,0 @@
.music-studio-release-editor {
display: flex;
flex-direction: row;
width: 100%;
//padding: 20px;
gap: 20px;
.music-studio-release-editor-custom-page {
display: flex;
flex-direction: column;
width: 100%;
gap: 20px;
.music-studio-release-editor-custom-page-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
background-color: var(--background-color-accent);
padding: 10px;
border-radius: 12px;
h1,
h2,
h3,
h4,
h5,
h6,
p,
span {
margin: 0;
}
.music-studio-release-editor-custom-page-header-title {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
}
}
.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

@ -1,115 +0,0 @@
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, setState, state } = props
async function onFormChange(change) {
setState((globalState) => {
return {
...globalState,
...change
}
})
}
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}
onValuesChange={onFormChange}
>
<antd.Form.Item
label=""
name="cover"
rules={[{ required: true, message: "Input a cover for the release" }]}
initialValue={state?.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={state?.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={state?.type}
>
<antd.Select
placeholder="Release type"
options={ReleasesTypes}
/>
</antd.Form.Item>
<antd.Form.Item
label={<><Icons.MdPublic /> <span>Public</span></>}
name="public"
initialValue={state?.public}
>
<antd.Switch />
</antd.Form.Item>
</antd.Form>
</div>
}
export default BasicInformation

View File

@ -1,117 +0,0 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { Draggable } from "react-beautiful-dnd"
import Image from "@components/Image"
import { Icons } from "@components/Icons"
import TrackEditor from "@components/MusicStudio/TrackEditor"
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
import "./index.less"
const stateToString = {
uploading: "Uploading",
transmuxing: "Processing...",
uploading_s3: "Archiving...",
}
const getTitleString = ({ track, progress }) => {
if (progress) {
return stateToString[progress.state] || progress.state
}
return track.title
}
const TrackListItem = (props) => {
const context = React.useContext(ReleaseEditorStateContext)
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState(null)
const { track, progress } = props
async function onClickEditTrack() {
context.renderCustomPage({
header: "Track Editor",
content: <TrackEditor />,
props: {
track: track,
},
})
}
async function onClickRemoveTrack() {
props.onDelete(track.uid)
}
return (
<div
className={classnames(
"music-studio-release-editor-tracks-list-item",
{
["loading"]: loading,
["failed"]: !!error,
["disabled"]: props.disabled,
},
)}
data-swapy-item={track.id ?? track._id}
>
<div
className="music-studio-release-editor-tracks-list-item-progress"
style={{
"--upload-progress": `${props.progress?.percent ?? 0}%`,
}}
/>
<div className="music-studio-release-editor-tracks-list-item-index">
<span>{props.index + 1}</span>
</div>
{progress !== null && <Icons.LoadingOutlined />}
<Image
src={track.cover}
height={25}
width={25}
style={{
borderRadius: 8,
}}
/>
<span>{getTitleString({ track, progress })}</span>
<div className="music-studio-release-editor-tracks-list-item-actions">
<antd.Popconfirm
title="Are you sure you want to delete this track?"
onConfirm={onClickRemoveTrack}
okText="Yes"
disabled={props.disabled}
>
<antd.Button
type="ghost"
icon={<Icons.FiTrash2 />}
disabled={props.disabled}
/>
</antd.Popconfirm>
<antd.Button
type="ghost"
icon={<Icons.FiEdit2 />}
onClick={onClickEditTrack}
disabled={props.disabled}
/>
<div
data-swapy-handle
className="music-studio-release-editor-tracks-list-item-dragger"
>
<Icons.MdDragIndicator />
</div>
</div>
</div>
)
}
export default TrackListItem

View File

@ -1,62 +0,0 @@
.music-studio-release-editor-tracks-list-item {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
padding: 10px;
gap: 10px;
border-radius: 12px;
background-color: var(--background-color-accent);
overflow: hidden;
.music-studio-release-editor-tracks-list-item-progress {
position: absolute;
bottom: 0;
left: 0;
width: var(--upload-progress);
height: 2px;
background-color: var(--colorPrimary);
transition: all 150ms ease-in-out;
}
.music-studio-release-editor-tracks-list-item-actions {
position: absolute;
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

@ -1,352 +0,0 @@
import React from "react"
import * as antd from "antd"
import { createSwapy } from "swapy"
import queuedUploadFile from "@utils/queuedUploadFile"
import FilesModel from "@models/files"
import TrackManifest from "@cores/player/classes/TrackManifest"
import { Icons } from "@components/Icons"
import TrackListItem from "./components/TrackListItem"
import UploadHint from "./components/UploadHint"
import "./index.less"
class TracksManager extends React.Component {
swapyRef = React.createRef()
state = {
items: Array.isArray(this.props.items) ? this.props.items : [],
pendingUploads: [],
}
componentDidUpdate = (prevProps, prevState) => {
if (prevState.items !== this.state.items) {
if (typeof this.props.onChangeState === "function") {
this.props.onChangeState(this.state)
}
}
}
componentDidMount() {
this.swapyRef.current = createSwapy(
document.getElementById("editor-tracks-list"),
{
animation: "dynamic",
dragAxis: "y",
},
)
this.swapyRef.current.onSwapEnd((event) => {
console.log("end", event)
this.orderTrackList(
event.slotItemMap.asArray.map((item) => item.item),
)
})
}
componentWillUnmount() {
this.swapyRef.current.destroy()
}
findTrackByUid = (uid) => {
if (!uid) {
return false
}
return this.state.items.find((item) => item.uid === uid)
}
addTrackToList = (track) => {
if (!track) {
return false
}
this.setState({
items: [...this.state.items, track],
})
}
removeTrackByUid = (uid) => {
if (!uid) {
return false
}
this.removeTrackUIDFromPendingUploads(uid)
this.setState({
items: this.state.items.filter((item) => item.uid !== uid),
})
}
modifyTrackByUid = (uid, track) => {
if (!uid || !track) {
return false
}
this.setState({
items: this.state.items.map((item) => {
if (item.uid === uid) {
return {
...item,
...track,
}
}
return item
}),
})
}
addTrackUIDToPendingUploads = (uid) => {
if (!uid) {
return false
}
const pendingUpload = this.state.pendingUploads.find(
(item) => item.uid === uid,
)
if (!pendingUpload) {
this.setState({
pendingUploads: [
...this.state.pendingUploads,
{
uid: uid,
progress: 0,
},
],
})
}
}
removeTrackUIDFromPendingUploads = (uid) => {
if (!uid) {
return false
}
this.setState({
pendingUploads: this.state.pendingUploads.filter(
(item) => item.uid !== uid,
),
})
}
getUploadProgress = (uid) => {
const uploadProgressIndex = this.state.pendingUploads.findIndex(
(item) => item.uid === uid,
)
if (uploadProgressIndex === -1) {
return null
}
return this.state.pendingUploads[uploadProgressIndex].progress
}
updateUploadProgress = (uid, progress) => {
const uploadProgressIndex = this.state.pendingUploads.findIndex(
(item) => item.uid === uid,
)
if (uploadProgressIndex === -1) {
return false
}
const newData = [...this.state.pendingUploads]
newData[uploadProgressIndex].progress = progress
console.log(`Updating progress for [${uid}] to >`, progress)
this.setState({
pendingUploads: newData,
})
}
handleUploaderStateChange = async (change) => {
const uid = change.file.uid
console.log("handleUploaderStateChange", change)
switch (change.file.status) {
case "uploading": {
this.addTrackUIDToPendingUploads(uid)
const trackManifest = new TrackManifest({
uid: uid,
file: change.file.originFileObj,
})
this.addTrackToList(trackManifest)
break
}
case "done": {
// remove pending file
this.removeTrackUIDFromPendingUploads(uid)
let trackManifest = this.state.items.find(
(item) => item.uid === uid,
)
if (!trackManifest) {
console.error(`Track with uid [${uid}] not found!`)
break
}
// // update track list
// await this.modifyTrackByUid(uid, {
// source: change.file.response.url
// })
trackManifest.source = change.file.response.url
trackManifest = await trackManifest.initialize()
// if has a cover, Upload
if (trackManifest._coverBlob) {
console.log(
`[${trackManifest.uid}] Founded cover, uploading...`,
)
const coverFile = new File(
[trackManifest._coverBlob],
"cover.jpg",
{ type: trackManifest._coverBlob.type },
)
const coverUpload = await FilesModel.upload(coverFile)
trackManifest.cover = coverUpload.url
}
await this.modifyTrackByUid(uid, trackManifest)
break
}
case "error": {
// remove pending file
this.removeTrackUIDFromPendingUploads(uid)
// remove from tracklist
await this.removeTrackByUid(uid)
}
case "removed": {
// stop upload & delete from pending list and tracklist
await this.removeTrackByUid(uid)
}
default: {
break
}
}
}
uploadToStorage = async (req) => {
await queuedUploadFile(req.file, {
onFinish: (file, response) => {
req.onSuccess(response)
},
onError: req.onError,
onProgress: this.handleTrackFileUploadProgress,
headers: {
transformations: "a-dash",
},
})
}
handleTrackFileUploadProgress = async (file, progress) => {
this.updateUploadProgress(file.uid, progress)
}
orderTrackList = (orderedIdsArray) => {
this.setState((prev) => {
// move all list items by id
const orderedIds = orderedIdsArray.map((id) =>
this.state.items.find((item) => item._id === id),
)
console.log("orderedIds", orderedIds)
return {
items: orderedIds,
}
})
}
render() {
console.log(`Tracks List >`, this.state.items)
return (
<div className="music-studio-release-editor-tracks">
<antd.Upload
className="music-studio-tracks-uploader"
onChange={this.handleUploaderStateChange}
customRequest={this.uploadToStorage}
showUploadList={false}
accept="audio/*"
multiple
>
{this.state.items.length === 0 ? (
<UploadHint />
) : (
<antd.Button
className="uploadMoreButton"
icon={<Icons.FiPlus />}
>
Add another
</antd.Button>
)}
</antd.Upload>
<div
id="editor-tracks-list"
className="music-studio-release-editor-tracks-list"
>
{this.state.items.length === 0 && (
<antd.Result status="info" title="No tracks" />
)}
{this.state.items.map((track, index) => {
const progress = this.getUploadProgress(track.uid)
return (
<div data-swapy-slot={track._id ?? track.uid}>
<TrackListItem
index={index}
track={track}
onEdit={this.modifyTrackByUid}
onDelete={this.removeTrackByUid}
progress={progress}
disabled={progress > 0}
/>
</div>
)
})}
</div>
</div>
)
}
}
const ReleaseTracks = (props) => {
const { state, setState } = props
return (
<div className="music-studio-release-editor-tab">
<h1>Tracks</h1>
<TracksManager
_id={state._id}
items={state.items}
onChangeState={(managerState) => {
setState({
...state,
...managerState,
})
}}
/>
</div>
)
}
export default ReleaseTracks

View File

@ -1,33 +0,0 @@
.music-studio-release-editor-tracks {
display: flex;
flex-direction: column;
gap: 10px;
.music-studio-tracks-uploader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
.ant-upload {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
}
}
.music-studio-release-editor-tracks-list {
display: flex;
flex-direction: column;
gap: 10px;
}

View File

@ -1,93 +0,0 @@
import React from "react"
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
export default function SortableItem({ id, children }) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: "grab",
}
return (
<div ref={setNodeRef} style={style}>
{children({
...attributes,
...listeners,
ref: setActivatorNodeRef,
style: { cursor: "grab", touchAction: "none" },
})}
</div>
)
}
export function SortableList({ items, renderItem, onOrder }) {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
}),
useSensor(KeyboardSensor),
)
const handleDragEnd = (event) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = items.findIndex((i) => i.id === active.id)
const newIndex = items.findIndex((i) => i.id === over.id)
const newItems = arrayMove(items, oldIndex, newIndex)
onOrder(newItems)
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis]}
>
<SortableContext
items={items}
strategy={verticalListSortingStrategy}
>
{items.map((item, index) => (
<SortableItem key={item.id} id={item.id}>
{(handleProps) => (
<div>
{renderItem(item, index)}
<div id="drag-handle" {...handleProps} />
</div>
)}
</SortableItem>
))}
</SortableContext>
</DndContext>
)
}

View File

@ -1,51 +0,0 @@
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

@ -1,184 +0,0 @@
import React from "react"
import * as antd from "antd"
import CoverEditor from "@components/CoverEditor"
import { Icons } from "@components/Icons"
import EnhancedLyricsEditor from "@components/MusicStudio/EnhancedLyricsEditor"
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
import "./index.less"
const TrackEditor = (props) => {
const context = React.useContext(ReleaseEditorStateContext)
const [track, setTrack] = React.useState(props.track ?? {})
async function handleChange(key, value) {
setTrack((prev) => {
return {
...prev,
[key]: value,
}
})
}
async function openEnhancedLyricsEditor() {
context.renderCustomPage({
header: "Enhanced Lyrics",
content: EnhancedLyricsEditor,
props: {
track: track,
},
})
}
async function handleOnSave() {
setTrack((prev) => {
const listData = [...context.items]
const trackIndex = listData.findIndex(
(item) => item.uid === prev.uid,
)
if (trackIndex === -1) {
return prev
}
listData[trackIndex] = prev
context.setGlobalState({
...context,
items: listData,
})
props.close()
return prev
})
}
function setParentCover() {
handleChange("cover", context.cover)
}
React.useEffect(() => {
context.setCustomPageActions([
{
label: "Save",
icon: "FiSave",
type: "primary",
onClick: handleOnSave,
disabled: props.track === track,
},
])
}, [track])
return (
<div className="track-editor">
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdImage />
<span>Cover</span>
</div>
<CoverEditor
value={track.cover}
onChange={(url) => handleChange("cover", url)}
extraActions={[
<antd.Button onClick={setParentCover}>
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.FiUser />
<span>Artist</span>
</div>
<antd.Input
value={track.artist}
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>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.FiEye />
<span>Public</span>
</div>
<antd.Switch
checked={track.public}
onChange={(value) => handleChange("public", value)}
/>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdLyrics />
<span>Enhanced Lyrics</span>
</div>
<div className="track-editor-field-actions">
<antd.Button
disabled={!track.params._id}
onClick={openEnhancedLyricsEditor}
>
Edit
</antd.Button>
{!track.params._id && (
<span>
You cannot edit Video and Lyrics without release
first
</span>
)}
</div>
</div>
</div>
)
}
export default TrackEditor

View File

@ -1,59 +0,0 @@
.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;
gap: 7px;
width: 100%;
h3 {
font-size: 1.2rem;
}
}
.track-editor-field-actions {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: 10px;
width: 100%;
}
}
}

View File

@ -4,7 +4,7 @@ import CountUp from "react-countup"
import "./index.less"
export default (props) => {
const LikeButtonAction = (props) => {
const [liked, setLiked] = React.useState(props.defaultLiked ?? false)
const [clicked, setCliked] = React.useState(false)
@ -15,6 +15,7 @@ export default (props) => {
if (typeof props.onClick === "function") {
const result = await props.onClick(to)
if (typeof result === "boolean") {
to = result
}
@ -23,16 +24,12 @@ export default (props) => {
setLiked(to)
}
return <div
className={
classnames(
"like_btn_wrapper",
{
return (
<div
className={classnames("like_btn_wrapper", {
["liked"]: liked,
["clicked"]: clicked
}
)
}
["clicked"]: clicked,
})}
onClick={handleClick}
>
<button className="like_btn">
@ -56,4 +53,7 @@ export default (props) => {
useEasing={true}
/>
</div>
)
}
export default LikeButtonAction

View File

@ -1,5 +1,6 @@
import React from "react"
import * as antd from "antd"
import lodash from "lodash"
import { AnimatePresence } from "motion/react"
import { Icons } from "@components/Icons"
@ -23,353 +24,8 @@ const LoadingComponent = () => {
)
}
const NoResultComponent = () => {
return (
<antd.Empty
description="No more post here"
style={{
width: "100%",
}}
/>
)
}
const typeToComponent = {
post: (args) => <PostCard {...args} />,
//"playlist": (args) => <PlaylistTimelineEntry {...args} />,
}
const Entry = React.memo((props) => {
const { data } = props
return React.createElement(
typeToComponent[data.type ?? "post"] ?? PostCard,
{
key: data._id,
data: data,
disableReplyTag: props.disableReplyTag,
disableHasReplies: props.disableHasReplies,
events: {
onClickLike: props.onLikePost,
onClickSave: props.onSavePost,
onClickDelete: props.onDeletePost,
onClickEdit: props.onEditPost,
onClickReply: props.onReplyPost,
onDoubleClick: props.onDoubleClick,
},
},
)
})
const PostList = React.forwardRef((props, ref) => {
return (
<LoadMore
ref={ref}
className="post-list"
loadingComponent={LoadingComponent}
noResultComponent={NoResultComponent}
hasMore={props.hasMore}
fetching={props.loading}
onBottom={props.onLoadMore}
>
{!props.realtimeUpdates && !app.isMobile && (
<div className="resume_btn_wrapper">
<antd.Button
type="primary"
shape="round"
onClick={props.onResumeRealtimeUpdates}
loading={props.resumingLoading}
icon={<Icons.FiSyncOutlined />}
>
Resume
</antd.Button>
</div>
)}
<AnimatePresence>
{props.list.map((data) => {
return <Entry key={data._id} data={data} {...props} />
})}
</AnimatePresence>
</LoadMore>
)
})
export class PostsListsComponent extends React.Component {
state = {
openPost: null,
loading: false,
resumingLoading: false,
initialLoading: true,
scrollingToTop: false,
topVisible: true,
realtimeUpdates: true,
hasMore: true,
list: this.props.list ?? [],
pageCount: 0,
}
parentRef = this.props.innerRef
listRef = React.createRef()
timelineWsEvents = {
"post:new": (data) => {
console.log("[WS] Recived a post >", data)
this.setState({
list: [data, ...this.state.list],
})
},
"post:delete": (id) => {
console.log("[WS] Received a post delete >", id)
this.setState({
list: this.state.list.filter((post) => {
return post._id !== id
}),
})
},
"post:update": (data) => {
console.log("[WS] Received a post update >", data)
this.setState({
list: this.state.list.map((post) => {
if (post._id === data._id) {
return data
}
return post
}),
})
},
}
handleLoad = async (fn, params = {}) => {
if (this.state.loading === true) {
console.warn(`Please wait to load the post before load more`)
return
}
this.setState({
loading: true,
})
let payload = {
page: this.state.pageCount,
limit: app.cores.settings.get("feed_max_fetch"),
}
if (this.props.loadFromModelProps) {
payload = {
...payload,
...this.props.loadFromModelProps,
}
}
const result = await fn(payload).catch((err) => {
console.error(err)
app.message.error("Failed to load more posts")
return null
})
if (result) {
if (result.length === 0) {
return this.setState({
hasMore: false,
})
}
if (params.replace) {
this.setState({
list: result,
pageCount: 0,
})
} else {
this.setState({
list: [...this.state.list, ...result],
pageCount: this.state.pageCount + 1,
})
}
}
this.setState({
loading: false,
})
}
addPost = (post) => {
this.setState({
list: [post, ...this.state.list],
})
}
removePost = (id) => {
this.setState({
list: this.state.list.filter((post) => {
return post._id !== id
}),
})
}
_hacks = {
addPost: this.addPost,
removePost: this.removePost,
addRandomPost: () => {
const randomId = Math.random().toString(36).substring(7)
this.addPost({
_id: randomId,
message: `Random post ${randomId}`,
user: {
_id: randomId,
username: "random user",
avatar: `https://api.dicebear.com/7.x/thumbs/svg?seed=${randomId}`,
},
})
},
listRef: this.listRef,
}
onResumeRealtimeUpdates = async () => {
console.log("Resuming realtime updates")
this.setState({
resumingLoading: true,
scrollingToTop: true,
})
this.listRef.current.scrollTo({
top: 0,
behavior: "smooth",
})
// reload posts
await this.handleLoad(this.props.loadFromModel, {
replace: true,
})
this.setState({
realtimeUpdates: true,
resumingLoading: false,
})
}
onScrollList = (e) => {
const { scrollTop } = e.target
if (this.state.scrollingToTop && scrollTop === 0) {
this.setState({
scrollingToTop: false,
})
}
if (scrollTop > 200) {
if (this.state.topVisible) {
this.setState({
topVisible: false,
})
if (typeof this.props.onTopVisibility === "function") {
this.props.onTopVisibility(false)
}
}
if (
!this.props.realtime ||
this.state.resumingLoading ||
this.state.scrollingToTop
) {
return null
}
this.setState({
realtimeUpdates: false,
})
} else {
if (!this.state.topVisible) {
this.setState({
topVisible: true,
})
if (typeof this.props.onTopVisibility === "function") {
this.props.onTopVisibility(true)
}
// if (this.props.realtime || !this.state.realtimeUpdates && !this.state.resumingLoading && scrollTop < 5) {
// this.onResumeRealtimeUpdates()
// }
}
}
}
componentDidMount = async () => {
if (typeof this.props.loadFromModel === "function") {
await this.handleLoad(this.props.loadFromModel)
}
this.setState({
initialLoading: false,
})
if (this.props.realtime) {
for (const [event, handler] of Object.entries(
this.timelineWsEvents,
)) {
app.cores.api.listenEvent(event, handler, "posts")
}
app.cores.api.joinTopic(
"posts",
this.props.customTopic ?? "realtime:feed",
)
}
if (this.listRef && this.listRef.current) {
this.listRef.current.addEventListener("scroll", this.onScrollList)
}
window._hacks = this._hacks
}
componentWillUnmount = async () => {
if (this.props.realtime) {
for (const [event, handler] of Object.entries(
this.timelineWsEvents,
)) {
app.cores.api.unlistenEvent(event, handler, "posts")
}
app.cores.api.leaveTopic(
"posts",
this.props.customTopic ?? "realtime:feed",
)
}
if (this.listRef && this.listRef.current) {
this.listRef.current.removeEventListener(
"scroll",
this.onScrollList,
)
}
window._hacks = null
}
componentDidUpdate = async (prevProps, prevState) => {
if (prevProps.list !== this.props.list) {
this.setState({
list: this.props.list,
})
}
}
onLikePost = async (data) => {
const PostActions = {
onClickLike: async (data) => {
let result = await PostModel.toggleLike({ post_id: data._id }).catch(
() => {
antd.message.error("Failed to like post")
@ -379,9 +35,8 @@ export class PostsListsComponent extends React.Component {
)
return result
}
onSavePost = async (data) => {
},
onClickSave: async (data) => {
let result = await PostModel.toggleSave({ post_id: data._id }).catch(
() => {
antd.message.error("Failed to save post")
@ -391,25 +46,8 @@ export class PostsListsComponent extends React.Component {
)
return result
}
onEditPost = (data) => {
app.controls.openPostCreator({
edit_post: data._id,
})
}
onReplyPost = (data) => {
app.controls.openPostCreator({
reply_to: data._id,
})
}
onDoubleClickPost = (data) => {
app.navigation.goToPost(data._id)
}
onDeletePost = async (data) => {
},
onClickDelete: async (data) => {
antd.Modal.confirm({
title: "Are you sure you want to delete this post?",
content: "This action is irreversible",
@ -422,74 +60,218 @@ export class PostsListsComponent extends React.Component {
})
},
})
}
},
onClickEdit: async (data) => {
app.controls.openPostCreator({
edit_post: data._id,
})
},
onClickReply: async (data) => {
app.controls.openPostCreator({
reply_to: data._id,
})
},
onDoubleClick: async (data) => {
app.navigation.goToPost(data._id)
},
}
ontoggleOpen = (to, data) => {
if (typeof this.props.onOpenPost === "function") {
this.props.onOpenPost(to, data)
}
}
onLoadMore = async () => {
if (typeof this.props.onLoadMore === "function") {
return this.handleLoad(this.props.onLoadMore)
} else if (this.props.loadFromModel) {
return this.handleLoad(this.props.loadFromModel)
}
}
render() {
if (this.state.initialLoading) {
return <antd.Skeleton active />
}
if (this.state.list.length === 0) {
if (typeof this.props.emptyListRender === "function") {
return React.createElement(this.props.emptyListRender)
}
const Entry = (props) => {
const { data } = props
return (
<div className="no_more_posts">
<antd.Empty />
<h1>Whoa, nothing on here...</h1>
</div>
<PostCard
key={data._id}
data={data}
disableReplyTag={props.disableReplyTag}
disableHasReplies={props.disableHasReplies}
events={PostActions}
/>
)
}
const PostList = React.forwardRef((props, ref) => {
return (
<LoadMore
ref={ref}
className="post-list"
loadingComponent={LoadingComponent}
hasMore={props.hasMore}
onBottom={props.onLoadMore}
>
<AnimatePresence>
{props.list.map((data) => {
return <Entry key={data._id} data={data} {...props} />
})}
</AnimatePresence>
</LoadMore>
)
})
const PostsListsComponent = (props) => {
const [list, setList] = React.useState([])
const [hasMore, setHasMore] = React.useState(true)
// Refs
const firstLoad = React.useRef(true)
const loading = React.useRef(false)
const page = React.useRef(0)
const listRef = React.useRef(null)
const loadModelPropsRef = React.useRef({})
const timelineWsEvents = React.useRef({
"post:new": (data) => {
console.debug("post:new", data)
setList((prev) => {
return [data, ...prev]
})
},
"post:delete": (data) => {
console.debug("post:delete", data)
setList((prev) => {
return prev.filter((item) => {
return item._id !== data._id
})
})
},
"post:update": (data) => {
console.debug("post:update", data)
setList((prev) => {
return prev.map((item) => {
if (item._id === data._id) {
return data
}
return item
})
})
},
})
// Logic
async function handleLoad(fn, params = {}) {
if (loading.current === true) {
console.warn(`Please wait to load the post before load more`)
return
}
loading.current = true
let payload = {
page: page.current,
limit: app.cores.settings.get("feed_max_fetch"),
}
if (loadModelPropsRef.current) {
payload = {
...payload,
...loadModelPropsRef.current,
}
}
const result = await fn(payload).catch((err) => {
console.error(err)
app.message.error("Failed to load more posts")
return null
})
loading.current = false
firstLoad.current = false
if (result) {
setHasMore(result.has_more)
if (result.items?.length > 0) {
if (params.replace) {
setList(result.items)
page.current = 0
} else {
setList((prev) => {
return [...prev, ...result.items]
})
page.current = page.current + 1
}
}
}
}
const onLoadMore = React.useCallback(() => {
if (typeof props.onLoadMore === "function") {
return handleLoad(props.onLoadMore)
} else if (props.loadFromModel) {
return handleLoad(props.loadFromModel)
}
}, [props])
React.useEffect(() => {
if (
!lodash.isEqual(props.loadFromModelProps, loadModelPropsRef.current)
) {
loadModelPropsRef.current = props.loadFromModelProps
page.current = 0
loading.current = false
setHasMore(true)
setList([])
handleLoad(props.loadFromModel)
}
}, [
props.loadFromModel,
props.loadFromModelProps,
firstLoad.current === false,
])
React.useEffect(() => {
if (props.loadFromModelProps) {
loadModelPropsRef.current = props.loadFromModelProps
}
if (typeof props.loadFromModel === "function") {
handleLoad(props.loadFromModel)
}
if (props.realtime) {
for (const [event, handler] of Object.entries(
timelineWsEvents.current,
)) {
app.cores.api.listenEvent(event, handler, "posts")
}
app.cores.api.joinTopic(
"posts",
props.customTopic ?? "realtime:feed",
)
}
const PostListProps = {
list: this.state.list,
disableReplyTag: this.props.disableReplyTag,
disableHasReplies: this.props.disableHasReplies,
onLikePost: this.onLikePost,
onSavePost: this.onSavePost,
onDeletePost: this.onDeletePost,
onEditPost: this.onEditPost,
onReplyPost: this.onReplyPost,
onDoubleClick: this.onDoubleClickPost,
onLoadMore: this.onLoadMore,
hasMore: this.state.hasMore,
loading: this.state.loading,
realtimeUpdates: this.state.realtimeUpdates,
resumingLoading: this.state.resumingLoading,
onResumeRealtimeUpdates: this.onResumeRealtimeUpdates,
return () => {
if (props.realtime) {
for (const [event, handler] of Object.entries(
timelineWsEvents.current,
)) {
app.cores.api.unlistenEvent(event, handler, "posts")
}
if (app.isMobile) {
return <PostList ref={this.listRef} {...PostListProps} />
app.cores.api.leaveTopic(
"posts",
props.customTopic ?? "realtime:feed",
)
}
}
}, [])
return (
<div className="post-list_wrapper">
<PostList ref={this.listRef} {...PostListProps} />
<PostList
ref={listRef}
list={list}
hasMore={hasMore}
onLoadMore={onLoadMore}
/>
</div>
)
}
}
export default React.forwardRef((props, ref) => (
<PostsListsComponent innerRef={ref} {...props} />
))
export default PostsListsComponent

View File

@ -22,22 +22,17 @@ function processValue(value, decorator) {
const UserLinkViewer = (props) => {
const { link, decorator } = props
return <div className="userLinkViewer">
return (
<div className="userLinkViewer">
<div className="userLinkViewer_icon">
{
createIconRender(decorator.icon ?? "MdLink")
}
{createIconRender(decorator.icon ?? "MdLink")}
</div>
<div className="userLinkViewer_value">
<p>
{
link.value
}
</p>
<p>{link.value}</p>
</div>
</div>
)
}
const UserLink = (props) => {
@ -57,8 +52,8 @@ const UserLink = (props) => {
app.layout.drawer.open("link_viewer", UserLinkViewer, {
componentProps: {
link: link,
decorator: decorator
}
decorator: decorator,
},
})
}
return false
@ -75,39 +70,33 @@ const UserLink = (props) => {
return link.value
}
return <div
return (
<div
key={index}
id={`link-${index}-${link.key}`}
className={`userLink ${hasHref ? "clickable" : ""}`}
onClick={handleOnClick}
>
{
createIconRender(decorator.icon ?? "MdLink")
}
{createIconRender(decorator.icon ?? "MdLink")}
{
!app.isMobile && <p>
{
renderName()
}
</p>
}
{!app.isMobile && <p>{renderName()}</p>}
</div>
)
}
export const UserCard = React.forwardRef((props, ref) => {
export const UserCard = (props) => {
const [user, setUser] = React.useState(props.user)
React.useEffect(() => {
setUser(props.user)
}, [props.user])
// TODO: Support API user data fetching
return <div
className="userCard"
ref={ref}
>
return (
<div className="userCard">
<div className="avatar">
<Image
src={user.avatar}
/>
<Image src={user.avatar} />
</div>
<div className="username">
@ -116,121 +105,101 @@ export const UserCard = React.forwardRef((props, ref) => {
{user.public_name || user.username}
{user.verified && <Icons.verifiedBadge />}
</h1>
<span>
@{user.username}
</span>
<span>@{user.username}</span>
</div>
{
user.badges?.length > 0 && <UserBadges user_id={user._id} />
}
{user.badges?.length > 0 && <UserBadges user_id={user._id} />}
</div>
<div className="description">
<span>
{user.description}
</span>
<span>{user.description}</span>
</div>
{
user.links && Array.isArray(user.links) && user.links.length > 0 && <div className="userLinks">
{
user.links.map((link, index) => {
return <UserLink index={index} link={link} />
})
}
{user.links &&
Array.isArray(user.links) &&
user.links.length > 0 && (
<div className="userLinks">
{user.links.map((link, index) => {
return (
<UserLink
key={index}
index={index}
link={link}
/>
)
})}
</div>
}
</div>
})
export const MobileUserCard = React.forwardRef((props, ref) => {
return <div
ref={ref}
className={classnames(
"_mobile_userCard",
{
["no-cover"]: !props.user.cover
}
)}
</div>
)
}
export const MobileUserCard = (props, ref) => {
return (
<div
ref={ref}
className={classnames("_mobile_userCard", {
["no-cover"]: !props.user.cover,
})}
>
<div className="_mobile_userCard_top">
{
props.user.cover && <div className="_mobile_userCard_top_cover">
{props.user.cover && (
<div className="_mobile_userCard_top_cover">
<div
className="cover"
style={{
backgroundImage: `url("${props.user.cover}")`
backgroundImage: `url("${props.user.cover}")`,
}}
/>
<div className="_mobile_userCard_top_avatar_wrapper">
<div className="_mobile_userCard_top_avatar">
<Image
src={props.user.avatar}
/>
<Image src={props.user.avatar} />
</div>
</div>
</div>
}
)}
{
!props.user.cover && <div className="_mobile_userCard_top_avatar">
<Image
src={props.user.avatar}
/>
{!props.user.cover && (
<div className="_mobile_userCard_top_avatar">
<Image src={props.user.avatar} />
</div>
}
)}
<div className="_mobile_userCard_top_texts">
<div className="_mobile_userCard_top_username">
<h1>
{
props.user.fullName ?? `@${props.user.username}`
}
{
props.user.verified && <Icons.verifiedBadge id="verification_tick" />
}
{props.user.fullName ?? `@${props.user.username}`}
{props.user.verified && (
<Icons.verifiedBadge id="verification_tick" />
)}
</h1>
{
props.user.fullName && <span>
@{props.user.username}
</span>
}
{props.user.fullName && (
<span>@{props.user.username}</span>
)}
</div>
<div className="_mobile_userCard_top_badges_wrapper">
{
props.user.badges?.length > 0 && <UserBadges user_id={props.user._id} />
}
{props.user.badges?.length > 0 && (
<UserBadges user_id={props.user._id} />
)}
</div>
<div className="_mobile_userCard_top_description">
<p>
{
props.user.description
}
</p>
<p>{props.user.description}</p>
</div>
</div>
{
props.user.links
&& Array.isArray(props.user.links)
&& props.user.links.length > 0
&& <div
className={classnames(
"_mobile_userCard_links",
)}
>
{
props.user.links.map((link, index) => {
{props.user.links &&
Array.isArray(props.user.links) &&
props.user.links.length > 0 && (
<div className={classnames("_mobile_userCard_links")}>
{props.user.links.map((link, index) => {
return <UserLink index={index} link={link} />
})
}
})}
</div>
}
)}
</div>
<div
@ -239,14 +208,14 @@ export const MobileUserCard = React.forwardRef((props, ref) => {
"_mobile_userCard_actions",
)}
>
{
props.followers && <FollowButton
{props.followers && (
<FollowButton
count={props.followers.length}
onClick={props.onClickFollow}
followed={props.isFollowed}
self={props.isSelf}
/>
}
)}
<antd.Button
type="primary"
@ -254,12 +223,10 @@ export const MobileUserCard = React.forwardRef((props, ref) => {
disabled
/>
<antd.Button
type="primary"
icon={<Icons.MdShare />}
/>
<antd.Button type="primary" icon={<Icons.MdShare />} />
</div>
</div>
})
)
}
export default UserCard

View File

@ -59,6 +59,7 @@ const UserPreview = (props) => {
return (
<div
id={userData._id}
className={classnames("userPreview", {
["clickable"]: typeof props.onClick === "function",
["small"]: props.small && !props.big,

View File

@ -65,8 +65,6 @@
height: 100%;
}
transform: translate(0, 3px);
img {
object-fit: cover;

View File

@ -1,19 +0,0 @@
import React from "react"
export const DefaultReleaseEditorState = {
cover: null,
title: "Untitled",
type: "single",
public: false,
items: [],
pendingUploads: [],
setCustomPage: () => {},
}
export const ReleaseEditorStateContext = React.createContext(
DefaultReleaseEditorState,
)
export default ReleaseEditorStateContext

View File

@ -56,7 +56,9 @@ export class WithPlayerContext extends React.Component {
events = {
"player.state.update": async (state) => {
if (state !== this.state) {
this.setState(state)
}
},
}

View File

@ -1,12 +1,12 @@
import shaka from "shaka-player/dist/shaka-player.compiled.js"
import * as dashjs from "dashjs"
import MPDParser from "../mpd_parser"
import PlayerProcessors from "./PlayerProcessors"
import AudioPlayerStorage from "../player.storage"
import TrackManifest from "../classes/TrackManifest"
import findInitializationChunk from "../helpers/findInitializationChunk"
import parseSourceFormatMetadata from "../helpers/parseSourceFormatMetadata"
import handleInlineDashManifest from "../helpers/handleInlineDashManifest"
export default class AudioBase {
constructor(player) {
@ -45,13 +45,13 @@ export default class AudioBase {
this.audio.addEventListener(key, value)
}
// setup shaka player for mpd
// setup dash.js player for mpd
this.createDemuxer()
// create element source with low latency buffer
this.elementSource = this.context.createMediaElementSource(this.audio)
await this.processorsManager.initialize(),
await this.processorsManager.initialize()
await this.processorsManager.attachAllNodes()
}
@ -60,29 +60,49 @@ export default class AudioBase {
return null
}
if (manifest._initialized) {
return manifest
}
this.console.time("itemInit()")
if (
typeof manifest === "string" ||
(!manifest.source && !manifest.dash_manifest)
) {
this.console.time("resolve")
this.console.time("resolve manifest")
manifest = await this.player.serviceProviders.resolve(manifest)
this.console.timeEnd("resolve")
this.console.timeEnd("resolve manifest")
}
if (!(manifest instanceof TrackManifest)) {
this.console.time("init manifest")
this.console.time("instanciate class")
manifest = new TrackManifest(manifest, this.player)
this.console.timeEnd("init manifest")
this.console.timeEnd("instanciate class")
}
if (manifest.mpd_mode === true && !manifest.dash_manifest) {
this.console.time("fetch dash manifest")
manifest.dash_manifest = await fetch(manifest.source).then((r) =>
r.text(),
if (
manifest.mpd_mode === true &&
!manifest.dash_manifest &&
this.demuxer
) {
this.console.time("fetch")
const manifestString = await fetch(manifest.source).then((res) =>
res.text(),
)
this.console.timeEnd("fetch dash manifest")
this.console.timeEnd("fetch")
this.console.time("parse mpd")
manifest.dash_manifest = await MPDParser(
manifestString,
manifest.source,
)
this.console.timeEnd("parse mpd")
}
manifest._initialized = true
this.console.timeEnd("itemInit()")
return manifest
}
@ -110,28 +130,32 @@ export default class AudioBase {
this.processors.gain.set(this.player.state.volume)
}
if (this.audio.paused) {
try {
if (manifest.mpd_mode && this.demuxer) {
this.console.time("play")
await this.demuxer.play()
this.console.timeEnd("play")
}
if (!manifest.mpd_mode && this.audio.paused) {
this.console.time("play")
await this.audio.play()
this.console.timeEnd("play")
} catch (error) {
this.console.error(
"Error during audio.play():",
error,
"State:",
this.audio.readyState,
)
}
}
let initChunk = manifest.source
if (this.demuxer && manifest.dash_manifest) {
initChunk = findInitializationChunk(
manifest.source,
manifest.dash_manifest,
let initializationTemplate =
manifest.dash_manifest["Period"][0]["AdaptationSet"][0][
"Representation"
][0]["SegmentTemplate"]["initialization"]
initializationTemplate = initializationTemplate.replace(
"$RepresentationID$",
"0",
)
initChunk = new URL(initializationTemplate, manifest.source)
}
try {
@ -172,79 +196,67 @@ export default class AudioBase {
if (!this.demuxer) {
this.console.log("Creating demuxer cause not initialized")
this.createDemuxer()
await this.createDemuxer()
}
if (manifest._preloaded) {
this.console.log(
`using preloaded source >`,
manifest._preloaded,
)
await this.demuxer.attachSource(manifest.dash_manifest, 0)
return await this.demuxer.load(manifest._preloaded)
}
const inlineManifest =
"inline://" + manifest.source + "::" + manifest.dash_manifest
return await this.demuxer
.load(inlineManifest, 0, "application/dash+xml")
.catch((err) => {
this.console.error("Error loading inline manifest", err)
})
return manifest.source
}
// if not using demuxer, destroy previous instance
if (this.demuxer) {
await this.demuxer.unload()
await this.demuxer.destroy()
try {
this.demuxer.reset()
this.demuxer.destroy()
} catch (error) {
this.console.warn("Error destroying demuxer:", error)
}
this.demuxer = null
}
// load source
this.audio.src = manifest.source
return this.audio.load()
this.audio.load()
return manifest.source
}
async createDemuxer() {
// Destroy previous instance if exists
if (this.demuxer) {
await this.demuxer.unload()
await this.demuxer.detach()
await this.demuxer.destroy()
try {
this.demuxer.reset()
this.demuxer.destroy()
} catch (error) {
this.console.warn("Error destroying previous demuxer:", error)
}
}
this.demuxer = new shaka.Player()
this.demuxer = dashjs.MediaPlayer().create()
this.demuxer.attach(this.audio)
try {
this.demuxer.initialize(this.audio)
} catch (error) {
this.console.error("Error initializing DASH.js player:", error)
throw error
}
this.demuxer.configure({
manifest: {
//updatePeriod: 5,
disableVideo: true,
disableText: true,
dash: {
ignoreMinBufferTime: true,
ignoreMaxSegmentDuration: true,
autoCorrectDrift: false,
enableFastSwitching: true,
useStreamOnceInPeriodFlattening: false,
},
},
this.demuxer.updateSettings({
streaming: {
bufferingGoal: 15,
rebufferingGoal: 1,
bufferBehind: 30,
stallThreshold: 0.5,
//cacheInitSegments: true,
buffer: {
bufferTimeAtTopQuality: 15,
initialBufferLevel: 1,
},
},
})
shaka.net.NetworkingEngine.registerScheme(
"inline",
handleInlineDashManifest,
)
//this.demuxer.setAutoPlay(false)
this.demuxer.addEventListener("error", (event) => {
// Event listeners
this.demuxer.on(dashjs.MediaPlayer.events.ERROR, (event) => {
console.error("Demuxer error", event)
})
}
@ -263,40 +275,21 @@ export default class AudioBase {
// if remaining time is less than 3s, try to init next item
if (parseInt(remainingTime) <= 10) {
// check if queue has next item
if (this.player.queue.nextItems[0]) {
if (
this.player.queue.nextItems[0] &&
!this.player.queue.nextItems[0]._initialized
) {
this.player.queue.nextItems[0] = await this.itemInit(
this.player.queue.nextItems[0],
)
if (
this.demuxer &&
this.player.queue.nextItems[0].source &&
this.player.queue.nextItems[0].mpd_mode &&
!this.player.queue.nextItems[0]._preloaded
) {
const manifest = this.player.queue.nextItems[0]
// preload next item
this.console.time("preload next item")
this.player.queue.nextItems[0]._preloaded =
await this.demuxer.preload(
"inline://" +
manifest.source +
"::" +
manifest.dash_manifest,
0,
"application/dash+xml",
)
this.console.timeEnd("preload next item")
}
}
}
}
flush() {
async flush() {
this.audio.pause()
this.audio.currentTime = 0
this.createDemuxer()
await this.createDemuxer()
}
audioEvents = {

View File

@ -72,6 +72,8 @@ export default class SyncRoom {
track_manifest = {
...currentItem.toSeriableObject(),
}
delete track_manifest.source
}
// check if has changed the track
@ -98,8 +100,6 @@ export default class SyncRoom {
}
syncState = async (data) => {
console.log(data)
if (!data || !data.track_manifest) {
return false
}
@ -129,11 +129,12 @@ export default class SyncRoom {
const currentTime = this.player.seek()
const offset = serverTime - currentTime
console.log({
this.player.console.debug("sync_state", {
serverPayload: data,
serverTime: serverTime,
currentTime: currentTime,
maxTimeOffset: SyncRoom.maxTimeOffset,
offset: offset,
maxTimeOffset: SyncRoom.maxTimeOffset,
})
if (
@ -182,13 +183,13 @@ export default class SyncRoom {
this.state.joined_room = null
if (this.socket) {
await this.socket.disconnect()
await this.socket.destroy()
}
}
createSocket = async () => {
if (this.socket) {
await this.socket.disconnect()
await this.socket.destroy()
}
this.socket = new RTEngineClient({

View File

@ -45,12 +45,8 @@ export default class TrackManifest {
this.source = params.source
}
if (typeof params.dash_manifest !== "undefined") {
this.dash_manifest = params.dash_manifest
}
if (typeof params.encoded_manifest !== "undefined") {
this.encoded_manifest = params.encoded_manifest
if (typeof params.mpd_string !== "undefined") {
this.mpd_string = params.mpd_string
}
if (typeof params.metadata !== "undefined") {
@ -128,7 +124,7 @@ export default class TrackManifest {
}
serviceOperations = {
fetchLyrics: async () => {
fetchLyrics: async (options) => {
if (!this._id) {
return null
}
@ -137,6 +133,7 @@ export default class TrackManifest {
"resolveLyrics",
this.service,
this,
options,
)
if (this.overrides) {
@ -195,8 +192,7 @@ export default class TrackManifest {
album: this.album,
artist: this.artist,
source: this.source,
dash_manifest: this.dash_manifest,
encoded_manifest: this.encoded_manifest,
mpd_string: this.mpd_string,
metadata: this.metadata,
liked: this.liked,
service: this.service,

View File

@ -0,0 +1,198 @@
/**
* The copyright in this software is being made available under the BSD License,
* included below. This software may be subject to other third party and contributor
* rights, including patent rights, and no such rights are granted under this license.
*
* Copyright (c) 2013, Dash Industry Forum.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation and/or
* other materials provided with the distribution.
* * Neither the name of Dash Industry Forum nor the names of its
* contributors may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, LOSS OF USE, DATA, OR
* PROFITS, OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
/**
* Dash constants declaration
* @ignore
*/
export default {
ACCESSIBILITY: "Accessibility",
ADAPTATION_SET: "AdaptationSet",
ADAPTATION_SETS: "adaptationSets",
ADAPTATION_SET_SWITCHING_SCHEME_ID_URI:
"urn:mpeg:dash:adaptation-set-switching:2016",
ADD: "add",
ASSET_IDENTIFIER: "AssetIdentifier",
AUDIO_CHANNEL_CONFIGURATION: "AudioChannelConfiguration",
AUDIO_SAMPLING_RATE: "audioSamplingRate",
AVAILABILITY_END_TIME: "availabilityEndTime",
AVAILABILITY_START_TIME: "availabilityStartTime",
AVAILABILITY_TIME_COMPLETE: "availabilityTimeComplete",
AVAILABILITY_TIME_OFFSET: "availabilityTimeOffset",
BANDWITH: "bandwidth",
BASE_URL: "BaseURL",
BITSTREAM_SWITCHING: "BitstreamSwitching",
BITSTREAM_SWITCHING_MINUS: "bitstreamSwitching",
BYTE_RANGE: "byteRange",
CAPTION: "caption",
CENC_DEFAULT_KID: "cenc:default_KID",
CLIENT_DATA_REPORTING: "ClientDataReporting",
CLIENT_REQUIREMENT: "clientRequirement",
CMCD_PARAMETERS: "CMCDParameters",
CODECS: "codecs",
CODEC_PRIVATE_DATA: "codecPrivateData",
CODING_DEPENDENCY: "codingDependency",
CONTENT_COMPONENT: "ContentComponent",
CONTENT_PROTECTION: "ContentProtection",
CONTENT_STEERING: "ContentSteering",
CONTENT_STEERING_RESPONSE: {
VERSION: "VERSION",
TTL: "TTL",
RELOAD_URI: "RELOAD-URI",
PATHWAY_PRIORITY: "PATHWAY-PRIORITY",
PATHWAY_CLONES: "PATHWAY-CLONES",
BASE_ID: "BASE-ID",
ID: "ID",
URI_REPLACEMENT: "URI-REPLACEMENT",
HOST: "HOST",
PARAMS: "PARAMS",
},
CONTENT_TYPE: "contentType",
DEFAULT_SERVICE_LOCATION: "defaultServiceLocation",
DEPENDENCY_ID: "dependencyId",
DURATION: "duration",
DVB_PRIORITY: "dvb:priority",
DVB_WEIGHT: "dvb:weight",
DVB_URL: "dvb:url",
DVB_MIMETYPE: "dvb:mimeType",
DVB_FONTFAMILY: "dvb:fontFamily",
DYNAMIC: "dynamic",
END_NUMBER: "endNumber",
ESSENTIAL_PROPERTY: "EssentialProperty",
EVENT: "Event",
EVENT_STREAM: "EventStream",
FORCED_SUBTITLE: "forced-subtitle",
FRAMERATE: "frameRate",
FRAME_PACKING: "FramePacking",
GROUP_LABEL: "GroupLabel",
HEIGHT: "height",
ID: "id",
INBAND: "inband",
INBAND_EVENT_STREAM: "InbandEventStream",
INDEX: "index",
INDEX_RANGE: "indexRange",
INITIALIZATION: "Initialization",
INITIALIZATION_MINUS: "initialization",
LA_URL: "Laurl",
LA_URL_LOWER_CASE: "laurl",
LABEL: "Label",
LANG: "lang",
LOCATION: "Location",
MAIN: "main",
MAXIMUM_SAP_PERIOD: "maximumSAPPeriod",
MAX_PLAYOUT_RATE: "maxPlayoutRate",
MAX_SEGMENT_DURATION: "maxSegmentDuration",
MAX_SUBSEGMENT_DURATION: "maxSubsegmentDuration",
MEDIA: "media",
MEDIA_PRESENTATION_DURATION: "mediaPresentationDuration",
MEDIA_RANGE: "mediaRange",
MEDIA_STREAM_STRUCTURE_ID: "mediaStreamStructureId",
METRICS: "Metrics",
METRICS_MINUS: "metrics",
MIME_TYPE: "mimeType",
MINIMUM_UPDATE_PERIOD: "minimumUpdatePeriod",
MIN_BUFFER_TIME: "minBufferTime",
MP4_PROTECTION_SCHEME: "urn:mpeg:dash:mp4protection:2011",
MPD: "MPD",
MPD_TYPE: "mpd",
MPD_PATCH_TYPE: "mpdpatch",
ORIGINAL_MPD_ID: "mpdId",
ORIGINAL_PUBLISH_TIME: "originalPublishTime",
PATCH_LOCATION: "PatchLocation",
PERIOD: "Period",
PRESENTATION_TIME: "presentationTime",
PRESENTATION_TIME_OFFSET: "presentationTimeOffset",
PRO: "pro",
PRODUCER_REFERENCE_TIME: "ProducerReferenceTime",
PRODUCER_REFERENCE_TIME_TYPE: {
ENCODER: "encoder",
CAPTURED: "captured",
APPLICATION: "application",
},
PROFILES: "profiles",
PSSH: "pssh",
PUBLISH_TIME: "publishTime",
QUALITY_RANKING: "qualityRanking",
QUERY_BEFORE_START: "queryBeforeStart",
QUERY_PART: "$querypart$",
RANGE: "range",
RATING: "Rating",
REF: "ref",
REF_ID: "refId",
REMOVE: "remove",
REPLACE: "replace",
REPORTING: "Reporting",
REPRESENTATION: "Representation",
REPRESENTATION_INDEX: "RepresentationIndex",
ROBUSTNESS: "robustness",
ROLE: "Role",
S: "S",
SAR: "sar",
SCAN_TYPE: "scanType",
SEGMENT_ALIGNMENT: "segmentAlignment",
SEGMENT_BASE: "SegmentBase",
SEGMENT_LIST: "SegmentList",
SEGMENT_PROFILES: "segmentProfiles",
SEGMENT_TEMPLATE: "SegmentTemplate",
SEGMENT_TIMELINE: "SegmentTimeline",
SEGMENT_TYPE: "segment",
SEGMENT_URL: "SegmentURL",
SERVICE_DESCRIPTION: "ServiceDescription",
SERVICE_DESCRIPTION_LATENCY: "Latency",
SERVICE_DESCRIPTION_OPERATING_BANDWIDTH: "OperatingBandwidth",
SERVICE_DESCRIPTION_OPERATING_QUALITY: "OperatingQuality",
SERVICE_DESCRIPTION_PLAYBACK_RATE: "PlaybackRate",
SERVICE_DESCRIPTION_SCOPE: "Scope",
SERVICE_LOCATION: "serviceLocation",
SERVICE_LOCATIONS: "serviceLocations",
SOURCE_URL: "sourceURL",
START: "start",
START_NUMBER: "startNumber",
START_WITH_SAP: "startWithSAP",
STATIC: "static",
STEERING_TYPE: "steering",
SUBSET: "Subset",
SUBTITLE: "subtitle",
SUB_REPRESENTATION: "SubRepresentation",
SUB_SEGMENT_ALIGNMENT: "subsegmentAlignment",
SUGGESTED_PRESENTATION_DELAY: "suggestedPresentationDelay",
SUPPLEMENTAL_PROPERTY: "SupplementalProperty",
SUPPLEMENTAL_CODECS: "scte214:supplementalCodecs",
TIMESCALE: "timescale",
TIMESHIFT_BUFFER_DEPTH: "timeShiftBufferDepth",
TTL: "ttl",
TYPE: "type",
UTC_TIMING: "UTCTiming",
VALUE: "value",
VIEWPOINT: "Viewpoint",
WALL_CLOCK_TIME: "wallClockTime",
WIDTH: "width",
}

View File

@ -0,0 +1,131 @@
import { parseXml as cmlParseXml } from "@svta/common-media-library/xml/parseXml.js"
import DashConstants from "./constants"
import DurationMatcher from "./matchers/duration"
import DateTimeMatcher from "./matchers/datetime"
import NumericMatcher from "./matchers/numeric"
import LangMatcher from "./matchers/lang"
const arrayNodes = [
DashConstants.PERIOD,
DashConstants.BASE_URL,
DashConstants.ADAPTATION_SET,
DashConstants.REPRESENTATION,
DashConstants.CONTENT_PROTECTION,
DashConstants.ROLE,
DashConstants.ACCESSIBILITY,
DashConstants.AUDIO_CHANNEL_CONFIGURATION,
DashConstants.CONTENT_COMPONENT,
DashConstants.ESSENTIAL_PROPERTY,
DashConstants.LABEL,
DashConstants.S,
DashConstants.SEGMENT_URL,
DashConstants.EVENT,
DashConstants.EVENT_STREAM,
DashConstants.LOCATION,
DashConstants.SERVICE_DESCRIPTION,
DashConstants.SUPPLEMENTAL_PROPERTY,
DashConstants.METRICS,
DashConstants.REPORTING,
DashConstants.PATCH_LOCATION,
DashConstants.REPLACE,
DashConstants.ADD,
DashConstants.REMOVE,
DashConstants.UTC_TIMING,
DashConstants.INBAND_EVENT_STREAM,
DashConstants.PRODUCER_REFERENCE_TIME,
DashConstants.CONTENT_STEERING,
]
function processNode(node, matchers) {
// Convert tag name
let p = node.nodeName.indexOf(":")
if (p !== -1) {
node.__prefix = node.prefix
node.nodeName = node.localName
}
const { childNodes, attributes, nodeName } = node
node.tagName = nodeName
// Convert attributes
for (let k in attributes) {
let value = attributes[k]
if (nodeName === "S") {
value = parseInt(value)
} else {
for (let i = 0, len = matchers.length; i < len; i++) {
const matcher = matchers[i]
if (matcher.test(nodeName, k, value)) {
value = matcher.converter(value)
break
}
}
}
node[k] = value
}
// Convert children
const len = childNodes?.length
for (let i = 0; i < len; i++) {
const child = childNodes[i]
if (child.nodeName === "#text") {
node.__text = child.nodeValue
continue
}
processNode(child, matchers)
const { nodeName } = child
if (Array.isArray(node[nodeName])) {
node[nodeName].push(child)
} else if (arrayNodes.indexOf(nodeName) !== -1) {
if (!node[nodeName]) {
node[nodeName] = []
}
node[nodeName].push(child)
} else {
node[nodeName] = child
}
}
node.__children = childNodes
}
export default async (mpd_string, url) => {
let manifest = {
protocol: "DASH",
}
const matchers = [
new DurationMatcher(),
new DateTimeMatcher(),
new NumericMatcher(),
new LangMatcher(),
]
const xml = cmlParseXml(mpd_string)
const root =
xml.childNodes.find(
(child) => child.nodeName === "MPD" || child.nodeName === "Patch",
) || xml.childNodes[0]
processNode(root, matchers)
manifest = {
...manifest,
...root,
loadedTime: new Date(),
url: url,
originalUrl: url,
baseUri: url,
}
return manifest
}

View File

@ -0,0 +1,52 @@
/**
* The copyright in this software is being made available under the BSD License,
* included below. This software may be subject to other third party and contributor
* rights, including patent rights, and no such rights are granted under this license.
*
* Copyright (c) 2013, Dash Industry Forum.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation and/or
* other materials provided with the distribution.
* * Neither the name of Dash Industry Forum nor the names of its
* contributors may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
/**
* @classdesc a base type for matching and converting types in manifest to
* something more useful
* @ignore
*/
class BaseMatcher {
constructor(test, converter) {
this._test = test
this._converter = converter
}
get test() {
return this._test
}
get converter() {
return this._converter
}
}
export default BaseMatcher

View File

@ -0,0 +1,84 @@
/**
* The copyright in this software is being made available under the BSD License,
* included below. This software may be subject to other third party and contributor
* rights, including patent rights, and no such rights are granted under this license.
*
* Copyright (c) 2013, Dash Industry Forum.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation and/or
* other materials provided with the distribution.
* * Neither the name of Dash Industry Forum nor the names of its
* contributors may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
/**
* @classdesc matches and converts xs:datetime to Date
*/
import BaseMatcher from "./base"
const SECONDS_IN_MIN = 60
const MINUTES_IN_HOUR = 60
const MILLISECONDS_IN_SECONDS = 1000
const datetimeRegex =
/^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})(?::([0-9]*)(\.[0-9]*)?)?(?:([+-])([0-9]{2})(?::?)([0-9]{2}))?/
class DateTimeMatcher extends BaseMatcher {
constructor() {
super(
(tagName, attrName, value) => datetimeRegex.test(value),
(str) => {
const match = datetimeRegex.exec(str)
let utcDate
// If the string does not contain a timezone offset different browsers can interpret it either
// as UTC or as a local time so we have to parse the string manually to normalize the given date value for
// all browsers
utcDate = Date.UTC(
parseInt(match[1], 10),
parseInt(match[2], 10) - 1, // months start from zero
parseInt(match[3], 10),
parseInt(match[4], 10),
parseInt(match[5], 10),
(match[6] && parseInt(match[6], 10)) || 0,
(match[7] &&
parseFloat(match[7]) * MILLISECONDS_IN_SECONDS) ||
0,
)
// If the date has timezone offset take it into account as well
if (match[9] && match[10]) {
const timezoneOffset =
parseInt(match[9], 10) * MINUTES_IN_HOUR +
parseInt(match[10], 10)
utcDate +=
(match[8] === "+" ? -1 : +1) *
timezoneOffset *
SECONDS_IN_MIN *
MILLISECONDS_IN_SECONDS
}
return new Date(utcDate)
},
)
}
}
export default DateTimeMatcher

View File

@ -0,0 +1,93 @@
/**
* The copyright in this software is being made available under the BSD License,
* included below. This software may be subject to other third party and contributor
* rights, including patent rights, and no such rights are granted under this license.
*
* Copyright (c) 2013, Dash Industry Forum.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation and/or
* other materials provided with the distribution.
* * Neither the name of Dash Industry Forum nor the names of its
* contributors may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
/**
* @classdesc matches and converts xs:duration to seconds
*/
import BaseMatcher from "./base"
import DashConstants from "../constants"
const durationRegex =
/^([-])?P(([\d.]*)Y)?(([\d.]*)M)?(([\d.]*)D)?T?(([\d.]*)H)?(([\d.]*)M)?(([\d.]*)S)?/
const SECONDS_IN_YEAR = 365 * 24 * 60 * 60
const SECONDS_IN_MONTH = 30 * 24 * 60 * 60
const SECONDS_IN_DAY = 24 * 60 * 60
const SECONDS_IN_HOUR = 60 * 60
const SECONDS_IN_MIN = 60
class DurationMatcher extends BaseMatcher {
constructor() {
super(
(tagName, attrName, value) => {
const attributeList = [
DashConstants.MIN_BUFFER_TIME,
DashConstants.MEDIA_PRESENTATION_DURATION,
DashConstants.MINIMUM_UPDATE_PERIOD,
DashConstants.TIMESHIFT_BUFFER_DEPTH,
DashConstants.MAX_SEGMENT_DURATION,
DashConstants.MAX_SUBSEGMENT_DURATION,
DashConstants.SUGGESTED_PRESENTATION_DELAY,
DashConstants.START,
"starttime",
DashConstants.DURATION,
]
const len = attributeList.length
for (let i = 0; i < len; i++) {
if (attrName === attributeList[i]) {
return durationRegex.test(value)
}
}
return false
},
(str) => {
//str = "P10Y10M10DT10H10M10.1S";
const match = durationRegex.exec(str)
let result =
parseFloat(match[3] || 0) * SECONDS_IN_YEAR +
parseFloat(match[5] || 0) * SECONDS_IN_MONTH +
parseFloat(match[7] || 0) * SECONDS_IN_DAY +
parseFloat(match[9] || 0) * SECONDS_IN_HOUR +
parseFloat(match[11] || 0) * SECONDS_IN_MIN +
parseFloat(match[13] || 0)
if (match[1] !== undefined) {
result = -result
}
return result
},
)
}
}
export default DurationMatcher

View File

@ -0,0 +1,71 @@
/**
* The copyright in this software is being made available under the BSD License,
* included below. This software may be subject to other third party and contributor
* rights, including patent rights, and no such rights are granted under this license.
*
* Copyright (c) 2013, Dash Industry Forum.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation and/or
* other materials provided with the distribution.
* * Neither the name of Dash Industry Forum nor the names of its
* contributors may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
/**
* @classdesc Matches and converts any ISO 639 language tag to BCP-47 language tags
*/
import BaseMatcher from "./base"
import DashConstants from "../constants"
import { bcp47Normalize } from "bcp-47-normalize"
class LangMatcher extends BaseMatcher {
constructor() {
super(
(tagName, attr /*, value*/) => {
const stringAttrsInElements = {
[DashConstants.ADAPTATION_SET]: [DashConstants.LANG],
[DashConstants.REPRESENTATION]: [DashConstants.LANG],
[DashConstants.CONTENT_COMPONENT]: [DashConstants.LANG],
[DashConstants.LABEL]: [DashConstants.LANG],
[DashConstants.GROUP_LABEL]: [DashConstants.LANG],
// still missing from 23009-1: Preselection@lang, ProgramInformation@lang
}
if (stringAttrsInElements.hasOwnProperty(tagName)) {
let attrNames = stringAttrsInElements[tagName]
if (attrNames !== undefined) {
return attrNames.indexOf(attr) >= 0
} else {
return false
}
}
return false
},
(str) => {
let lang = bcp47Normalize(str)
if (lang) {
return lang
}
return String(str)
},
)
}
}
export default LangMatcher

View File

@ -0,0 +1,52 @@
/**
* The copyright in this software is being made available under the BSD License,
* included below. This software may be subject to other third party and contributor
* rights, including patent rights, and no such rights are granted under this license.
*
* Copyright (c) 2013, Dash Industry Forum.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation and/or
* other materials provided with the distribution.
* * Neither the name of Dash Industry Forum nor the names of its
* contributors may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
/**
* @classdesc Matches and converts xs:numeric to float
*/
import BaseMatcher from "./base"
import DashConstants from "../constants"
const numericRegex = /^[-+]?[0-9]+[.]?[0-9]*([eE][-+]?[0-9]+)?$/
const StringAttributeList = [DashConstants.ID]
class NumericMatcher extends BaseMatcher {
constructor() {
super(
(tagName, attrName, value) =>
numericRegex.test(value) &&
StringAttributeList.indexOf(attrName) === -1,
(str) => parseFloat(str),
)
}
}
export default NumericMatcher

View File

@ -0,0 +1,92 @@
import config from "@config"
const isAuthenticated = () => {
return !!app.userData
}
const handleAuthentication = (declaration) => {
if (
!isAuthenticated() &&
!declaration.public &&
window.location.pathname !== config.app?.authPath
) {
const authPath = config.app?.authPath ?? "/login"
if (typeof window.app?.location?.push === "function") {
window.app.location.push(authPath)
if (app.cores?.notifications?.new) {
app.cores.notifications.new({
title: "Please login to use this feature.",
duration: 15,
})
}
} else {
window.location.href = authPath
}
return false
}
return true
}
const handleLayout = (declaration) => {
if (declaration.useLayout && app.layout?.set) {
app.layout.set(declaration.useLayout)
}
}
const handleCenteredContent = (declaration) => {
if (
typeof declaration.centeredContent !== "undefined" &&
app.layout?.toggleCenteredContent
) {
let finalBool = null
if (typeof declaration.centeredContent === "boolean") {
finalBool = declaration.centeredContent
} else {
finalBool = app.isMobile
? (declaration.centeredContent?.mobile ?? null)
: (declaration.centeredContent?.desktop ?? null)
}
app.layout.toggleCenteredContent(finalBool)
}
}
const handleTitle = (declaration) => {
if (typeof declaration.useTitle !== "undefined") {
let title = declaration.useTitle
document.title = `${title} - ${config.app.siteName}`
} else {
document.title = config.app.siteName
}
}
export default ({ element, declaration }) => {
const options = element.options ?? {}
// Handle authentication first
const isAuthorized = handleAuthentication(declaration)
if (isAuthorized) {
handleLayout(declaration)
handleCenteredContent(declaration)
handleTitle(declaration)
}
if (options.layout) {
if (typeof options.layout.type === "string" && app.layout?.set) {
app.layout.set(options.layout.type)
}
if (
typeof options.layout.centeredContent === "boolean" &&
app.layout?.toggleCenteredContent
) {
app.layout.toggleCenteredContent()
}
}
}

View File

@ -0,0 +1,19 @@
import React from "react"
export default (to) => {
React.useEffect(() => {
if (typeof to !== "undefined") {
app.layout.toggleCenteredContent(to)
return () => {
app.layout.toggleCenteredContent(!!to)
}
}
app.layout.toggleCenteredContent(true)
return () => {
app.layout.toggleCenteredContent(false)
}
}, [])
}

View File

@ -1,19 +0,0 @@
import React from "react"
export default (to) => {
React.useEffect(() => {
if (typeof to !== "undefined") {
app.layout.toggleCenteredContent(to)
return () => {
app.layout.toggleCenteredContent(!!to)
}
}
app.layout.toggleCenteredContent(true)
return () => {
app.layout.toggleCenteredContent(false)
}
}, [])
}

View File

@ -32,12 +32,14 @@ export default (trackManifest) => {
}
}, [trackManifest])
const dominantColor = {
"--dominant-color": getDominantColorStr(coverAnalysis),
}
return {
coverAnalysis,
dominantColor,
dominantColor: getDominantColorStr(coverAnalysis),
cssVars: {
"--dominant-color": getDominantColorStr(coverAnalysis),
"--dominant-color-r": coverAnalysis?.value?.[0],
"--dominant-color-g": coverAnalysis?.value?.[1],
"--dominant-color-b": coverAnalysis?.value?.[2],
},
}
}

View File

@ -0,0 +1,81 @@
import { useState, useCallback, useEffect } from "react"
import parseTimeToMs from "@utils/parseTimeToMs"
export default ({ trackManifest }) => {
const [lyrics, setLyrics] = useState(null)
const processLyrics = useCallback((rawLyrics) => {
if (!rawLyrics) return false
return rawLyrics.sync_audio_at && !rawLyrics.sync_audio_at_ms
? {
...rawLyrics,
sync_audio_at_ms: parseTimeToMs(rawLyrics.sync_audio_at),
}
: rawLyrics
}, [])
const loadCurrentTrackLyrics = useCallback(
async (options) => {
let data = null
const track = app.cores.player.track()
if (!trackManifest || !track) {
return null
}
// if is in sync mode, fetch lyrics from sync room
if (app.cores.player.inOnSyncMode()) {
const syncRoomSocket = app.cores.player.sync().socket
if (syncRoomSocket) {
data = await syncRoomSocket
.call("sync_room:request_lyrics")
.catch(() => null)
}
} else {
data = await track.serviceOperations
.fetchLyrics(options)
.catch((err) => {
console.error(err)
return null
})
}
// if no data founded, flush lyrics
if (!data) {
return setLyrics(null)
}
// process & set lyrics
data = processLyrics(data)
setLyrics(data)
console.log("Track Lyrics:", data)
},
[trackManifest, processLyrics],
)
// Load lyrics when track manifest changes or when translation is toggled
useEffect(() => {
if (!trackManifest) {
setLyrics(null)
return
}
if (!lyrics || lyrics.track_id !== trackManifest._id) {
loadCurrentTrackLyrics({
language: app.cores.settings.get("lyrics:prefer_translation")
? app.cores.settings.get("app:language").split("_")[0]
: null,
})
}
}, [trackManifest, lyrics?.track_id, loadCurrentTrackLyrics])
return {
lyrics,
setLyrics,
loadCurrentTrackLyrics,
}
}

View File

@ -1,69 +0,0 @@
import { useState, useCallback, useEffect } from "react"
import parseTimeToMs from "@utils/parseTimeToMs"
export default ({ trackManifest }) => {
const [lyrics, setLyrics] = useState(null)
const processLyrics = useCallback((rawLyrics) => {
if (!rawLyrics) return false
return rawLyrics.sync_audio_at && !rawLyrics.sync_audio_at_ms
? {
...rawLyrics,
sync_audio_at_ms: parseTimeToMs(rawLyrics.sync_audio_at),
}
: rawLyrics
}, [])
const loadCurrentTrackLyrics = useCallback(async () => {
let data = null
const track = app.cores.player.track()
if (!trackManifest || !track) {
return null
}
// if is in sync mode, fetch lyrics from sync room
if (app.cores.player.inOnSyncMode()) {
const syncRoomSocket = app.cores.player.sync().socket
if (syncRoomSocket) {
data = await syncRoomSocket
.call("sync_room:request_lyrics")
.catch(() => null)
}
} else {
data = await track.serviceOperations.fetchLyrics().catch(() => null)
}
// if no data founded, flush lyrics
if (!data) {
return setLyrics(null)
}
// process & set lyrics
data = processLyrics(data)
setLyrics(data)
console.log("Track Lyrics:", data)
}, [trackManifest, processLyrics])
// Load lyrics when track manifest changes or when translation is toggled
useEffect(() => {
if (!trackManifest) {
setLyrics(null)
return
}
if (!lyrics || lyrics.track_id !== trackManifest._id) {
loadCurrentTrackLyrics()
}
}, [trackManifest, lyrics?.track_id, loadCurrentTrackLyrics])
return {
lyrics,
setLyrics,
loadCurrentTrackLyrics,
}
}

View File

@ -0,0 +1,19 @@
import React from "react"
export default () => {
const enterPlayerAnimation = () => {
app.controls.toggleUIVisibility(false)
}
const exitPlayerAnimation = () => {
app.controls.toggleUIVisibility(true)
}
React.useEffect(() => {
enterPlayerAnimation()
return () => {
exitPlayerAnimation()
}
}, [])
}

View File

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

View File

@ -0,0 +1,39 @@
import React from "react"
export default ({ defaultKey = "0", queryKey = "key" }) => {
const [activeKey, setActiveKey] = React.useState(
new URLSearchParams(window.location.search).get(queryKey) ?? defaultKey,
)
const replaceQueryTypeToCurrentTab = (key) => {
if (!key) {
// delete query
return history.pushState(undefined, "", window.location.pathname)
}
return history.pushState(undefined, "", `?${queryKey}=${key}`)
}
const changeActiveKey = (key) => {
setActiveKey(key)
replaceQueryTypeToCurrentTab(key)
}
const onHistoryChange = () => {
const newActiveKey = new URLSearchParams(window.location.search).get(
queryKey,
)
setActiveKey(newActiveKey ?? defaultKey)
}
React.useEffect(() => {
window.addEventListener("popstate", onHistoryChange)
return () => {
window.removeEventListener("popstate", onHistoryChange)
}
}, [])
return [activeKey, changeActiveKey]
}

View File

@ -1,27 +0,0 @@
import React from "react"
export default ({
defaultKey = "0",
queryKey = "key",
}) => {
const [activeKey, setActiveKey] = React.useState(new URLSearchParams(window.location.search).get(queryKey) ?? defaultKey)
const replaceQueryTypeToCurrentTab = (key) => {
if (!key) {
// delete query
return history.pushState(undefined, "", window.location.pathname)
}
return history.pushState(undefined, "", `?${queryKey}=${key}`)
}
const changeActiveKey = (key) => {
setActiveKey(key)
replaceQueryTypeToCurrentTab(key)
}
return [
activeKey,
changeActiveKey,
]
}

View File

@ -0,0 +1,148 @@
import React, {
useState,
useEffect,
useCallback,
useMemo,
forwardRef,
useImperativeHandle,
} from "react"
import PropTypes from "prop-types"
import classnames from "classnames"
import { motion } from "motion/react"
import DrawerHeader from "./header"
const Drawer = React.memo(
forwardRef(({ id, children, options = {}, controller }, ref) => {
const [header, setHeader] = useState(options.header)
const {
position = "left",
style = {},
props: componentProps = {},
onDone,
onFail,
} = options
const setExtraActions = useCallback((actions) => {
setHeader((prev) => ({ ...prev, actions: actions }))
}, [])
const setDrawerHeader = useCallback((header) => {
setHeader(header)
}, [])
const handleClose = useCallback(async () => {
if (typeof options.onClose === "function") {
options.onClose()
}
setTimeout(() => {
controller.close(id, { transition: 150 })
}, 150)
}, [id, controller, options.onClose])
const handleDone = useCallback(
(...context) => {
if (typeof onDone === "function") {
onDone(context)
}
},
[onDone],
)
const handleFail = useCallback(
(...context) => {
if (typeof onFail === "function") {
onFail(context)
}
},
[onFail],
)
const animationVariants = useMemo(() => {
const slideDirection = position === "right" ? 100 : -100
return {
initial: {
x: slideDirection,
opacity: 0,
},
animate: {
x: 0,
opacity: 1,
},
exit: {
x: slideDirection,
opacity: 0,
},
}
}, [position])
const enhancedComponentProps = useMemo(
() => ({
...componentProps,
setHeader,
close: handleClose,
handleDone,
handleFail,
}),
[componentProps, handleClose, handleDone, handleFail],
)
useImperativeHandle(
ref,
() => ({
close: handleClose,
handleDone,
handleFail,
options,
id,
}),
[handleClose, handleDone, handleFail, options, id],
)
useEffect(() => {
if (!controller) {
throw new Error(`Cannot mount a drawer without a controller`)
}
if (!children) {
throw new Error(`Empty component`)
}
}, [controller, children])
return (
<motion.div
ref={ref}
key={id}
id={id}
className={classnames("drawer", `drawer-${position}`)}
style={style}
{...animationVariants}
transition={{
type: "spring",
stiffness: 100,
damping: 20,
}}
>
{header && <DrawerHeader {...header} onClose={handleClose} />}
<div className="drawer-content">
{React.createElement(children, enhancedComponentProps)}
</div>
</motion.div>
)
}),
)
Drawer.displayName = "Drawer"
Drawer.propTypes = {
id: PropTypes.string.isRequired,
children: PropTypes.elementType.isRequired,
options: PropTypes.object,
controller: PropTypes.object.isRequired,
}
export default Drawer

View File

@ -0,0 +1,37 @@
import React from "react"
import PropTypes from "prop-types"
const DrawerHeader = ({ title, actions, onClose, showCloseButton = true }) => {
if (!title && !actions && !showCloseButton) return null
return (
<div className="drawer-header">
<div className="drawer-header-content">
{title && <h3 className="drawer-title">{title}</h3>}
<div className="drawer-header-actions">
{actions && (
<div className="drawer-custom-actions">{actions}</div>
)}
{showCloseButton && (
<button
className="drawer-close-button"
onClick={onClose}
aria-label="Close drawer"
>
×
</button>
)}
</div>
</div>
</div>
)
}
DrawerHeader.propTypes = {
title: PropTypes.string,
actions: PropTypes.node,
onClose: PropTypes.func.isRequired,
showCloseButton: PropTypes.bool,
}
export default DrawerHeader

View File

@ -0,0 +1,516 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
import { useDrawer } from "./index.jsx"
/**
* Hook for managing drawer state with local persistence
*/
export const useDrawerState = (drawerId, initialOptions = {}) => {
const drawer = useDrawer()
const [isOpen, setIsOpen] = useState(false)
const [options, setOptions] = useState(initialOptions)
const optionsRef = useRef(options)
useEffect(() => {
optionsRef.current = options
}, [options])
const open = useCallback(
(component, newOptions = {}) => {
const mergedOptions = { ...optionsRef.current, ...newOptions }
setOptions(mergedOptions)
setIsOpen(true)
drawer.open(drawerId, component, mergedOptions)
},
[drawer, drawerId],
)
const close = useCallback(
(params = {}) => {
setIsOpen(false)
drawer.close(drawerId, params)
},
[drawer, drawerId],
)
const toggle = useCallback(
(component, newOptions = {}) => {
if (isOpen) {
close()
} else {
open(component, newOptions)
}
},
[isOpen, open, close],
)
const updateOptions = useCallback((newOptions) => {
setOptions((prev) => ({ ...prev, ...newOptions }))
}, [])
return {
isOpen,
options,
open,
close,
toggle,
updateOptions,
}
}
/**
* Hook for managing drawer queues and sequences
*/
export const useDrawerQueue = () => {
const drawer = useDrawer()
const [queue, setQueue] = useState([])
const [currentIndex, setCurrentIndex] = useState(-1)
const isProcessing = useRef(false)
const addToQueue = useCallback((id, component, options = {}) => {
setQueue((prev) => [...prev, { id, component, options }])
}, [])
const processNext = useCallback(async () => {
if (isProcessing.current) return
isProcessing.current = true
const nextIndex = currentIndex + 1
if (nextIndex < queue.length) {
const item = queue[nextIndex]
setCurrentIndex(nextIndex)
// Close previous drawer if exists
if (currentIndex >= 0) {
const prevItem = queue[currentIndex]
await new Promise((resolve) => {
drawer.close(prevItem.id, { transition: 200 })
setTimeout(resolve, 200)
})
}
// Open next drawer
drawer.open(item.id, item.component, {
...item.options,
onClose: () => {
item.options.onClose?.()
processNext()
},
})
}
isProcessing.current = false
}, [currentIndex, queue, drawer])
const processPrevious = useCallback(async () => {
if (isProcessing.current || currentIndex <= 0) return
isProcessing.current = true
const prevIndex = currentIndex - 1
const currentItem = queue[currentIndex]
const prevItem = queue[prevIndex]
// Close current drawer
await new Promise((resolve) => {
drawer.close(currentItem.id, { transition: 200 })
setTimeout(resolve, 200)
})
// Open previous drawer
setCurrentIndex(prevIndex)
drawer.open(prevItem.id, prevItem.component, prevItem.options)
isProcessing.current = false
}, [currentIndex, queue, drawer])
const clearQueue = useCallback(() => {
setQueue([])
setCurrentIndex(-1)
drawer.closeAll()
}, [drawer])
const startQueue = useCallback(() => {
if (queue.length > 0) {
setCurrentIndex(-1)
processNext()
}
}, [queue, processNext])
return {
queue,
currentIndex,
addToQueue,
processNext,
processPrevious,
clearQueue,
startQueue,
hasNext: currentIndex < queue.length - 1,
hasPrevious: currentIndex > 0,
}
}
/**
* Hook for form handling in drawers
*/
export const useDrawerForm = (drawerId, initialData = {}) => {
const drawerState = useDrawerState(drawerId)
const [formData, setFormData] = useState(initialData)
const [errors, setErrors] = useState({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const updateField = useCallback(
(field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }))
setIsDirty(true)
// Clear field error when user starts typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }))
}
},
[errors],
)
const setFieldError = useCallback((field, error) => {
setErrors((prev) => ({ ...prev, [field]: error }))
}, [])
const clearErrors = useCallback(() => {
setErrors({})
}, [])
const reset = useCallback(() => {
setFormData(initialData)
setErrors({})
setIsDirty(false)
setIsSubmitting(false)
}, [initialData])
const openForm = useCallback(
(component, options = {}) => {
const formOptions = {
...options,
confirmOnOutsideClick: isDirty,
confirmOnOutsideClickText:
"You have unsaved changes. Are you sure you want to close?",
onClose: () => {
if (
isDirty &&
!window.confirm(
"You have unsaved changes. Are you sure you want to close?",
)
) {
return false
}
reset()
options.onClose?.()
},
}
drawerState.open(component, formOptions)
},
[drawerState, isDirty, reset],
)
const submit = useCallback(
async (submitFn, options = {}) => {
const { validate, onSuccess, onError } = options
setIsSubmitting(true)
clearErrors()
try {
// Run validation if provided
if (validate) {
const validationErrors = await validate(formData)
if (
validationErrors &&
Object.keys(validationErrors).length > 0
) {
setErrors(validationErrors)
setIsSubmitting(false)
return { success: false, errors: validationErrors }
}
}
// Submit form
const result = await submitFn(formData)
// Handle success
setIsDirty(false)
onSuccess?.(result)
drawerState.close()
return { success: true, data: result }
} catch (error) {
const errorMessage = error.message || "An error occurred"
setErrors({ _global: errorMessage })
onError?.(error)
return { success: false, error: errorMessage }
} finally {
setIsSubmitting(false)
}
},
[formData, drawerState, clearErrors],
)
return {
...drawerState,
formData,
errors,
isSubmitting,
isDirty,
updateField,
setFieldError,
clearErrors,
reset,
openForm,
submit,
}
}
/**
* Hook for keyboard shortcuts in drawers
*/
export const useDrawerKeyboard = (shortcuts = {}) => {
const drawer = useDrawer()
useEffect(() => {
const handleKeyDown = (event) => {
// Only handle shortcuts when drawers are open
if (drawer.drawersLength() === 0) return
const key = event.key.toLowerCase()
const ctrlKey = event.ctrlKey || event.metaKey
const altKey = event.altKey
const shiftKey = event.shiftKey
// Build shortcut key combination
let combination = ""
if (ctrlKey) combination += "ctrl+"
if (altKey) combination += "alt+"
if (shiftKey) combination += "shift+"
combination += key
// Execute shortcut if found
const shortcut = shortcuts[combination]
if (shortcut) {
event.preventDefault()
event.stopPropagation()
shortcut(event)
}
}
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("keydown", handleKeyDown)
}
}, [shortcuts, drawer])
return {
addShortcut: useCallback(
(key, handler) => {
shortcuts[key] = handler
},
[shortcuts],
),
removeShortcut: useCallback(
(key) => {
delete shortcuts[key]
},
[shortcuts],
),
}
}
/**
* Hook for drawer animations and transitions
*/
export const useDrawerAnimation = (options = {}) => {
const { duration = 300, easing = "ease-out", stagger = 100 } = options
const [isAnimating, setIsAnimating] = useState(false)
const createVariants = useCallback((position = "left") => {
const slideDirection = position === "right" ? 100 : -100
return {
initial: {
x: slideDirection,
opacity: 0,
scale: 0.95,
},
animate: {
x: 0,
opacity: 1,
scale: 1,
},
exit: {
x: slideDirection,
opacity: 0,
scale: 0.95,
},
}
}, [])
const createTransition = useCallback(
(delay = 0) => ({
type: "spring",
stiffness: 100,
damping: 20,
duration: duration / 1000,
delay: delay / 1000,
}),
[duration],
)
const staggeredTransition = useCallback(
(index = 0) => createTransition(index * stagger),
[createTransition, stagger],
)
const animateSequence = useCallback(
async (animations) => {
setIsAnimating(true)
for (let i = 0; i < animations.length; i++) {
const animation = animations[i]
await new Promise((resolve) => {
setTimeout(() => {
animation()
resolve()
}, i * stagger)
})
}
setTimeout(() => setIsAnimating(false), duration)
},
[stagger, duration],
)
return {
isAnimating,
createVariants,
createTransition,
staggeredTransition,
animateSequence,
}
}
/**
* Hook for drawer persistence (localStorage)
*/
export const useDrawerPersistence = (key, initialState = {}) => {
const [state, setState] = useState(() => {
try {
const item = localStorage.getItem(`drawer_${key}`)
return item ? JSON.parse(item) : initialState
} catch {
return initialState
}
})
const updateState = useCallback(
(newState) => {
setState(newState)
try {
localStorage.setItem(`drawer_${key}`, JSON.stringify(newState))
} catch (error) {
console.warn(
"Failed to save drawer state to localStorage:",
error,
)
}
},
[key],
)
const clearState = useCallback(() => {
setState(initialState)
try {
localStorage.removeItem(`drawer_${key}`)
} catch (error) {
console.warn(
"Failed to clear drawer state from localStorage:",
error,
)
}
}, [key, initialState])
return [state, updateState, clearState]
}
/**
* Hook for drawer accessibility features
*/
export const useDrawerAccessibility = (options = {}) => {
const { trapFocus = true, autoFocus = true, restoreFocus = true } = options
const previousActiveElement = useRef(null)
const drawerRef = useRef(null)
const setupAccessibility = useCallback(() => {
// Store the currently focused element
if (restoreFocus) {
previousActiveElement.current = document.activeElement
}
// Auto focus the drawer
if (autoFocus && drawerRef.current) {
const firstFocusable = drawerRef.current.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
if (firstFocusable) {
firstFocusable.focus()
}
}
// Set up focus trap
if (trapFocus) {
const handleTabKey = (e) => {
if (e.key !== "Tab" || !drawerRef.current) return
const focusableElements = drawerRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
const firstElement = focusableElements[0]
const lastElement =
focusableElements[focusableElements.length - 1]
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus()
e.preventDefault()
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus()
e.preventDefault()
}
}
}
document.addEventListener("keydown", handleTabKey)
return () => document.removeEventListener("keydown", handleTabKey)
}
}, [trapFocus, autoFocus, restoreFocus])
const cleanupAccessibility = useCallback(() => {
// Restore focus to previous element
if (restoreFocus && previousActiveElement.current) {
previousActiveElement.current.focus()
}
}, [restoreFocus])
return {
drawerRef,
setupAccessibility,
cleanupAccessibility,
}
}

View File

@ -1,153 +1,56 @@
import React from "react"
import React, {
useState,
useEffect,
useCallback,
useMemo,
useRef,
createContext,
useContext,
} from "react"
import classnames from "classnames"
import { AnimatePresence, motion } from "motion/react"
import Drawer from "./component"
import "./index.less"
export class Drawer extends React.Component {
options = this.props.options ?? {}
// Context for drawer management
const DrawerContext = createContext()
state = {
visible: false,
// Hook to use drawer context
export const useDrawer = () => {
const context = useContext(DrawerContext)
if (!context) {
throw new Error("useDrawer must be used within a DrawerProvider")
}
toggleVisibility = (to) => {
to = to ?? !this.state.visible
this.setState({ visible: to })
}
close = async () => {
if (typeof this.options.onClose === "function") {
this.options.onClose()
}
this.toggleVisibility(false)
this.props.controller.close(this.props.id, {
transition: 150,
})
}
handleDone = (...context) => {
if (typeof this.options.onDone === "function") {
this.options.onDone(this, ...context)
}
}
handleFail = (...context) => {
if (typeof this.options.onFail === "function") {
this.options.onFail(this, ...context)
}
}
componentDidMount = async () => {
if (typeof this.props.controller === "undefined") {
throw new Error(`Cannot mount an drawer without an controller`)
}
if (typeof this.props.children === "undefined") {
throw new Error(`Empty component`)
}
this.toggleVisibility(true)
}
render() {
const componentProps = {
...this.options.props,
close: this.close,
handleDone: this.handleDone,
handleFail: this.handleFail,
}
return (
<AnimatePresence>
<motion.div
key={this.props.id}
id={this.props.id}
className="drawer"
style={{
...this.options.style,
}}
animate={{
x: 0,
opacity: 1,
}}
initial={{
x: -100,
opacity: 0,
}}
exit={{
x: -100,
opacity: 0,
}}
transition={{
type: "spring",
stiffness: 100,
damping: 20,
}}
>
{React.createElement(this.props.children, componentProps)}
</motion.div>
</AnimatePresence>
)
}
return context
}
export default class DrawerController extends React.Component {
constructor(props) {
super(props)
this.state = {
function DrawerController() {
const [state, setState] = useState({
addresses: {},
refs: {},
drawers: [],
maskVisible: false,
}
})
this.interface = {
open: this.open,
close: this.close,
closeAll: this.closeAll,
drawers: () => this.state.drawers,
drawersLength: () => this.state.drawers.length,
isMaskVisible: () => this.state.maskVisible,
}
}
const stateRef = useRef(state)
stateRef.current = state
componentDidMount = () => {
app.layout["drawer"] = this.interface
const toggleMaskVisibility = useCallback((to) => {
setState((prev) => ({
...prev,
maskVisible: to ?? !prev.maskVisible,
}))
}, [])
this.listenEscape()
}
const handleEscKeyPress = useCallback((event) => {
const currentState = stateRef.current
componentWillUnmount = () => {
delete app.layout["drawer"]
this.unlistenEscape()
}
componentWillUpdate = (prevProps, prevState) => {
// is mask visible, hide sidebar with `app.layout.sidebar.toggleVisibility(false)`
if (app.layout.sidebar) {
if (prevState.maskVisible !== this.state.maskVisible) {
app.layout.sidebar.toggleVisibility(this.state.maskVisible)
}
}
}
listenEscape = () => {
document.addEventListener("keydown", this.handleEscKeyPress)
}
unlistenEscape = () => {
document.removeEventListener("keydown", this.handleEscKeyPress)
}
handleEscKeyPress = (event) => {
if (this.state.drawers.length === 0) {
return false
if (currentState.drawers.length === 0) {
return null
}
let isEscape = false
@ -159,83 +62,62 @@ export default class DrawerController extends React.Component {
}
if (isEscape) {
this.closeLastDrawer()
closeLastDrawer()
}
}, [])
const getLastDrawer = useCallback(() => {
const currentState = stateRef.current
const lastDrawerId =
currentState.drawers[currentState.drawers.length - 1]?.id
if (!lastDrawerId) {
return null
}
getLastDrawer = () => {
return this.state.drawers[this.state.drawers.length - 1].ref.current
return {
id: lastDrawerId,
ref: currentState.refs[lastDrawerId]?.current,
options:
currentState.drawers[currentState.drawers.length - 1]?.options,
}
}, [])
closeLastDrawer = () => {
const lastDrawer = this.getLastDrawer()
const closeLastDrawer = useCallback(() => {
const lastDrawer = getLastDrawer()
if (lastDrawer) {
if (app.layout.modal && lastDrawer.options.confirmOnOutsideClick) {
if (lastDrawer && lastDrawer.id) {
if (
app.layout?.modal &&
lastDrawer.options?.confirmOnOutsideClick
) {
return app.layout.modal.confirm({
descriptionText:
lastDrawer.options.confirmOnOutsideClickText ??
lastDrawer.options.confirmOnOutsideClickText ||
"Are you sure you want to close this drawer?",
onConfirm: () => {
lastDrawer.close()
close(lastDrawer.id)
},
})
}
lastDrawer.close()
}
close(lastDrawer.id)
}
}, [getLastDrawer])
toggleMaskVisibility = async (to) => {
this.setState({
maskVisible: to ?? !this.state.maskVisible,
})
}
open = (id, component, options) => {
const refs = this.state.refs ?? {}
const drawers = this.state.drawers ?? []
const addresses = this.state.addresses ?? {}
const instance = {
id: id,
ref: React.createRef(),
children: component,
options: options,
controller: this,
}
if (typeof addresses[id] === "undefined") {
drawers.push(<Drawer key={id} {...instance} />)
addresses[id] = drawers.length - 1
refs[id] = instance.ref
} else {
drawers[addresses[id]] = <Drawer key={id} {...instance} />
refs[id] = instance.ref
}
this.setState({
refs,
addresses,
drawers,
})
this.toggleMaskVisibility(true)
}
close = async (id, { transition = 0 } = {}) => {
let { addresses, drawers, refs } = this.state
const index = addresses[id]
const ref = this.state.refs[id]?.current
const close = useCallback(
async (id, { transition = 0 } = {}) => {
const currentState = stateRef.current
const index = currentState.addresses[id]
const ref = currentState.refs[id]?.current
if (typeof ref === "undefined") {
return console.warn("This drawer not exists")
console.warn("This drawer does not exist")
return
}
if (drawers.length === 1) {
this.toggleMaskVisibility(false)
if (currentState.drawers.length === 1) {
toggleMaskVisibility(false)
}
if (transition > 0) {
@ -244,43 +126,139 @@ export default class DrawerController extends React.Component {
})
}
if (typeof drawers[index] !== "undefined") {
drawers = drawers.filter((value, i) => i !== index)
setState((prev) => {
const newDrawers = prev.drawers.filter((_, i) => i !== index)
const newAddresses = { ...prev.addresses }
const newRefs = { ...prev.refs }
delete newAddresses[id]
delete newRefs[id]
return {
...prev,
refs: newRefs,
addresses: newAddresses,
drawers: newDrawers,
}
delete addresses[id]
delete refs[id]
this.setState({
refs,
addresses,
drawers,
})
}
},
[toggleMaskVisibility],
)
closeAll = () => {
this.state.drawers.forEach((drawer) => {
drawer.ref.current.close()
const closeAll = useCallback(() => {
const currentState = stateRef.current
currentState.drawers.forEach((drawer) => {
close(drawer.id)
})
}, [close])
// Create controller object
const controller = useMemo(
() => ({
close,
closeAll,
drawers: () => stateRef.current.drawers,
drawersLength: () => stateRef.current.drawers.length,
isMaskVisible: () => stateRef.current.maskVisible,
}),
[close, closeAll],
)
const open = useCallback(
(id, component, options = {}) => {
setState((prev) => {
const { refs, drawers, addresses } = prev
const newRefs = { ...refs }
const newDrawers = [...drawers]
const newAddresses = { ...addresses }
const drawerRef = React.createRef()
const instance = {
id,
children: component,
options,
controller,
}
render() {
if (typeof newAddresses[id] === "undefined") {
newDrawers.push({
...instance,
element: (
<Drawer key={id} ref={drawerRef} {...instance} />
),
})
newAddresses[id] = newDrawers.length - 1
newRefs[id] = drawerRef
} else {
newDrawers[newAddresses[id]] = {
...instance,
element: (
<Drawer key={id} ref={drawerRef} {...instance} />
),
}
newRefs[id] = drawerRef
}
return {
...prev,
refs: newRefs,
addresses: newAddresses,
drawers: newDrawers,
maskVisible: true,
}
})
},
[controller],
)
// Complete interface with open method
const interface_ = useMemo(
() => ({
...controller,
open,
}),
[controller, open],
)
// Setup effects
useEffect(() => {
if (app.layout) {
app.layout["drawer"] = interface_
}
return () => {
if (app.layout) {
delete app.layout["drawer"]
}
}
}, [interface_])
useEffect(() => {
document.addEventListener("keydown", handleEscKeyPress)
return () => {
document.removeEventListener("keydown", handleEscKeyPress)
}
}, [handleEscKeyPress])
// Handle sidebar visibility based on mask visibility
useEffect(() => {
if (app.layout?.sidebar) {
app.layout.sidebar.toggleVisibility(!state.maskVisible)
}
}, [state.maskVisible])
return (
<>
<DrawerContext.Provider value={interface_}>
<AnimatePresence>
{this.state.maskVisible && (
{state.maskVisible && (
<motion.div
className="drawers-mask"
onClick={() => this.closeLastDrawer()}
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
onClick={closeLastDrawer}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
type: "spring",
stiffness: 100,
@ -292,12 +270,15 @@ export default class DrawerController extends React.Component {
<div
className={classnames("drawers-wrapper", {
["hidden"]: !this.state.drawers.length,
["hidden"]: !state.drawers.length,
})}
>
{this.state.drawers}
<AnimatePresence mode="wait">
{state.drawers.map((drawer) => drawer.element)}
</AnimatePresence>
</div>
</>
</DrawerContext.Provider>
)
}
}
export default DrawerController

View File

@ -2,21 +2,16 @@
.drawers-wrapper {
position: absolute;
top: 0;
left: 0;
z-index: 1200;
display: flex;
flex-direction: row;
padding: @sidebar_padding;
margin-left: calc(@sidebar_padding * 2);
height: 100dvh;
height: 100vh;
pointer-events: none;
&.hidden {
display: none;
@ -25,63 +20,187 @@
.drawers-mask {
position: absolute;
top: 0;
left: 0;
z-index: 1100;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(4px);
cursor: pointer;
}
.drawer {
position: relative;
z-index: 1300;
top: 0;
left: 0;
bottom: 0;
display: flex;
flex-direction: column;
pointer-events: auto;
width: fit-content;
min-width: 320px;
max-width: 90vw;
height: 100%;
padding: 20px;
background-color: var(--background-color-accent);
border-radius: @sidebar_borderRadius;
box-shadow: @card-shadow;
border: 1px solid var(--sidebar-background-color);
overflow: hidden;
&.drawer-left {
left: 0;
margin-right: 20px;
}
&.drawer-right {
right: 0;
margin-left: 20px;
}
.drawer-header {
flex-shrink: 0;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
background-color: var(--background-color);
.drawer-header-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
.drawer-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-color);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawer-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.drawer-custom-actions {
display: flex;
align-items: center;
gap: 8px;
}
.drawer-close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background-color: transparent;
color: var(--text-color);
font-size: 24px;
line-height: 1;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
&:active {
transform: scale(0.95);
}
}
}
}
}
.drawer-content {
flex: 1;
padding: 20px;
overflow-x: hidden;
overflow-y: overlay;
overflow-y: auto;
min-height: 0;
color: var(--text-color);
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-color);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover-color);
}
}
}
// Responsive design
@media (max-width: 768px) {
.drawers-wrapper {
margin-left: 0;
padding: 10px;
}
.drawer {
min-width: 280px;
max-width: calc(100vw - 20px);
.drawer-header {
padding: 12px 16px;
.drawer-header-content {
.drawer-title {
font-size: 16px;
}
.drawer-header-actions {
.drawer-close-button {
width: 28px;
height: 28px;
font-size: 20px;
}
}
}
}
.drawer-content {
padding: 16px;
}
}
}
// Confirm dialog styles
.drawer_close_confirm {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
.drawer_close_confirm_content {
display: flex;
flex-direction: column;
gap: 2px;
}
.drawer_close_confirm_actions {
display: flex;
flex-direction: row;
gap: 10px;
}
}

View File

@ -5,7 +5,7 @@ import useLayoutInterface from "@hooks/useLayoutInterface"
import "./index.less"
export default (props) => {
const HeaderBar = (props) => {
const [render, setRender] = React.useState(null)
useLayoutInterface("header", {
@ -33,15 +33,23 @@ export default (props) => {
<AnimatePresence>
{render && (
<motion.div
layoutRoot
className="page_header_wrapper"
animate={{
y: 0,
}}
initial={{
y: -100,
width: "100%",
}}
animate={{
y: 0,
position: "sticky",
width: "100%",
}}
exit={{
y: -100,
position: "absolute",
width: "100%",
top: 0,
left: 0,
}}
transition={{
type: "spring",
@ -61,3 +69,5 @@ export default (props) => {
</AnimatePresence>
)
}
export default HeaderBar

View File

@ -17,27 +17,27 @@ import TopBar from "@layouts/components/@mobile/topBar"
import BackgroundDecorator from "@components/BackgroundDecorator"
const DesktopLayout = (props) => {
return <>
return (
<>
<BackgroundDecorator />
<Modals />
<DraggableDrawerController />
<Layout id="app_layout" className="app_layout">
<Sidebar />
<Sidebar user={props.user} />
<Layout.Content
id="content_layout"
className={classnames(
...props.contentClassnames ?? [],
...(props.contentClassnames ?? []),
"content_layout",
"fade-transverse-active",
)}
>
<Header />
{
props.children && React.cloneElement(props.children, props)
}
{props.children &&
React.cloneElement(props.children, props)}
</Layout.Content>
<ToolsBar />
@ -45,30 +45,35 @@ const DesktopLayout = (props) => {
<BetaBanner />
</>
)
}
const MobileLayout = (props) => {
return <Layout id="app_layout" className="app_layout">
return (
<Layout id="app_layout" className="app_layout">
<DraggableDrawerController />
<TopBar />
<Layout.Content
id="content_layout"
className={classnames(
...props.layoutPageModesClassnames ?? [],
...(props.layoutPageModesClassnames ?? []),
"content_layout",
"fade-transverse-active",
)}
>
{
props.children && React.cloneElement(props.children, props)
}
{props.children && React.cloneElement(props.children, props)}
</Layout.Content>
<BottomBar />
</Layout>
)
}
export default (props) => {
return window.app.isMobile ? <MobileLayout {...props} /> : <DesktopLayout {...props} />
return window.app.isMobile ? (
<MobileLayout {...props} />
) : (
<DesktopLayout {...props} />
)
}

View File

@ -1,92 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import Hls from "hls.js"
const exampleData = {
video: "https://im-fa.manifest.tidal.com/1/manifests/CAESCTE5Njg2MTQ0NCIWd05QUkh1YTIyOGRXTUVUdmFxbThQdyIWZE05ZHNYTFNkTEhaODdmTUxQMDhGQSIWS0dfYTZubHUtcTUydVZMenRyOTJwQSIWLWU1NHRpanJlNzZhSjdMcXVoQ05idyIWenRCWnZEYmpia1hvNS14UUowWFl1USIWdFRHY20ycFNpVTktaHBtVDlzUlNvdyIWdVJDMlNqMFJQYWVMSnN6NWRhRXZtdyIWZnNYUWZpNk01LUdpeUV3dE9JNTZ2dygBMAJQAQ.m3u8?token=1738270941~MjEyMTc0MTk0NTlmNjdiY2RkNjljYzc0NzU1NGRmZDcxMGJhNDI2Mg==",
audio: "https://sp-pr-fa.audio.tidal.com/mediatracks/CAEaKwgDEidmMmE5YjEyYTQ5ZTQ4YWFkZDdhOTY0YzBmZTdhZTY1ZV82MS5tcDQ/0.flac?token=1738270937~Y2ViYjZiNmYyZmVjN2JhNmYzN2ViMWEzOTcwNzQ3NDdkNzA5YzhhZg=="
}
function AudioSyncApp() {
const videoRef = useRef(null);
const audioRef = useRef(null);
const [worker, setWorker] = useState(null);
const [startTime, setStartTime] = useState(null);
const audioCtxRef = useRef(null);
const hlsRef = useRef(null);
// Configurar HLS para el video
useEffect(() => {
if (Hls.isSupported()) {
const hls = new Hls({ enableWorker: false, xhrSetup: (xhr) => xhr.withCredentials = false });
hlsRef.current = hls;
hls.loadSource(exampleData.video);
hls.attachMedia(videoRef.current);
} else if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) {
videoRef.current.src = exampleData.video;
}
return () => {
if (hlsRef.current) hlsRef.current.destroy();
};
}, []);
// Inicializar Web Audio y Worker
useEffect(() => {
audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)();
const newWorker = new Worker(new URL("./worker.js", import.meta.url));
newWorker.onmessage = (event) => {
setStartTime(event.data.offset);
};
setWorker(newWorker);
return () => newWorker.terminate();
}, []);
// Manejar la sincronización
const handleSync = async () => {
try {
// 1. Obtener buffers de audio
const [videoBuffer, audioBuffer] = await Promise.all([
fetch(exampleData.video, { mode: "cors" }).then(r => r.arrayBuffer()),
fetch(exampleData.audio, { mode: "cors" }).then(r => r.arrayBuffer())
]);
// 2. Decodificar
const [videoAudio, songAudio] = await Promise.all([
audioCtxRef.current.decodeAudioData(videoBuffer),
audioCtxRef.current.decodeAudioData(audioBuffer)
]);
// 3. Enviar al Worker
worker.postMessage(
{ videoBuffer: videoAudio, audioBuffer: songAudio },
[videoAudio, songAudio]
);
} catch (error) {
console.error("Error de decodificación:", error);
}
};
return (
<div>
<video
ref={videoRef}
controls
crossOrigin="anonymous"
width="600"
/>
<audio
ref={audioRef}
controls
crossOrigin="anonymous"
src={exampleData.audio}
/>
<button onClick={handleSync}>Sincronizar</button>
{startTime !== null && (
<p>Offset de sincronización: {startTime.toFixed(2)} segundos</p>
)}
</div>
);
}
export default AudioSyncApp;

View File

@ -1,70 +0,0 @@
self.onmessage = async (event) => {
const { videoBuffer, audioBuffer } = event.data;
const SAMPLE_RATE = 44100;
// Extraer energía en rango de frecuencias
const getEnergy = (buffer, freqRange) => {
const offlineCtx = new OfflineAudioContext(1, buffer.length, SAMPLE_RATE);
const source = offlineCtx.createBufferSource();
source.buffer = buffer;
const analyser = offlineCtx.createAnalyser();
analyser.fftSize = 4096;
source.connect(analyser);
analyser.connect(offlineCtx.destination);
source.start();
return offlineCtx.startRendering().then(() => {
const data = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(data);
const startBin = Math.floor(freqRange[0] * analyser.fftSize / SAMPLE_RATE);
const endBin = Math.floor(freqRange[1] * analyser.fftSize / SAMPLE_RATE);
return data.slice(startBin, endBin);
});
};
// Cross-correlación optimizada
const crossCorrelate = (videoFeatures, audioFeatures) => {
let maxCorr = -Infinity;
let bestOffset = 0;
for (let i = 0; i < videoFeatures.length - audioFeatures.length; i++) {
let corr = 0;
for (let j = 0; j < audioFeatures.length; j++) {
corr += videoFeatures[i + j] * audioFeatures[j];
}
if (corr > maxCorr) {
maxCorr = corr;
bestOffset = i;
}
}
return bestOffset;
};
// Procesar características
try {
const [videoBass, audioBass] = await Promise.all([
getEnergy(videoBuffer, [60, 250]), // Bajos
getEnergy(audioBuffer, [60, 250])
]);
const [videoVoice, audioVoice] = await Promise.all([
getEnergy(videoBuffer, [300, 3400]), // Voces
getEnergy(audioBuffer, [300, 3400])
]);
// Combinar características (peso dinámico)
const isElectronic = audioVoice.reduce((a, b) => a + b) < audioBass.reduce((a, b) => a + b);
const weight = isElectronic ? 0.8 : 0.4;
const videoFeatures = videoBass.map((v, i) => weight * v + (1 - weight) * videoVoice[i]);
const audioFeatures = audioBass.map((v, i) => weight * v + (1 - weight) * audioVoice[i]);
// Calcular offset
const offset = crossCorrelate(videoFeatures, audioFeatures);
self.postMessage({ offset: offset / SAMPLE_RATE });
} catch (error) {
self.postMessage({ error: "Error en el procesamiento" });
}
};

View File

@ -1,47 +0,0 @@
import TrackManifest from "@cores/player/classes/TrackManifest"
const D_Manifest = () => {
const [manifest, setManifest] = React.useState(null)
function selectLocalFile() {
const input = document.createElement("input")
input.type = "file"
input.accept = "audio/*"
input.onchange = (e) => {
loadManifest(e.target.files[0])
}
input.click()
}
async function loadManifest(file) {
let track = new TrackManifest({ file: file })
await track.initialize()
console.log(track)
setManifest(track)
}
return (
<div className="flex-column gap-10">
<p>Select a local file to view & create a track manifest</p>
<button onClick={selectLocalFile}>Select</button>
{manifest?.cover && (
<img
src={manifest.cover}
alt="Cover"
style={{ width: "100px", height: "100px" }}
/>
)}
<code style={{ whiteSpace: "break-spaces", width: "300px" }}>
{JSON.stringify(manifest)}
</code>
</div>
)
}
export default D_Manifest

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