mirror of
https://github.com/ragestudio/comty.git
synced 2025-07-09 01:04:16 +00:00
Merge pull request #158 from ragestudio/dev
This commit is contained in:
commit
dedf1856a9
5
.gitignore
vendored
5
.gitignore
vendored
@ -43,4 +43,7 @@
|
|||||||
/**/**/.aliaser
|
/**/**/.aliaser
|
||||||
|
|
||||||
# .vscode folder
|
# .vscode folder
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# zed folder
|
||||||
|
.zed/
|
2
comty.js
2
comty.js
@ -1 +1 @@
|
|||||||
Subproject commit cbb45df2ef42205022e38e4c7d33a001e162c383
|
Subproject commit 0dd24fee4a231dbb41d5b14ff92b67d8f14cb5a2
|
@ -1 +1 @@
|
|||||||
Subproject commit fa61273d5b4b40a22d97c7773321d8ca6c985fd7
|
Subproject commit 1c4291928a3286a6c1d5ed0a4c3f7ac3eb87d45d
|
1
packages/app/.gitignore
vendored
1
packages/app/.gitignore
vendored
@ -8,5 +8,6 @@ yarn-error.log
|
|||||||
out/
|
out/
|
||||||
.ssl
|
.ssl
|
||||||
|
|
||||||
|
src/pages/_debug
|
||||||
public/oss-licenses.json
|
public/oss-licenses.json
|
||||||
/**/**/src/cores/@*
|
/**/**/src/cores/@*
|
||||||
|
52
packages/app/config/excuses.json
Normal file
52
packages/app/config/excuses.json
Normal 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."
|
||||||
|
]
|
@ -1,611 +1,8 @@
|
|||||||
{
|
{
|
||||||
"ab": "Abkhazian",
|
"en": "English",
|
||||||
"ace": "Achinese",
|
"es": "Español",
|
||||||
"ach": "Acoli",
|
"fr": "Français",
|
||||||
"ada": "Adangme",
|
"de": "Deutsch",
|
||||||
"ady": "Adyghe",
|
"it": "Italiano",
|
||||||
"aa": "Afar",
|
"pt": "Português"
|
||||||
"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"
|
|
||||||
}
|
|
||||||
|
6
packages/app/config/randomErrorImages.json
Normal file
6
packages/app/config/randomErrorImages.json
Normal 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"
|
||||||
|
]
|
@ -14,6 +14,11 @@ export default [
|
|||||||
useLayout: "default",
|
useLayout: "default",
|
||||||
public: true,
|
public: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tv/*",
|
||||||
|
useLayout: "default",
|
||||||
|
centeredContent: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/featured-event/*",
|
path: "/featured-event/*",
|
||||||
useLayout: "default",
|
useLayout: "default",
|
||||||
@ -31,7 +36,7 @@ export default [
|
|||||||
{
|
{
|
||||||
path: "/music/*",
|
path: "/music/*",
|
||||||
useLayout: "default",
|
useLayout: "default",
|
||||||
centeredContent: true,
|
centeredContent: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/nfc/*",
|
path: "/nfc/*",
|
||||||
|
@ -14,4 +14,10 @@ export default defineConfig([
|
|||||||
languageOptions: { globals: globals.browser },
|
languageOptions: { globals: globals.browser },
|
||||||
},
|
},
|
||||||
pluginReact.configs.flat.recommended,
|
pluginReact.configs.flat.recommended,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"react/jsx-uses-react": "off",
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
@ -1,91 +1,91 @@
|
|||||||
{
|
{
|
||||||
"name": "@comty/app",
|
"name": "@comty/app",
|
||||||
"version": "1.43.0@alpha",
|
"version": "1.44.0@alpha",
|
||||||
"license": "ComtyLicense",
|
"license": "ComtyLicense",
|
||||||
"main": "electron/main",
|
"main": "electron/main",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "RageStudio",
|
"author": "RageStudio",
|
||||||
"description": "A prototype of a social network.",
|
"description": "A prototype of a social network.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"release": "node ./scripts/release.js",
|
"release": "node ./scripts/release.js",
|
||||||
"postinstall": "./scripts/postinstall.sh",
|
"postinstall": "./scripts/postinstall.sh",
|
||||||
"eslint": "eslint"
|
"eslint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.4.0",
|
"@ant-design/icons": "^5.4.0",
|
||||||
"@dnd-kit/core": "^6.0.8",
|
"@dnd-kit/core": "^6.0.8",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^7.0.2",
|
"@dnd-kit/sortable": "^7.0.2",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.13.0",
|
"@emotion/react": "^11.13.0",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@ffmpeg/ffmpeg": "^0.12.10",
|
"@ffmpeg/ffmpeg": "^0.12.10",
|
||||||
"@ffmpeg/util": "^0.12.1",
|
"@ffmpeg/util": "^0.12.1",
|
||||||
"@mui/material": "^5.11.9",
|
"@mui/material": "^5.11.9",
|
||||||
"@ragestudio/cordova-nfc": "^1.2.0",
|
"@ragestudio/cordova-nfc": "^1.2.0",
|
||||||
"@ragestudio/vessel": "^0.20.0",
|
"@ragestudio/vessel": "^0.20.0",
|
||||||
"@sentry/browser": "^7.64.0",
|
"@sentry/browser": "^7.64.0",
|
||||||
"@tauri-apps/api": "^1.5.4",
|
"@tauri-apps/api": "^1.5.4",
|
||||||
"@tsmx/human-readable": "^1.0.7",
|
"@tsmx/human-readable": "^1.0.7",
|
||||||
"antd": "^5.20.6",
|
"antd": "^5.20.6",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bear-react-carousel": "^4.0.10-alpha.0",
|
"bear-react-carousel": "^4.0.10-alpha.0",
|
||||||
"classnames": "2.3.1",
|
"classnames": "2.3.1",
|
||||||
"comty.js": "^0.67.0",
|
"comty.js": "^0.68.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"dompurify": "^3.0.0",
|
"dashjs": "^5.0.3",
|
||||||
"fast-average-color": "^9.2.0",
|
"dompurify": "^3.0.0",
|
||||||
"fuse.js": "6.5.3",
|
"fast-average-color": "^9.2.0",
|
||||||
"hls.js": "^1.5.17",
|
"fuse.js": "6.5.3",
|
||||||
"howler": "2.2.3",
|
"hls.js": "^1.5.17",
|
||||||
"i18next": "21.6.6",
|
"howler": "2.2.3",
|
||||||
"js-cookie": "3.0.1",
|
"i18next": "21.6.6",
|
||||||
"jsmediatags": "^3.9.7",
|
"js-cookie": "3.0.1",
|
||||||
"lottie-react": "^2.4.0",
|
"jsmediatags": "^3.9.7",
|
||||||
"luxon": "^3.0.4",
|
"lottie-react": "^2.4.0",
|
||||||
"mime": "^3.0.0",
|
"luxon": "^3.0.4",
|
||||||
"moment": "2.29.4",
|
"mime": "^3.0.0",
|
||||||
"motion": "^12.4.2",
|
"moment": "2.29.4",
|
||||||
"music-metadata": "^11.2.1",
|
"motion": "^12.4.2",
|
||||||
"plyr": "^3.7.8",
|
"music-metadata": "^11.2.1",
|
||||||
"prop-types": "^15.8.1",
|
"plyr": "^3.7.8",
|
||||||
"qs": "^6.14.0",
|
"prop-types": "^15.8.1",
|
||||||
"react": "18.3.1",
|
"qs": "^6.14.0",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react": "18.3.1",
|
||||||
"react-color": "2.19.3",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-countup": "^6.4.1",
|
"react-color": "2.19.3",
|
||||||
"react-dom": "18.3.1",
|
"react-countup": "^6.4.1",
|
||||||
"react-fast-marquee": "^1.3.5",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "11.15.3",
|
"react-fast-marquee": "^1.3.5",
|
||||||
"react-icons": "^5.4.0",
|
"react-i18next": "11.15.3",
|
||||||
"react-lazy-load-image-component": "^1.5.4",
|
"react-icons": "^5.4.0",
|
||||||
"react-markdown": "^8.0.3",
|
"react-lazy-load-image-component": "^1.5.4",
|
||||||
"react-modal-image": "^2.6.0",
|
"react-markdown": "^8.0.3",
|
||||||
"react-player": "^2.16.0",
|
"react-modal-image": "^2.6.0",
|
||||||
"react-rnd": "^10.4.14",
|
"react-player": "^2.16.0",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-rnd": "^10.4.14",
|
||||||
"react-transition-group": "^4.4.5",
|
"react-router": "^7.6.2",
|
||||||
"react-useanimations": "^2.10.0",
|
"react-transition-group": "^4.4.5",
|
||||||
"remark-gfm": "^3.0.1",
|
"react-useanimations": "^2.10.0",
|
||||||
"rxjs": "^7.5.5",
|
"remark-gfm": "^3.0.1",
|
||||||
"shaka-player": "^4.14.12",
|
"rxjs": "^7.5.5",
|
||||||
"store": "^2.0.12",
|
"store": "^2.0.12",
|
||||||
"swapy": "^1.0.5",
|
"swapy": "^1.0.5",
|
||||||
"ua-parser-js": "^1.0.36",
|
"ua-parser-js": "^1.0.36",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"vite": "^6.2.6"
|
"vite": "^6.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.26.0",
|
"@eslint/js": "^9.26.0",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
"7zip-min": "1.4.3",
|
"7zip-min": "1.4.3",
|
||||||
"dotenv": "16.0.3",
|
"dotenv": "16.0.3",
|
||||||
"eslint": "^9.26.0",
|
"eslint": "^9.26.0",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.0",
|
||||||
"globals": "^16.1.0"
|
"globals": "^16.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { Runtime } from "@ragestudio/vessel"
|
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 { Helmet } from "react-helmet"
|
||||||
import * as Sentry from "@sentry/browser"
|
import * as Sentry from "@sentry/browser"
|
||||||
import { invoke } from "@tauri-apps/api/tauri"
|
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 { ThemeProvider } from "@cores/style/style.core.jsx"
|
||||||
|
|
||||||
import Layout from "./layout"
|
import Layout from "./layout"
|
||||||
import * as Router from "./router"
|
|
||||||
|
|
||||||
import StaticMethods from "./statics/methods"
|
import StaticMethods from "./statics/methods"
|
||||||
import StaticEvents from "./statics/events"
|
import StaticEvents from "./statics/events"
|
||||||
@ -117,19 +121,19 @@ class ComtyApp extends React.Component {
|
|||||||
/>
|
/>
|
||||||
<meta property="og:title" content={config.app.siteName} />
|
<meta property="og:title" content={config.app.siteName} />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<Router.InternalRouter>
|
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{window.__TAURI__ && <DesktopTopBar />}
|
{window.__TAURI__ && <DesktopTopBar />}
|
||||||
<Layout
|
<Layout staticRenders={ComtyApp.staticRenders}>
|
||||||
user={this.auth.user}
|
{this.state.firstInitialized && (
|
||||||
staticRenders={ComtyApp.staticRenders}
|
<Router.Render
|
||||||
>
|
declarations={routesDeclarations}
|
||||||
{this.state.firstInitialized && (
|
staticRenders={ComtyApp.staticRenders}
|
||||||
<Router.PageRender />
|
onPageMount={onPageMount}
|
||||||
)}
|
/>
|
||||||
</Layout>
|
)}
|
||||||
</ThemeProvider>
|
</Layout>
|
||||||
</Router.InternalRouter>
|
</ThemeProvider>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -70,6 +70,11 @@ export default class AuthManager {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleUserDataUpdate = (data) => {
|
||||||
|
this.state.user = data
|
||||||
|
app.eventBus.emit("self:user:update", data)
|
||||||
|
}
|
||||||
|
|
||||||
initialize = async () => {
|
initialize = async () => {
|
||||||
const token = await SessionModel.token
|
const token = await SessionModel.token
|
||||||
|
|
||||||
@ -87,6 +92,8 @@ export default class AuthManager {
|
|||||||
|
|
||||||
app.userData = user
|
app.userData = user
|
||||||
this.state.user = user
|
this.state.user = user
|
||||||
|
|
||||||
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
flush = async () => {
|
flush = async () => {
|
||||||
|
154
packages/app/src/components/ErrorCatcher/index.jsx
Normal file
154
packages/app/src/components/ErrorCatcher/index.jsx
Normal 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
|
@ -1,26 +1,28 @@
|
|||||||
import React from "react"
|
|
||||||
import { Button } from "antd"
|
import { Button } from "antd"
|
||||||
import classnames from "classnames"
|
import classnames from "classnames"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export default (props) => {
|
const FollowButton = (props) => {
|
||||||
return <div className="followButton">
|
return (
|
||||||
<div className="counter">
|
<div className="followButton">
|
||||||
{props.count}
|
<div className="counter">
|
||||||
{props.self && " Followers"}
|
{props.count}
|
||||||
</div>
|
{props.self && " Followers"}
|
||||||
{
|
</div>
|
||||||
!props.self && <Button
|
{!props.self && (
|
||||||
type="ghost"
|
<Button
|
||||||
onClick={props.onClick}
|
type="ghost"
|
||||||
className={classnames(
|
onClick={props.onClick}
|
||||||
"btn",
|
className={classnames("btn", {
|
||||||
{ ["followed"]: props.followed }
|
["followed"]: props.followed,
|
||||||
)}
|
})}
|
||||||
>
|
>
|
||||||
<span>{props.followed ? "Following" : "Follow"}</span>
|
<span>{props.followed ? "Following" : "Follow"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default FollowButton
|
||||||
|
@ -1,101 +1,98 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import * as antd from "antd"
|
import * as antd from "antd"
|
||||||
import { Icons } from "@components/Icons"
|
import { Icons } from "@components/Icons"
|
||||||
|
import LoadMore from "@components/LoadMore"
|
||||||
|
import UserPreview from "@components/UserPreview"
|
||||||
|
|
||||||
import FollowsModel from "@models/follows"
|
import FollowsModel from "@models/follows"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const FollowerItem = ({
|
const FollowerItem = React.memo(({ data }) => {
|
||||||
follower,
|
return <UserPreview user={data} />
|
||||||
onClick,
|
})
|
||||||
index
|
|
||||||
}) => {
|
FollowerItem.displayName = "FollowerItem"
|
||||||
return <div
|
|
||||||
className="follower"
|
const FollowersList = (props) => {
|
||||||
onClick={onClick}
|
const [loading, setLoading] = React.useState(false)
|
||||||
key={index}
|
const [followers, setFollowers] = React.useState(props.followers ?? [])
|
||||||
>
|
const [hasMore, setHasMore] = React.useState(true)
|
||||||
<div className="avatar">
|
|
||||||
<antd.Avatar shape="square" src={follower.avatar} />
|
const page = React.useRef(0)
|
||||||
</div>
|
const userId = React.useRef(props.user_id)
|
||||||
<div className="names">
|
|
||||||
<div>
|
const loadFollowers = React.useCallback(async () => {
|
||||||
<h2>
|
setLoading(true)
|
||||||
{follower.fullName ?? follower.username}
|
|
||||||
</h2>
|
console.log(
|
||||||
</div>
|
`Loading Followers for [${userId.current}] page [${page.current}]`,
|
||||||
<div>
|
)
|
||||||
<span>
|
|
||||||
@{follower.username}
|
const followers = await FollowsModel.getFollowers(userId.current, {
|
||||||
</span>
|
fetchData: true,
|
||||||
</div>
|
limit: 10,
|
||||||
</div>
|
page: page.current,
|
||||||
</div>
|
}).catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
app.message.error("Failed to fetch followers")
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (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 && 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 (
|
||||||
|
<LoadMore
|
||||||
|
className="followersList"
|
||||||
|
onBottom={onLoadMore}
|
||||||
|
hasMore={hasMore}
|
||||||
|
>
|
||||||
|
{followers.map((data) => {
|
||||||
|
return <FollowerItem key={data._id} data={data} />
|
||||||
|
})}
|
||||||
|
</LoadMore>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (props) => {
|
export default FollowersList
|
||||||
const [loading, setLoading] = React.useState(false)
|
|
||||||
const [followers, setFollowers] = React.useState(props.followers ?? [])
|
|
||||||
|
|
||||||
const goToProfile = (username) => {
|
|
||||||
app.navigation.goToAccount(username)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadFollowers = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
console.log(`Loading Followers for [${props.user_id}]...`)
|
|
||||||
|
|
||||||
const followers = await FollowsModel.getFollowers(props.user_id, true).catch((err) => {
|
|
||||||
console.error(err)
|
|
||||||
app.message.error("Failed to fetch followers")
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
setLoading(false)
|
|
||||||
|
|
||||||
if (followers) {
|
|
||||||
console.log(`Loaded Followers: [${followers.length}] >`, followers)
|
|
||||||
setFollowers(followers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!props.followers) {
|
|
||||||
if (props.user_id) {
|
|
||||||
loadFollowers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
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>
|
|
||||||
</antd.Result>
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="followersList">
|
|
||||||
{
|
|
||||||
followers.map((follower, index) => {
|
|
||||||
return <FollowerItem
|
|
||||||
index={index}
|
|
||||||
follower={follower}
|
|
||||||
onClick={() => goToProfile(follower.username)}
|
|
||||||
/>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
@ -33,37 +33,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.followersList {
|
.followersList {
|
||||||
.follower {
|
display: flex;
|
||||||
display: inline-flex;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
width: 100%;
|
gap: 10px;
|
||||||
|
|
||||||
margin-bottom: 10px;
|
.userPreview {
|
||||||
padding: 10px;
|
background-color: var(--background-color-primary);
|
||||||
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
|
|
||||||
cursor: pointer;
|
padding: 5px 10px;
|
||||||
|
|
||||||
h2 {
|
width: 100%;
|
||||||
margin: 0;
|
|
||||||
font-size: 22px;
|
|
||||||
line-height: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
>div {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.names {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.follower:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -3,4 +3,4 @@ import { LazyLoadImage } from "react-lazy-load-image-component"
|
|||||||
|
|
||||||
import "react-lazy-load-image-component/src/effects/blur.css"
|
import "react-lazy-load-image-component/src/effects/blur.css"
|
||||||
|
|
||||||
export default (props) => <LazyLoadImage {...props} effect="blur" />
|
export default (props) => <LazyLoadImage {...props} effect="blur" />
|
||||||
|
@ -102,6 +102,9 @@ export default class LiveChat extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
leaveSocketRoom = () => {
|
leaveSocketRoom = () => {
|
||||||
|
if (!this.socket) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (this.state.connectionEnd) {
|
if (this.state.connectionEnd) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -3,59 +3,65 @@ import classnames from "classnames"
|
|||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export default React.forwardRef((props, ref) => {
|
const LoadMore = React.forwardRef((props, ref) => {
|
||||||
const {
|
const {
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
hasMore,
|
hasMore = false,
|
||||||
loadingComponent,
|
loadingComponent,
|
||||||
noResultComponent,
|
contentProps = {},
|
||||||
contentProps = {},
|
} = props
|
||||||
} = props
|
|
||||||
|
|
||||||
let observer = null
|
const nodeRef = React.useRef(null)
|
||||||
|
|
||||||
const insideViewportCb = (entries) => {
|
let observer = null
|
||||||
const { fetching, onBottom } = props
|
|
||||||
|
|
||||||
entries.forEach(element => {
|
const insideViewportCb = (entries) => {
|
||||||
if (element.intersectionRatio > 0 && !fetching) {
|
const { fetching, onBottom, hasMore } = props
|
||||||
onBottom()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
if (!hasMore) {
|
||||||
try {
|
return false
|
||||||
const node = document.getElementById("bottom")
|
}
|
||||||
|
|
||||||
observer = new IntersectionObserver(insideViewportCb)
|
entries.forEach((element) => {
|
||||||
observer.observe(node)
|
if (element.intersectionRatio > 0 && !fetching) {
|
||||||
} catch (err) {
|
onBottom()
|
||||||
console.log("err in finding node", err)
|
}
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
React.useEffect(() => {
|
||||||
observer.disconnect()
|
try {
|
||||||
observer = null
|
observer = new IntersectionObserver(insideViewportCb)
|
||||||
}
|
observer.observe(nodeRef.current)
|
||||||
}, [])
|
} catch (err) {
|
||||||
|
console.log("err in finding node", err)
|
||||||
|
}
|
||||||
|
|
||||||
return <div
|
return () => {
|
||||||
ref={ref}
|
observer.disconnect()
|
||||||
className={classnames(className)}
|
observer = null
|
||||||
{...contentProps}
|
}
|
||||||
>
|
}, [])
|
||||||
{children}
|
|
||||||
|
|
||||||
<div style={{ clear: "both" }} />
|
return (
|
||||||
|
<div ref={ref} className={classnames(className)} {...contentProps}>
|
||||||
|
{children}
|
||||||
|
|
||||||
<div
|
{/* <div style={{ clear: "both" }} /> */}
|
||||||
id="bottom"
|
|
||||||
className="bottom"
|
<div
|
||||||
style={{ display: hasMore ? "block" : "none" }}
|
ref={nodeRef}
|
||||||
>
|
id="bottom"
|
||||||
{loadingComponent && React.createElement(loadingComponent)}
|
className="bottom"
|
||||||
</div>
|
style={{ display: hasMore ? "block" : "none" }}
|
||||||
</div>
|
>
|
||||||
})
|
{loadingComponent && React.createElement(loadingComponent)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
LoadMore.displayName = "LoadMore"
|
||||||
|
|
||||||
|
export default LoadMore
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import React from "react"
|
|
||||||
import * as antd from "antd"
|
import * as antd from "antd"
|
||||||
import classnames from "classnames"
|
import classnames from "classnames"
|
||||||
|
|
||||||
import { WithPlayerContext } from "@contexts/WithPlayerContext"
|
import {
|
||||||
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
|
WithPlayerContext,
|
||||||
|
usePlayerStateContext,
|
||||||
|
} from "@contexts/WithPlayerContext"
|
||||||
|
|
||||||
import LoadMore from "@components/LoadMore"
|
import LoadMore from "@components/LoadMore"
|
||||||
import { Icons } from "@components/Icons"
|
import { Icons } from "@components/Icons"
|
||||||
@ -24,6 +25,8 @@ const TrackList = ({
|
|||||||
hasMore,
|
hasMore,
|
||||||
noHeader = false,
|
noHeader = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [{ track_manifest, playback_status }] = usePlayerStateContext()
|
||||||
|
|
||||||
const showListHeader = !noHeader && (tracks.length > 0 || searchResults)
|
const showListHeader = !noHeader && (tracks.length > 0 || searchResults)
|
||||||
|
|
||||||
if (!searchResults && tracks.length === 0) {
|
if (!searchResults && tracks.length === 0) {
|
||||||
@ -62,7 +65,7 @@ const TrackList = ({
|
|||||||
key={item._id}
|
key={item._id}
|
||||||
order={item._id} // Consider using index if order matters
|
order={item._id} // Consider using index if order matters
|
||||||
track={item}
|
track={item}
|
||||||
onPlay={() => onTrackClick(item)}
|
onPlay={onTrackClick}
|
||||||
changeState={(update) =>
|
changeState={(update) =>
|
||||||
onTrackStateChange(item._id, update)
|
onTrackStateChange(item._id, update)
|
||||||
}
|
}
|
||||||
@ -76,19 +79,19 @@ const TrackList = ({
|
|||||||
onBottom={onLoadMore}
|
onBottom={onLoadMore}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
>
|
>
|
||||||
<WithPlayerContext>
|
{tracks.map((item, index) => (
|
||||||
{tracks.map((item, index) => (
|
<MusicTrack
|
||||||
<MusicTrack
|
key={item._id}
|
||||||
key={item._id} // Use unique ID for key
|
order={index + 1}
|
||||||
order={index + 1}
|
track={item}
|
||||||
track={item}
|
onPlay={onTrackClick}
|
||||||
onPlay={() => onTrackClick(item)}
|
isCurrent={item._id === track_manifest?._id}
|
||||||
changeState={(update) =>
|
isPlaying={
|
||||||
onTrackStateChange(item._id, update)
|
item._id === track_manifest?._id &&
|
||||||
}
|
playback_status === "playing"
|
||||||
/>
|
}
|
||||||
))}
|
/>
|
||||||
</WithPlayerContext>
|
))}
|
||||||
</LoadMore>
|
</LoadMore>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,140 +2,37 @@ import React from "react"
|
|||||||
import * as antd from "antd"
|
import * as antd from "antd"
|
||||||
import classnames from "classnames"
|
import classnames from "classnames"
|
||||||
|
|
||||||
import RGBStringToValues from "@utils/rgbToValues"
|
|
||||||
|
|
||||||
import ImageViewer from "@components/ImageViewer"
|
import ImageViewer from "@components/ImageViewer"
|
||||||
import { Icons } from "@components/Icons"
|
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 { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const handlers = {
|
function secondsToIsoTime(seconds) {
|
||||||
like: async (ctx, track) => {
|
const minutes = Math.floor(seconds / 60)
|
||||||
await MusicModel.toggleItemFavourite("track", track._id, true)
|
|
||||||
|
|
||||||
ctx.changeState({
|
return `${minutes}:${Math.floor(seconds % 60)
|
||||||
liked: true,
|
.toString()
|
||||||
})
|
.padStart(2, "0")}`
|
||||||
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 })
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Track = (props) => {
|
const Track = React.memo((props) => {
|
||||||
const [{ loading, track_manifest, playback_status }] =
|
|
||||||
usePlayerStateContext()
|
|
||||||
|
|
||||||
const playlist_ctx = React.useContext(PlaylistContext)
|
const playlist_ctx = React.useContext(PlaylistContext)
|
||||||
|
|
||||||
const [moreMenuOpened, setMoreMenuOpened] = React.useState(false)
|
const [moreMenuOpened, setMoreMenuOpened] = React.useState(false)
|
||||||
|
const [liked, setLiked] = React.useState(props.track.liked)
|
||||||
|
|
||||||
const isCurrent = track_manifest?._id === props.track._id
|
const trackDuration = React.useMemo(() => {
|
||||||
const isPlaying = isCurrent && playback_status === "playing"
|
return props.track?.metadata?.duration ?? props.track?.duration
|
||||||
|
}, [props.track])
|
||||||
|
|
||||||
const handleClickPlayBtn = React.useCallback(() => {
|
const menuItems = React.useMemo(() => {
|
||||||
if (typeof props.onPlay === "function") {
|
const items = [...MenuItemsBase]
|
||||||
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",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (props.track.liked) {
|
if (props.track.liked) {
|
||||||
items[0] = {
|
items[0] = {
|
||||||
@ -162,19 +59,68 @@ const Track = (props) => {
|
|||||||
return items
|
return items
|
||||||
}, [props.track])
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
id={props.track._id}
|
id={props.track._id}
|
||||||
className={classnames("music-track", {
|
className={classnames("music-track", {
|
||||||
["current"]: isCurrent,
|
["current"]: props.isCurrent,
|
||||||
["playing"]: isPlaying,
|
["playing"]: props.isPlaying,
|
||||||
["loading"]: isCurrent && loading,
|
|
||||||
})}
|
})}
|
||||||
style={{
|
|
||||||
"--cover_average-color": RGBStringToValues(
|
|
||||||
track_manifest?.cover_analysis?.rgb,
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="music-track_background" />
|
<div className="music-track_background" />
|
||||||
|
|
||||||
@ -193,7 +139,7 @@ const Track = (props) => {
|
|||||||
type="primary"
|
type="primary"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
icon={
|
icon={
|
||||||
isPlaying ? (
|
props.isPlaying ? (
|
||||||
<Icons.MdPause />
|
<Icons.MdPause />
|
||||||
) : (
|
) : (
|
||||||
<Icons.MdPlayArrow />
|
<Icons.MdPlayArrow />
|
||||||
@ -214,13 +160,19 @@ const Track = (props) => {
|
|||||||
className="music-track_details"
|
className="music-track_details"
|
||||||
onClick={handleOnClickItem}
|
onClick={handleOnClickItem}
|
||||||
>
|
>
|
||||||
<div className="music-track_title">
|
<div className="music-track_titles">
|
||||||
<span>
|
<span className="music-track_title">
|
||||||
{props.track.service === "tidal" && (
|
{props.track.service === "tidal" && (
|
||||||
<Icons.SiTidal />
|
<Icons.SiTidal />
|
||||||
)}
|
)}
|
||||||
{props.track.title}
|
{props.track.title}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{props.track.version && (
|
||||||
|
<span className="music-track_version">
|
||||||
|
({props.track.version})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="music-track_artist">
|
<div className="music-track_artist">
|
||||||
<span>
|
<span>
|
||||||
@ -233,24 +185,31 @@ const Track = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="music-track_actions">
|
<div className="music-track_actions">
|
||||||
|
{trackDuration && (
|
||||||
|
<div className="music-track_play_duration">
|
||||||
|
<Icons.FiClock />
|
||||||
|
{secondsToIsoTime(trackDuration)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<antd.Dropdown
|
<antd.Dropdown
|
||||||
menu={{
|
menu={{
|
||||||
items: moreMenuItems,
|
items: menuItems,
|
||||||
onClick: handleMoreMenuItemClick,
|
onClick: handleMoreMenuItemClick,
|
||||||
}}
|
}}
|
||||||
onOpenChange={handleMoreMenuOpen}
|
onOpenChange={handleMoreMenuOpen}
|
||||||
open={moreMenuOpened}
|
open={moreMenuOpened}
|
||||||
trigger={["click"]}
|
trigger={["click"]}
|
||||||
>
|
>
|
||||||
<antd.Button
|
<div className="music-track_more-menu">
|
||||||
type="ghost"
|
<Icons.IoMdMore />
|
||||||
size="large"
|
</div>
|
||||||
icon={<Icons.IoMdMore />}
|
|
||||||
/>
|
|
||||||
</antd.Dropdown>
|
</antd.Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
Track.displayName = "Track"
|
||||||
|
|
||||||
export default Track
|
export default Track
|
||||||
|
@ -49,6 +49,11 @@ html {
|
|||||||
.music-track_orderIndex {
|
.music-track_orderIndex {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.music-track_play_duration {
|
||||||
|
opacity: 1;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -113,6 +118,13 @@ html {
|
|||||||
.music-track_play {
|
.music-track_play {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
transition: all 150ms ease-in-out;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
@ -200,13 +212,31 @@ html {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
width: 100%;
|
width: 75%;
|
||||||
|
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
|
||||||
.music-track_title {
|
.music-track_titles {
|
||||||
font-size: 1rem;
|
display: flex;
|
||||||
//font-family: "Space Grotesk", sans-serif;
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
.music-track_title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-track_version {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-track_artist {
|
.music-track_artist {
|
||||||
@ -225,10 +255,27 @@ html {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
gap: 10px;
|
gap: 5px;
|
||||||
|
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
|
||||||
color: var(--text-color);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
31
packages/app/src/components/Music/Track/menuHandlers.js
Normal file
31
packages/app/src/components/Music/Track/menuHandlers.js
Normal 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)
|
||||||
|
},
|
||||||
|
}
|
42
packages/app/src/components/Music/Track/menuItems.jsx
Normal file
42
packages/app/src/components/Music/Track/menuItems.jsx
Normal 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",
|
||||||
|
},
|
||||||
|
]
|
@ -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
|
|
@ -1,11 +0,0 @@
|
|||||||
.lyrics-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 20px;
|
|
||||||
padding: 15px;
|
|
||||||
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
background-color: var(--background-color-accent);
|
|
||||||
}
|
|
@ -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
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||||||
.enhanced_lyrics_editor-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
@ -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
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
@ -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;
|
|
||||||
}
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -4,56 +4,56 @@ import CountUp from "react-countup"
|
|||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export default (props) => {
|
const LikeButtonAction = (props) => {
|
||||||
const [liked, setLiked] = React.useState(props.defaultLiked ?? false)
|
const [liked, setLiked] = React.useState(props.defaultLiked ?? false)
|
||||||
const [clicked, setCliked] = React.useState(false)
|
const [clicked, setCliked] = React.useState(false)
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
let to = !liked
|
let to = !liked
|
||||||
|
|
||||||
setCliked(to)
|
setCliked(to)
|
||||||
|
|
||||||
if (typeof props.onClick === "function") {
|
if (typeof props.onClick === "function") {
|
||||||
const result = await props.onClick(to)
|
const result = await props.onClick(to)
|
||||||
if (typeof result === "boolean") {
|
|
||||||
to = result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLiked(to)
|
if (typeof result === "boolean") {
|
||||||
}
|
to = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return <div
|
setLiked(to)
|
||||||
className={
|
}
|
||||||
classnames(
|
|
||||||
"like_btn_wrapper",
|
return (
|
||||||
{
|
<div
|
||||||
["liked"]: liked,
|
className={classnames("like_btn_wrapper", {
|
||||||
["clicked"]: clicked
|
["liked"]: liked,
|
||||||
}
|
["clicked"]: clicked,
|
||||||
)
|
})}
|
||||||
}
|
onClick={handleClick}
|
||||||
onClick={handleClick}
|
>
|
||||||
>
|
<button className="like_btn">
|
||||||
<button className="like_btn">
|
<div className="ripple"></div>
|
||||||
<div className="ripple"></div>
|
<svg
|
||||||
<svg
|
className="heart"
|
||||||
className="heart"
|
width="24"
|
||||||
width="24"
|
height="24"
|
||||||
height="24"
|
viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24"
|
>
|
||||||
>
|
<path d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"></path>
|
||||||
<path d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"></path>
|
</svg>
|
||||||
</svg>
|
</button>
|
||||||
</button>
|
<CountUp
|
||||||
<CountUp
|
start={props.count}
|
||||||
start={props.count}
|
separator="."
|
||||||
separator="."
|
end={props.count}
|
||||||
end={props.count}
|
startOnMount={false}
|
||||||
startOnMount={false}
|
duration={3}
|
||||||
duration={3}
|
className="count"
|
||||||
className="count"
|
useEasing={true}
|
||||||
useEasing={true}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default LikeButtonAction
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import * as antd from "antd"
|
import * as antd from "antd"
|
||||||
|
import lodash from "lodash"
|
||||||
import { AnimatePresence } from "motion/react"
|
import { AnimatePresence } from "motion/react"
|
||||||
import { Icons } from "@components/Icons"
|
import { Icons } from "@components/Icons"
|
||||||
|
|
||||||
@ -23,353 +24,8 @@ const LoadingComponent = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const NoResultComponent = () => {
|
const PostActions = {
|
||||||
return (
|
onClickLike: async (data) => {
|
||||||
<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) => {
|
|
||||||
let result = await PostModel.toggleLike({ post_id: data._id }).catch(
|
let result = await PostModel.toggleLike({ post_id: data._id }).catch(
|
||||||
() => {
|
() => {
|
||||||
antd.message.error("Failed to like post")
|
antd.message.error("Failed to like post")
|
||||||
@ -379,9 +35,8 @@ export class PostsListsComponent extends React.Component {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
},
|
||||||
|
onClickSave: async (data) => {
|
||||||
onSavePost = async (data) => {
|
|
||||||
let result = await PostModel.toggleSave({ post_id: data._id }).catch(
|
let result = await PostModel.toggleSave({ post_id: data._id }).catch(
|
||||||
() => {
|
() => {
|
||||||
antd.message.error("Failed to save post")
|
antd.message.error("Failed to save post")
|
||||||
@ -391,25 +46,8 @@ export class PostsListsComponent extends React.Component {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
},
|
||||||
|
onClickDelete: async (data) => {
|
||||||
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) => {
|
|
||||||
antd.Modal.confirm({
|
antd.Modal.confirm({
|
||||||
title: "Are you sure you want to delete this post?",
|
title: "Are you sure you want to delete this post?",
|
||||||
content: "This action is irreversible",
|
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) => {
|
const Entry = (props) => {
|
||||||
if (typeof this.props.onOpenPost === "function") {
|
const { data } = props
|
||||||
this.props.onOpenPost(to, data)
|
|
||||||
|
return (
|
||||||
|
<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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoadMore = async () => {
|
const onLoadMore = React.useCallback(() => {
|
||||||
if (typeof this.props.onLoadMore === "function") {
|
if (typeof props.onLoadMore === "function") {
|
||||||
return this.handleLoad(this.props.onLoadMore)
|
return handleLoad(props.onLoadMore)
|
||||||
} else if (this.props.loadFromModel) {
|
} else if (props.loadFromModel) {
|
||||||
return this.handleLoad(this.props.loadFromModel)
|
return handleLoad(props.loadFromModel)
|
||||||
}
|
}
|
||||||
}
|
}, [props])
|
||||||
|
|
||||||
render() {
|
React.useEffect(() => {
|
||||||
if (this.state.initialLoading) {
|
if (
|
||||||
return <antd.Skeleton active />
|
!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 (this.state.list.length === 0) {
|
if (typeof props.loadFromModel === "function") {
|
||||||
if (typeof this.props.emptyListRender === "function") {
|
handleLoad(props.loadFromModel)
|
||||||
return React.createElement(this.props.emptyListRender)
|
}
|
||||||
|
|
||||||
|
if (props.realtime) {
|
||||||
|
for (const [event, handler] of Object.entries(
|
||||||
|
timelineWsEvents.current,
|
||||||
|
)) {
|
||||||
|
app.cores.api.listenEvent(event, handler, "posts")
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
app.cores.api.joinTopic(
|
||||||
<div className="no_more_posts">
|
"posts",
|
||||||
<antd.Empty />
|
props.customTopic ?? "realtime:feed",
|
||||||
<h1>Whoa, nothing on here...</h1>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostListProps = {
|
return () => {
|
||||||
list: this.state.list,
|
if (props.realtime) {
|
||||||
|
for (const [event, handler] of Object.entries(
|
||||||
|
timelineWsEvents.current,
|
||||||
|
)) {
|
||||||
|
app.cores.api.unlistenEvent(event, handler, "posts")
|
||||||
|
}
|
||||||
|
|
||||||
disableReplyTag: this.props.disableReplyTag,
|
app.cores.api.leaveTopic(
|
||||||
disableHasReplies: this.props.disableHasReplies,
|
"posts",
|
||||||
|
props.customTopic ?? "realtime:feed",
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (app.isMobile) {
|
return (
|
||||||
return <PostList ref={this.listRef} {...PostListProps} />
|
<div className="post-list_wrapper">
|
||||||
}
|
<PostList
|
||||||
|
ref={listRef}
|
||||||
return (
|
list={list}
|
||||||
<div className="post-list_wrapper">
|
hasMore={hasMore}
|
||||||
<PostList ref={this.listRef} {...PostListProps} />
|
onLoadMore={onLoadMore}
|
||||||
</div>
|
/>
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.forwardRef((props, ref) => (
|
export default PostsListsComponent
|
||||||
<PostsListsComponent innerRef={ref} {...props} />
|
|
||||||
))
|
|
||||||
|
@ -10,256 +10,223 @@ import linksDecorators from "@config/linksDecorators"
|
|||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
function processValue(value, decorator) {
|
function processValue(value, decorator) {
|
||||||
if (decorator.hrefResolve) {
|
if (decorator.hrefResolve) {
|
||||||
if (!String(value).includes(decorator.hrefResolve)) {
|
if (!String(value).includes(decorator.hrefResolve)) {
|
||||||
return `${decorator.hrefResolve}${value}`
|
return `${decorator.hrefResolve}${value}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserLinkViewer = (props) => {
|
const UserLinkViewer = (props) => {
|
||||||
const { link, decorator } = props
|
const { link, decorator } = props
|
||||||
|
|
||||||
return <div className="userLinkViewer">
|
return (
|
||||||
<div className="userLinkViewer_icon">
|
<div className="userLinkViewer">
|
||||||
{
|
<div className="userLinkViewer_icon">
|
||||||
createIconRender(decorator.icon ?? "MdLink")
|
{createIconRender(decorator.icon ?? "MdLink")}
|
||||||
}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="userLinkViewer_value">
|
|
||||||
<p>
|
|
||||||
{
|
|
||||||
link.value
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="userLinkViewer_value">
|
||||||
|
<p>{link.value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserLink = (props) => {
|
const UserLink = (props) => {
|
||||||
let { index, link } = props
|
let { index, link } = props
|
||||||
|
|
||||||
link.key = link.key.toLowerCase()
|
link.key = link.key.toLowerCase()
|
||||||
|
|
||||||
const decorator = linksDecorators[link.key] ?? {}
|
const decorator = linksDecorators[link.key] ?? {}
|
||||||
|
|
||||||
link.value = processValue(link.value, decorator)
|
link.value = processValue(link.value, decorator)
|
||||||
|
|
||||||
const hasHref = String(link.value).includes("://")
|
const hasHref = String(link.value).includes("://")
|
||||||
|
|
||||||
const handleOnClick = () => {
|
const handleOnClick = () => {
|
||||||
if (!hasHref) {
|
if (!hasHref) {
|
||||||
if (app.isMobile) {
|
if (app.isMobile) {
|
||||||
app.layout.drawer.open("link_viewer", UserLinkViewer, {
|
app.layout.drawer.open("link_viewer", UserLinkViewer, {
|
||||||
componentProps: {
|
componentProps: {
|
||||||
link: link,
|
link: link,
|
||||||
decorator: decorator
|
decorator: decorator,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(link.value, "_blank")
|
window.open(link.value, "_blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderName = () => {
|
const renderName = () => {
|
||||||
if (decorator.hrefResolve) {
|
if (decorator.hrefResolve) {
|
||||||
return decorator.label ?? link.value
|
return decorator.label ?? link.value
|
||||||
}
|
}
|
||||||
|
|
||||||
return link.value
|
return link.value
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div
|
return (
|
||||||
key={index}
|
<div
|
||||||
id={`link-${index}-${link.key}`}
|
key={index}
|
||||||
className={`userLink ${hasHref ? "clickable" : ""}`}
|
id={`link-${index}-${link.key}`}
|
||||||
onClick={handleOnClick}
|
className={`userLink ${hasHref ? "clickable" : ""}`}
|
||||||
>
|
onClick={handleOnClick}
|
||||||
{
|
>
|
||||||
createIconRender(decorator.icon ?? "MdLink")
|
{createIconRender(decorator.icon ?? "MdLink")}
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{!app.isMobile && <p>{renderName()}</p>}
|
||||||
!app.isMobile && <p>
|
</div>
|
||||||
{
|
)
|
||||||
renderName()
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserCard = React.forwardRef((props, ref) => {
|
export const UserCard = (props) => {
|
||||||
const [user, setUser] = React.useState(props.user)
|
const [user, setUser] = React.useState(props.user)
|
||||||
|
|
||||||
// TODO: Support API user data fetching
|
React.useEffect(() => {
|
||||||
|
setUser(props.user)
|
||||||
|
}, [props.user])
|
||||||
|
|
||||||
return <div
|
// TODO: Support API user data fetching
|
||||||
className="userCard"
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<div className="avatar">
|
|
||||||
<Image
|
|
||||||
src={user.avatar}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="username">
|
return (
|
||||||
<div className="username_text">
|
<div className="userCard">
|
||||||
<h1>
|
<div className="avatar">
|
||||||
{user.public_name || user.username}
|
<Image src={user.avatar} />
|
||||||
{user.verified && <Icons.verifiedBadge />}
|
</div>
|
||||||
</h1>
|
|
||||||
<span>
|
|
||||||
@{user.username}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
<div className="username">
|
||||||
user.badges?.length > 0 && <UserBadges user_id={user._id} />
|
<div className="username_text">
|
||||||
}
|
<h1>
|
||||||
</div>
|
{user.public_name || user.username}
|
||||||
|
{user.verified && <Icons.verifiedBadge />}
|
||||||
|
</h1>
|
||||||
|
<span>@{user.username}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="description">
|
{user.badges?.length > 0 && <UserBadges user_id={user._id} />}
|
||||||
<span>
|
</div>
|
||||||
{user.description}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
<div className="description">
|
||||||
user.links && Array.isArray(user.links) && user.links.length > 0 && <div className="userLinks">
|
<span>{user.description}</span>
|
||||||
{
|
</div>
|
||||||
user.links.map((link, index) => {
|
|
||||||
return <UserLink index={index} link={link} />
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
})
|
|
||||||
|
|
||||||
export const MobileUserCard = React.forwardRef((props, ref) => {
|
{user.links &&
|
||||||
return <div
|
Array.isArray(user.links) &&
|
||||||
ref={ref}
|
user.links.length > 0 && (
|
||||||
className={classnames(
|
<div className="userLinks">
|
||||||
"_mobile_userCard",
|
{user.links.map((link, index) => {
|
||||||
{
|
return (
|
||||||
["no-cover"]: !props.user.cover
|
<UserLink
|
||||||
}
|
key={index}
|
||||||
)}
|
index={index}
|
||||||
>
|
link={link}
|
||||||
<div className="_mobile_userCard_top">
|
/>
|
||||||
{
|
)
|
||||||
props.user.cover && <div className="_mobile_userCard_top_cover">
|
})}
|
||||||
<div
|
</div>
|
||||||
className="cover"
|
)}
|
||||||
style={{
|
</div>
|
||||||
backgroundImage: `url("${props.user.cover}")`
|
)
|
||||||
}}
|
}
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="_mobile_userCard_top_avatar_wrapper">
|
export const MobileUserCard = (props, ref) => {
|
||||||
<div className="_mobile_userCard_top_avatar">
|
return (
|
||||||
<Image
|
<div
|
||||||
src={props.user.avatar}
|
ref={ref}
|
||||||
/>
|
className={classnames("_mobile_userCard", {
|
||||||
</div>
|
["no-cover"]: !props.user.cover,
|
||||||
</div>
|
})}
|
||||||
</div>
|
>
|
||||||
}
|
<div className="_mobile_userCard_top">
|
||||||
|
{props.user.cover && (
|
||||||
|
<div className="_mobile_userCard_top_cover">
|
||||||
|
<div
|
||||||
|
className="cover"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("${props.user.cover}")`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{
|
<div className="_mobile_userCard_top_avatar_wrapper">
|
||||||
!props.user.cover && <div className="_mobile_userCard_top_avatar">
|
<div className="_mobile_userCard_top_avatar">
|
||||||
<Image
|
<Image src={props.user.avatar} />
|
||||||
src={props.user.avatar}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
|
|
||||||
<div className="_mobile_userCard_top_texts">
|
{!props.user.cover && (
|
||||||
<div className="_mobile_userCard_top_username">
|
<div className="_mobile_userCard_top_avatar">
|
||||||
<h1>
|
<Image src={props.user.avatar} />
|
||||||
{
|
</div>
|
||||||
props.user.fullName ?? `@${props.user.username}`
|
)}
|
||||||
}
|
|
||||||
{
|
|
||||||
props.user.verified && <Icons.verifiedBadge id="verification_tick" />
|
|
||||||
}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{
|
<div className="_mobile_userCard_top_texts">
|
||||||
props.user.fullName && <span>
|
<div className="_mobile_userCard_top_username">
|
||||||
@{props.user.username}
|
<h1>
|
||||||
</span>
|
{props.user.fullName ?? `@${props.user.username}`}
|
||||||
}
|
{props.user.verified && (
|
||||||
</div>
|
<Icons.verifiedBadge id="verification_tick" />
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div className="_mobile_userCard_top_badges_wrapper">
|
{props.user.fullName && (
|
||||||
{
|
<span>@{props.user.username}</span>
|
||||||
props.user.badges?.length > 0 && <UserBadges user_id={props.user._id} />
|
)}
|
||||||
}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="_mobile_userCard_top_description">
|
<div className="_mobile_userCard_top_badges_wrapper">
|
||||||
<p>
|
{props.user.badges?.length > 0 && (
|
||||||
{
|
<UserBadges user_id={props.user._id} />
|
||||||
props.user.description
|
)}
|
||||||
}
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
<div className="_mobile_userCard_top_description">
|
||||||
props.user.links
|
<p>{props.user.description}</p>
|
||||||
&& Array.isArray(props.user.links)
|
</div>
|
||||||
&& props.user.links.length > 0
|
</div>
|
||||||
&& <div
|
|
||||||
className={classnames(
|
|
||||||
"_mobile_userCard_links",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
props.user.links.map((link, index) => {
|
|
||||||
return <UserLink index={index} link={link} />
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
{props.user.links &&
|
||||||
className={classnames(
|
Array.isArray(props.user.links) &&
|
||||||
"_mobile_card",
|
props.user.links.length > 0 && (
|
||||||
"_mobile_userCard_actions",
|
<div className={classnames("_mobile_userCard_links")}>
|
||||||
)}
|
{props.user.links.map((link, index) => {
|
||||||
>
|
return <UserLink index={index} link={link} />
|
||||||
{
|
})}
|
||||||
props.followers && <FollowButton
|
</div>
|
||||||
count={props.followers.length}
|
)}
|
||||||
onClick={props.onClickFollow}
|
</div>
|
||||||
followed={props.isFollowed}
|
|
||||||
self={props.isSelf}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
<antd.Button
|
<div
|
||||||
type="primary"
|
className={classnames(
|
||||||
icon={<Icons.MdMessage />}
|
"_mobile_card",
|
||||||
disabled
|
"_mobile_userCard_actions",
|
||||||
/>
|
)}
|
||||||
|
>
|
||||||
|
{props.followers && (
|
||||||
|
<FollowButton
|
||||||
|
count={props.followers.length}
|
||||||
|
onClick={props.onClickFollow}
|
||||||
|
followed={props.isFollowed}
|
||||||
|
self={props.isSelf}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<antd.Button
|
<antd.Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<Icons.MdShare />}
|
icon={<Icons.MdMessage />}
|
||||||
/>
|
disabled
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
})
|
|
||||||
|
|
||||||
export default UserCard
|
<antd.Button type="primary" icon={<Icons.MdShare />} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserCard
|
||||||
|
@ -59,6 +59,7 @@ const UserPreview = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id={userData._id}
|
||||||
className={classnames("userPreview", {
|
className={classnames("userPreview", {
|
||||||
["clickable"]: typeof props.onClick === "function",
|
["clickable"]: typeof props.onClick === "function",
|
||||||
["small"]: props.small && !props.big,
|
["small"]: props.small && !props.big,
|
||||||
|
@ -65,8 +65,6 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
transform: translate(0, 3px);
|
|
||||||
|
|
||||||
img {
|
img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|
||||||
@ -107,4 +105,4 @@
|
|||||||
align-self: start;
|
align-self: start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
@ -56,7 +56,9 @@ export class WithPlayerContext extends React.Component {
|
|||||||
|
|
||||||
events = {
|
events = {
|
||||||
"player.state.update": async (state) => {
|
"player.state.update": async (state) => {
|
||||||
this.setState(state)
|
if (state !== this.state) {
|
||||||
|
this.setState(state)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 PlayerProcessors from "./PlayerProcessors"
|
||||||
import AudioPlayerStorage from "../player.storage"
|
import AudioPlayerStorage from "../player.storage"
|
||||||
import TrackManifest from "../classes/TrackManifest"
|
import TrackManifest from "../classes/TrackManifest"
|
||||||
|
|
||||||
import findInitializationChunk from "../helpers/findInitializationChunk"
|
|
||||||
import parseSourceFormatMetadata from "../helpers/parseSourceFormatMetadata"
|
import parseSourceFormatMetadata from "../helpers/parseSourceFormatMetadata"
|
||||||
import handleInlineDashManifest from "../helpers/handleInlineDashManifest"
|
|
||||||
|
|
||||||
export default class AudioBase {
|
export default class AudioBase {
|
||||||
constructor(player) {
|
constructor(player) {
|
||||||
@ -45,14 +45,14 @@ export default class AudioBase {
|
|||||||
this.audio.addEventListener(key, value)
|
this.audio.addEventListener(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setup shaka player for mpd
|
// setup dash.js player for mpd
|
||||||
this.createDemuxer()
|
this.createDemuxer()
|
||||||
|
|
||||||
// create element source with low latency buffer
|
// create element source with low latency buffer
|
||||||
this.elementSource = this.context.createMediaElementSource(this.audio)
|
this.elementSource = this.context.createMediaElementSource(this.audio)
|
||||||
|
|
||||||
await this.processorsManager.initialize(),
|
await this.processorsManager.initialize()
|
||||||
await this.processorsManager.attachAllNodes()
|
await this.processorsManager.attachAllNodes()
|
||||||
}
|
}
|
||||||
|
|
||||||
itemInit = async (manifest) => {
|
itemInit = async (manifest) => {
|
||||||
@ -60,29 +60,49 @@ export default class AudioBase {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (manifest._initialized) {
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
this.console.time("itemInit()")
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof manifest === "string" ||
|
typeof manifest === "string" ||
|
||||||
(!manifest.source && !manifest.dash_manifest)
|
(!manifest.source && !manifest.dash_manifest)
|
||||||
) {
|
) {
|
||||||
this.console.time("resolve")
|
this.console.time("resolve manifest")
|
||||||
manifest = await this.player.serviceProviders.resolve(manifest)
|
manifest = await this.player.serviceProviders.resolve(manifest)
|
||||||
this.console.timeEnd("resolve")
|
this.console.timeEnd("resolve manifest")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(manifest instanceof TrackManifest)) {
|
if (!(manifest instanceof TrackManifest)) {
|
||||||
this.console.time("init manifest")
|
this.console.time("instanciate class")
|
||||||
manifest = new TrackManifest(manifest, this.player)
|
manifest = new TrackManifest(manifest, this.player)
|
||||||
this.console.timeEnd("init manifest")
|
this.console.timeEnd("instanciate class")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (manifest.mpd_mode === true && !manifest.dash_manifest) {
|
if (
|
||||||
this.console.time("fetch dash manifest")
|
manifest.mpd_mode === true &&
|
||||||
manifest.dash_manifest = await fetch(manifest.source).then((r) =>
|
!manifest.dash_manifest &&
|
||||||
r.text(),
|
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
|
return manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,28 +130,32 @@ export default class AudioBase {
|
|||||||
this.processors.gain.set(this.player.state.volume)
|
this.processors.gain.set(this.player.state.volume)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.audio.paused) {
|
if (manifest.mpd_mode && this.demuxer) {
|
||||||
try {
|
this.console.time("play")
|
||||||
this.console.time("play")
|
await this.demuxer.play()
|
||||||
await this.audio.play()
|
this.console.timeEnd("play")
|
||||||
this.console.timeEnd("play")
|
}
|
||||||
} catch (error) {
|
|
||||||
this.console.error(
|
if (!manifest.mpd_mode && this.audio.paused) {
|
||||||
"Error during audio.play():",
|
this.console.time("play")
|
||||||
error,
|
await this.audio.play()
|
||||||
"State:",
|
this.console.timeEnd("play")
|
||||||
this.audio.readyState,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let initChunk = manifest.source
|
let initChunk = manifest.source
|
||||||
|
|
||||||
if (this.demuxer && manifest.dash_manifest) {
|
if (this.demuxer && manifest.dash_manifest) {
|
||||||
initChunk = findInitializationChunk(
|
let initializationTemplate =
|
||||||
manifest.source,
|
manifest.dash_manifest["Period"][0]["AdaptationSet"][0][
|
||||||
manifest.dash_manifest,
|
"Representation"
|
||||||
|
][0]["SegmentTemplate"]["initialization"]
|
||||||
|
|
||||||
|
initializationTemplate = initializationTemplate.replace(
|
||||||
|
"$RepresentationID$",
|
||||||
|
"0",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
initChunk = new URL(initializationTemplate, manifest.source)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -172,79 +196,67 @@ export default class AudioBase {
|
|||||||
|
|
||||||
if (!this.demuxer) {
|
if (!this.demuxer) {
|
||||||
this.console.log("Creating demuxer cause not initialized")
|
this.console.log("Creating demuxer cause not initialized")
|
||||||
this.createDemuxer()
|
await this.createDemuxer()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (manifest._preloaded) {
|
await this.demuxer.attachSource(manifest.dash_manifest, 0)
|
||||||
this.console.log(
|
|
||||||
`using preloaded source >`,
|
|
||||||
manifest._preloaded,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await this.demuxer.load(manifest._preloaded)
|
return manifest.source
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if not using demuxer, destroy previous instance
|
// if not using demuxer, destroy previous instance
|
||||||
if (this.demuxer) {
|
if (this.demuxer) {
|
||||||
await this.demuxer.unload()
|
try {
|
||||||
await this.demuxer.destroy()
|
this.demuxer.reset()
|
||||||
|
this.demuxer.destroy()
|
||||||
|
} catch (error) {
|
||||||
|
this.console.warn("Error destroying demuxer:", error)
|
||||||
|
}
|
||||||
|
|
||||||
this.demuxer = null
|
this.demuxer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// load source
|
// load source
|
||||||
this.audio.src = manifest.source
|
this.audio.src = manifest.source
|
||||||
return this.audio.load()
|
this.audio.load()
|
||||||
|
|
||||||
|
return manifest.source
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDemuxer() {
|
async createDemuxer() {
|
||||||
// Destroy previous instance if exists
|
// Destroy previous instance if exists
|
||||||
if (this.demuxer) {
|
if (this.demuxer) {
|
||||||
await this.demuxer.unload()
|
try {
|
||||||
await this.demuxer.detach()
|
this.demuxer.reset()
|
||||||
await this.demuxer.destroy()
|
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({
|
this.demuxer.updateSettings({
|
||||||
manifest: {
|
|
||||||
//updatePeriod: 5,
|
|
||||||
disableVideo: true,
|
|
||||||
disableText: true,
|
|
||||||
dash: {
|
|
||||||
ignoreMinBufferTime: true,
|
|
||||||
ignoreMaxSegmentDuration: true,
|
|
||||||
autoCorrectDrift: false,
|
|
||||||
enableFastSwitching: true,
|
|
||||||
useStreamOnceInPeriodFlattening: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
streaming: {
|
streaming: {
|
||||||
bufferingGoal: 15,
|
//cacheInitSegments: true,
|
||||||
rebufferingGoal: 1,
|
buffer: {
|
||||||
bufferBehind: 30,
|
bufferTimeAtTopQuality: 15,
|
||||||
stallThreshold: 0.5,
|
initialBufferLevel: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
shaka.net.NetworkingEngine.registerScheme(
|
//this.demuxer.setAutoPlay(false)
|
||||||
"inline",
|
|
||||||
handleInlineDashManifest,
|
|
||||||
)
|
|
||||||
|
|
||||||
this.demuxer.addEventListener("error", (event) => {
|
// Event listeners
|
||||||
|
this.demuxer.on(dashjs.MediaPlayer.events.ERROR, (event) => {
|
||||||
console.error("Demuxer 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 remaining time is less than 3s, try to init next item
|
||||||
if (parseInt(remainingTime) <= 10) {
|
if (parseInt(remainingTime) <= 10) {
|
||||||
// check if queue has next item
|
// 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] = await this.itemInit(
|
||||||
this.player.queue.nextItems[0],
|
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.pause()
|
||||||
this.audio.currentTime = 0
|
this.audio.currentTime = 0
|
||||||
this.createDemuxer()
|
await this.createDemuxer()
|
||||||
}
|
}
|
||||||
|
|
||||||
audioEvents = {
|
audioEvents = {
|
||||||
|
@ -72,6 +72,8 @@ export default class SyncRoom {
|
|||||||
track_manifest = {
|
track_manifest = {
|
||||||
...currentItem.toSeriableObject(),
|
...currentItem.toSeriableObject(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete track_manifest.source
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if has changed the track
|
// check if has changed the track
|
||||||
@ -98,8 +100,6 @@ export default class SyncRoom {
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncState = async (data) => {
|
syncState = async (data) => {
|
||||||
console.log(data)
|
|
||||||
|
|
||||||
if (!data || !data.track_manifest) {
|
if (!data || !data.track_manifest) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -129,11 +129,12 @@ export default class SyncRoom {
|
|||||||
const currentTime = this.player.seek()
|
const currentTime = this.player.seek()
|
||||||
const offset = serverTime - currentTime
|
const offset = serverTime - currentTime
|
||||||
|
|
||||||
console.log({
|
this.player.console.debug("sync_state", {
|
||||||
|
serverPayload: data,
|
||||||
serverTime: serverTime,
|
serverTime: serverTime,
|
||||||
currentTime: currentTime,
|
currentTime: currentTime,
|
||||||
maxTimeOffset: SyncRoom.maxTimeOffset,
|
|
||||||
offset: offset,
|
offset: offset,
|
||||||
|
maxTimeOffset: SyncRoom.maxTimeOffset,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -182,13 +183,13 @@ export default class SyncRoom {
|
|||||||
this.state.joined_room = null
|
this.state.joined_room = null
|
||||||
|
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
await this.socket.disconnect()
|
await this.socket.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createSocket = async () => {
|
createSocket = async () => {
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
await this.socket.disconnect()
|
await this.socket.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.socket = new RTEngineClient({
|
this.socket = new RTEngineClient({
|
||||||
|
@ -45,12 +45,8 @@ export default class TrackManifest {
|
|||||||
this.source = params.source
|
this.source = params.source
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof params.dash_manifest !== "undefined") {
|
if (typeof params.mpd_string !== "undefined") {
|
||||||
this.dash_manifest = params.dash_manifest
|
this.mpd_string = params.mpd_string
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof params.encoded_manifest !== "undefined") {
|
|
||||||
this.encoded_manifest = params.encoded_manifest
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof params.metadata !== "undefined") {
|
if (typeof params.metadata !== "undefined") {
|
||||||
@ -128,7 +124,7 @@ export default class TrackManifest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serviceOperations = {
|
serviceOperations = {
|
||||||
fetchLyrics: async () => {
|
fetchLyrics: async (options) => {
|
||||||
if (!this._id) {
|
if (!this._id) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -137,6 +133,7 @@ export default class TrackManifest {
|
|||||||
"resolveLyrics",
|
"resolveLyrics",
|
||||||
this.service,
|
this.service,
|
||||||
this,
|
this,
|
||||||
|
options,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (this.overrides) {
|
if (this.overrides) {
|
||||||
@ -195,8 +192,7 @@ export default class TrackManifest {
|
|||||||
album: this.album,
|
album: this.album,
|
||||||
artist: this.artist,
|
artist: this.artist,
|
||||||
source: this.source,
|
source: this.source,
|
||||||
dash_manifest: this.dash_manifest,
|
mpd_string: this.mpd_string,
|
||||||
encoded_manifest: this.encoded_manifest,
|
|
||||||
metadata: this.metadata,
|
metadata: this.metadata,
|
||||||
liked: this.liked,
|
liked: this.liked,
|
||||||
service: this.service,
|
service: this.service,
|
||||||
|
198
packages/app/src/cores/player/mpd_parser/constants.js
Normal file
198
packages/app/src/cores/player/mpd_parser/constants.js
Normal 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",
|
||||||
|
}
|
131
packages/app/src/cores/player/mpd_parser/index.js
Normal file
131
packages/app/src/cores/player/mpd_parser/index.js
Normal 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
|
||||||
|
}
|
52
packages/app/src/cores/player/mpd_parser/matchers/base.js
Normal file
52
packages/app/src/cores/player/mpd_parser/matchers/base.js
Normal 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
|
@ -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
|
@ -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
|
71
packages/app/src/cores/player/mpd_parser/matchers/lang.js
Normal file
71
packages/app/src/cores/player/mpd_parser/matchers/lang.js
Normal 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
|
52
packages/app/src/cores/player/mpd_parser/matchers/numeric.js
Normal file
52
packages/app/src/cores/player/mpd_parser/matchers/numeric.js
Normal 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
|
92
packages/app/src/hooks/onPageMount.js
Normal file
92
packages/app/src/hooks/onPageMount.js
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
packages/app/src/hooks/useCenteredContainer.js
Executable file
19
packages/app/src/hooks/useCenteredContainer.js
Executable 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)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
@ -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)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
}
|
|
@ -32,12 +32,14 @@ export default (trackManifest) => {
|
|||||||
}
|
}
|
||||||
}, [trackManifest])
|
}, [trackManifest])
|
||||||
|
|
||||||
const dominantColor = {
|
|
||||||
"--dominant-color": getDominantColorStr(coverAnalysis),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
coverAnalysis,
|
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],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
81
packages/app/src/hooks/useLyrics.js
Normal file
81
packages/app/src/hooks/useLyrics.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
19
packages/app/src/hooks/useMaxScreen.js
Normal file
19
packages/app/src/hooks/useMaxScreen.js
Normal 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()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
@ -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()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
}
|
|
39
packages/app/src/hooks/useUrlQueryActiveKey.js
Executable file
39
packages/app/src/hooks/useUrlQueryActiveKey.js
Executable 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]
|
||||||
|
}
|
@ -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,
|
|
||||||
]
|
|
||||||
}
|
|
148
packages/app/src/layouts/components/drawer/component.jsx
Normal file
148
packages/app/src/layouts/components/drawer/component.jsx
Normal 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
|
37
packages/app/src/layouts/components/drawer/header.jsx
Normal file
37
packages/app/src/layouts/components/drawer/header.jsx
Normal 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
|
516
packages/app/src/layouts/components/drawer/hooks.js
Normal file
516
packages/app/src/layouts/components/drawer/hooks.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
@ -1,153 +1,56 @@
|
|||||||
import React from "react"
|
import React, {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
} from "react"
|
||||||
import classnames from "classnames"
|
import classnames from "classnames"
|
||||||
import { AnimatePresence, motion } from "motion/react"
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
|
||||||
|
import Drawer from "./component"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export class Drawer extends React.Component {
|
// Context for drawer management
|
||||||
options = this.props.options ?? {}
|
const DrawerContext = createContext()
|
||||||
|
|
||||||
state = {
|
// Hook to use drawer context
|
||||||
visible: false,
|
export const useDrawer = () => {
|
||||||
|
const context = useContext(DrawerContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useDrawer must be used within a DrawerProvider")
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleVisibility = (to) => {
|
return context
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class DrawerController extends React.Component {
|
function DrawerController() {
|
||||||
constructor(props) {
|
const [state, setState] = useState({
|
||||||
super(props)
|
addresses: {},
|
||||||
|
refs: {},
|
||||||
|
drawers: [],
|
||||||
|
maskVisible: false,
|
||||||
|
})
|
||||||
|
|
||||||
this.state = {
|
const stateRef = useRef(state)
|
||||||
addresses: {},
|
stateRef.current = state
|
||||||
refs: {},
|
|
||||||
drawers: [],
|
|
||||||
|
|
||||||
maskVisible: false,
|
const toggleMaskVisibility = useCallback((to) => {
|
||||||
}
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
maskVisible: to ?? !prev.maskVisible,
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
this.interface = {
|
const handleEscKeyPress = useCallback((event) => {
|
||||||
open: this.open,
|
const currentState = stateRef.current
|
||||||
close: this.close,
|
|
||||||
closeAll: this.closeAll,
|
|
||||||
drawers: () => this.state.drawers,
|
|
||||||
drawersLength: () => this.state.drawers.length,
|
|
||||||
isMaskVisible: () => this.state.maskVisible,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount = () => {
|
if (currentState.drawers.length === 0) {
|
||||||
app.layout["drawer"] = this.interface
|
return null
|
||||||
|
|
||||||
this.listenEscape()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let isEscape = false
|
let isEscape = false
|
||||||
@ -159,145 +62,223 @@ export default class DrawerController extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isEscape) {
|
if (isEscape) {
|
||||||
this.closeLastDrawer()
|
closeLastDrawer()
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
getLastDrawer = () => {
|
const getLastDrawer = useCallback(() => {
|
||||||
return this.state.drawers[this.state.drawers.length - 1].ref.current
|
const currentState = stateRef.current
|
||||||
}
|
const lastDrawerId =
|
||||||
|
currentState.drawers[currentState.drawers.length - 1]?.id
|
||||||
|
|
||||||
closeLastDrawer = () => {
|
if (!lastDrawerId) {
|
||||||
const lastDrawer = this.getLastDrawer()
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (lastDrawer) {
|
return {
|
||||||
if (app.layout.modal && lastDrawer.options.confirmOnOutsideClick) {
|
id: lastDrawerId,
|
||||||
|
ref: currentState.refs[lastDrawerId]?.current,
|
||||||
|
options:
|
||||||
|
currentState.drawers[currentState.drawers.length - 1]?.options,
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closeLastDrawer = useCallback(() => {
|
||||||
|
const lastDrawer = getLastDrawer()
|
||||||
|
|
||||||
|
if (lastDrawer && lastDrawer.id) {
|
||||||
|
if (
|
||||||
|
app.layout?.modal &&
|
||||||
|
lastDrawer.options?.confirmOnOutsideClick
|
||||||
|
) {
|
||||||
return app.layout.modal.confirm({
|
return app.layout.modal.confirm({
|
||||||
descriptionText:
|
descriptionText:
|
||||||
lastDrawer.options.confirmOnOutsideClickText ??
|
lastDrawer.options.confirmOnOutsideClickText ||
|
||||||
"Are you sure you want to close this drawer?",
|
"Are you sure you want to close this drawer?",
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
lastDrawer.close()
|
close(lastDrawer.id)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
lastDrawer.close()
|
close(lastDrawer.id)
|
||||||
}
|
}
|
||||||
}
|
}, [getLastDrawer])
|
||||||
|
|
||||||
toggleMaskVisibility = async (to) => {
|
const close = useCallback(
|
||||||
this.setState({
|
async (id, { transition = 0 } = {}) => {
|
||||||
maskVisible: to ?? !this.state.maskVisible,
|
const currentState = stateRef.current
|
||||||
})
|
const index = currentState.addresses[id]
|
||||||
}
|
const ref = currentState.refs[id]?.current
|
||||||
|
|
||||||
open = (id, component, options) => {
|
if (typeof ref === "undefined") {
|
||||||
const refs = this.state.refs ?? {}
|
console.warn("This drawer does not exist")
|
||||||
const drawers = this.state.drawers ?? []
|
return
|
||||||
const addresses = this.state.addresses ?? {}
|
}
|
||||||
|
|
||||||
const instance = {
|
if (currentState.drawers.length === 1) {
|
||||||
id: id,
|
toggleMaskVisibility(false)
|
||||||
ref: React.createRef(),
|
}
|
||||||
children: component,
|
|
||||||
options: options,
|
|
||||||
controller: this,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof addresses[id] === "undefined") {
|
if (transition > 0) {
|
||||||
drawers.push(<Drawer key={id} {...instance} />)
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, transition)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
addresses[id] = drawers.length - 1
|
setState((prev) => {
|
||||||
refs[id] = instance.ref
|
const newDrawers = prev.drawers.filter((_, i) => i !== index)
|
||||||
} else {
|
const newAddresses = { ...prev.addresses }
|
||||||
drawers[addresses[id]] = <Drawer key={id} {...instance} />
|
const newRefs = { ...prev.refs }
|
||||||
refs[id] = instance.ref
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
delete newAddresses[id]
|
||||||
refs,
|
delete newRefs[id]
|
||||||
addresses,
|
|
||||||
drawers,
|
|
||||||
})
|
|
||||||
|
|
||||||
this.toggleMaskVisibility(true)
|
return {
|
||||||
}
|
...prev,
|
||||||
|
refs: newRefs,
|
||||||
close = async (id, { transition = 0 } = {}) => {
|
addresses: newAddresses,
|
||||||
let { addresses, drawers, refs } = this.state
|
drawers: newDrawers,
|
||||||
|
}
|
||||||
const index = addresses[id]
|
|
||||||
const ref = this.state.refs[id]?.current
|
|
||||||
|
|
||||||
if (typeof ref === "undefined") {
|
|
||||||
return console.warn("This drawer not exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drawers.length === 1) {
|
|
||||||
this.toggleMaskVisibility(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transition > 0) {
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, transition)
|
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
[toggleMaskVisibility],
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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_
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof drawers[index] !== "undefined") {
|
return () => {
|
||||||
drawers = drawers.filter((value, i) => i !== index)
|
if (app.layout) {
|
||||||
|
delete app.layout["drawer"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}, [interface_])
|
||||||
|
|
||||||
delete addresses[id]
|
useEffect(() => {
|
||||||
delete refs[id]
|
document.addEventListener("keydown", handleEscKeyPress)
|
||||||
|
|
||||||
this.setState({
|
return () => {
|
||||||
refs,
|
document.removeEventListener("keydown", handleEscKeyPress)
|
||||||
addresses,
|
}
|
||||||
drawers,
|
}, [handleEscKeyPress])
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
closeAll = () => {
|
// Handle sidebar visibility based on mask visibility
|
||||||
this.state.drawers.forEach((drawer) => {
|
useEffect(() => {
|
||||||
drawer.ref.current.close()
|
if (app.layout?.sidebar) {
|
||||||
})
|
app.layout.sidebar.toggleVisibility(!state.maskVisible)
|
||||||
}
|
}
|
||||||
|
}, [state.maskVisible])
|
||||||
|
|
||||||
render() {
|
return (
|
||||||
return (
|
<DrawerContext.Provider value={interface_}>
|
||||||
<>
|
<AnimatePresence>
|
||||||
<AnimatePresence>
|
{state.maskVisible && (
|
||||||
{this.state.maskVisible && (
|
<motion.div
|
||||||
<motion.div
|
className="drawers-mask"
|
||||||
className="drawers-mask"
|
onClick={closeLastDrawer}
|
||||||
onClick={() => this.closeLastDrawer()}
|
initial={{ opacity: 0 }}
|
||||||
initial={{
|
animate={{ opacity: 1 }}
|
||||||
opacity: 0,
|
exit={{ opacity: 0 }}
|
||||||
}}
|
transition={{
|
||||||
animate={{
|
type: "spring",
|
||||||
opacity: 1,
|
stiffness: 100,
|
||||||
}}
|
damping: 20,
|
||||||
exit={{
|
}}
|
||||||
opacity: 0,
|
/>
|
||||||
}}
|
)}
|
||||||
transition={{
|
</AnimatePresence>
|
||||||
type: "spring",
|
|
||||||
stiffness: 100,
|
<div
|
||||||
damping: 20,
|
className={classnames("drawers-wrapper", {
|
||||||
}}
|
["hidden"]: !state.drawers.length,
|
||||||
/>
|
})}
|
||||||
)}
|
>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{state.drawers.map((drawer) => drawer.element)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
<div
|
</DrawerContext.Provider>
|
||||||
className={classnames("drawers-wrapper", {
|
)
|
||||||
["hidden"]: !this.state.drawers.length,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{this.state.drawers}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default DrawerController
|
||||||
|
@ -1,87 +1,206 @@
|
|||||||
@import "@styles/vars.less";
|
@import "@styles/vars.less";
|
||||||
|
|
||||||
.drawers-wrapper {
|
.drawers-wrapper {
|
||||||
position: absolute;
|
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;
|
||||||
|
|
||||||
top: 0;
|
&.hidden {
|
||||||
left: 0;
|
display: none;
|
||||||
|
}
|
||||||
z-index: 1200;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
padding: @sidebar_padding;
|
|
||||||
|
|
||||||
margin-left: calc(@sidebar_padding * 2);
|
|
||||||
|
|
||||||
height: 100dvh;
|
|
||||||
height: 100vh;
|
|
||||||
|
|
||||||
&.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawers-mask {
|
.drawers-mask {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
top: 0;
|
left: 0;
|
||||||
left: 0;
|
z-index: 1100;
|
||||||
|
width: 100vw;
|
||||||
z-index: 1100;
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
width: 100vw;
|
backdrop-filter: blur(4px);
|
||||||
height: 100vh;
|
cursor: pointer;
|
||||||
|
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1300;
|
||||||
|
|
||||||
z-index: 1300;
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
top: 0;
|
display: flex;
|
||||||
left: 0;
|
flex-direction: column;
|
||||||
bottom: 0;
|
pointer-events: auto;
|
||||||
|
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
height: 100%;
|
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);
|
||||||
|
|
||||||
background-color: var(--background-color-accent);
|
overflow: hidden;
|
||||||
|
|
||||||
border-radius: @sidebar_borderRadius;
|
&.drawer-left {
|
||||||
box-shadow: @card-shadow;
|
left: 0;
|
||||||
border: 1px solid var(--sidebar-background-color);
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
overflow-x: hidden;
|
&.drawer-right {
|
||||||
overflow-y: overlay;
|
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: 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 {
|
.drawer_close_confirm {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
gap: 10px;
|
.drawer_close_confirm_content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
width: 100%;
|
.drawer_close_confirm_actions {
|
||||||
|
display: flex;
|
||||||
.drawer_close_confirm_content {
|
flex-direction: row;
|
||||||
display: flex;
|
gap: 10px;
|
||||||
flex-direction: column;
|
}
|
||||||
|
}
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer_close_confirm_actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -5,7 +5,7 @@ import useLayoutInterface from "@hooks/useLayoutInterface"
|
|||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export default (props) => {
|
const HeaderBar = (props) => {
|
||||||
const [render, setRender] = React.useState(null)
|
const [render, setRender] = React.useState(null)
|
||||||
|
|
||||||
useLayoutInterface("header", {
|
useLayoutInterface("header", {
|
||||||
@ -33,15 +33,23 @@ export default (props) => {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{render && (
|
{render && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
layoutRoot
|
||||||
className="page_header_wrapper"
|
className="page_header_wrapper"
|
||||||
animate={{
|
|
||||||
y: 0,
|
|
||||||
}}
|
|
||||||
initial={{
|
initial={{
|
||||||
y: -100,
|
y: -100,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
y: 0,
|
||||||
|
position: "sticky",
|
||||||
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
exit={{
|
exit={{
|
||||||
y: -100,
|
y: -100,
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
}}
|
}}
|
||||||
transition={{
|
transition={{
|
||||||
type: "spring",
|
type: "spring",
|
||||||
@ -61,3 +69,5 @@ export default (props) => {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default HeaderBar
|
||||||
|
@ -17,58 +17,63 @@ import TopBar from "@layouts/components/@mobile/topBar"
|
|||||||
import BackgroundDecorator from "@components/BackgroundDecorator"
|
import BackgroundDecorator from "@components/BackgroundDecorator"
|
||||||
|
|
||||||
const DesktopLayout = (props) => {
|
const DesktopLayout = (props) => {
|
||||||
return <>
|
return (
|
||||||
<BackgroundDecorator />
|
<>
|
||||||
<Modals />
|
<BackgroundDecorator />
|
||||||
<DraggableDrawerController />
|
<Modals />
|
||||||
|
<DraggableDrawerController />
|
||||||
|
|
||||||
<Layout id="app_layout" className="app_layout">
|
<Layout id="app_layout" className="app_layout">
|
||||||
<Sidebar />
|
<Sidebar user={props.user} />
|
||||||
|
|
||||||
<Layout.Content
|
<Layout.Content
|
||||||
id="content_layout"
|
id="content_layout"
|
||||||
className={classnames(
|
className={classnames(
|
||||||
...props.contentClassnames ?? [],
|
...(props.contentClassnames ?? []),
|
||||||
"content_layout",
|
"content_layout",
|
||||||
"fade-transverse-active",
|
"fade-transverse-active",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
{
|
{props.children &&
|
||||||
props.children && React.cloneElement(props.children, props)
|
React.cloneElement(props.children, props)}
|
||||||
}
|
</Layout.Content>
|
||||||
</Layout.Content>
|
|
||||||
|
|
||||||
<ToolsBar />
|
<ToolsBar />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<BetaBanner />
|
<BetaBanner />
|
||||||
</>
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MobileLayout = (props) => {
|
const MobileLayout = (props) => {
|
||||||
return <Layout id="app_layout" className="app_layout">
|
return (
|
||||||
<DraggableDrawerController />
|
<Layout id="app_layout" className="app_layout">
|
||||||
<TopBar />
|
<DraggableDrawerController />
|
||||||
|
<TopBar />
|
||||||
|
|
||||||
<Layout.Content
|
<Layout.Content
|
||||||
id="content_layout"
|
id="content_layout"
|
||||||
className={classnames(
|
className={classnames(
|
||||||
...props.layoutPageModesClassnames ?? [],
|
...(props.layoutPageModesClassnames ?? []),
|
||||||
"content_layout",
|
"content_layout",
|
||||||
"fade-transverse-active",
|
"fade-transverse-active",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{
|
{props.children && React.cloneElement(props.children, props)}
|
||||||
props.children && React.cloneElement(props.children, props)
|
</Layout.Content>
|
||||||
}
|
|
||||||
</Layout.Content>
|
|
||||||
|
|
||||||
<BottomBar />
|
<BottomBar />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (props) => {
|
export default (props) => {
|
||||||
return window.app.isMobile ? <MobileLayout {...props} /> : <DesktopLayout {...props} />
|
return window.app.isMobile ? (
|
||||||
}
|
<MobileLayout {...props} />
|
||||||
|
) : (
|
||||||
|
<DesktopLayout {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -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;
|
|
@ -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" });
|
|
||||||
}
|
|
||||||
};
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user