diff --git a/.gitignore b/.gitignore
index fc00a838..9496d8c3 100755
--- a/.gitignore
+++ b/.gitignore
@@ -43,4 +43,7 @@
/**/**/.aliaser
# .vscode folder
-.vscode/
\ No newline at end of file
+.vscode/
+
+# zed folder
+.zed/
\ No newline at end of file
diff --git a/comty.js b/comty.js
index cbb45df2..0dd24fee 160000
--- a/comty.js
+++ b/comty.js
@@ -1 +1 @@
-Subproject commit cbb45df2ef42205022e38e4c7d33a001e162c383
+Subproject commit 0dd24fee4a231dbb41d5b14ff92b67d8f14cb5a2
diff --git a/linebridge b/linebridge
index fa61273d..1c429192 160000
--- a/linebridge
+++ b/linebridge
@@ -1 +1 @@
-Subproject commit fa61273d5b4b40a22d97c7773321d8ca6c985fd7
+Subproject commit 1c4291928a3286a6c1d5ed0a4c3f7ac3eb87d45d
diff --git a/packages/app/.gitignore b/packages/app/.gitignore
index 0d76f018..a1c6fb89 100755
--- a/packages/app/.gitignore
+++ b/packages/app/.gitignore
@@ -8,5 +8,6 @@ yarn-error.log
out/
.ssl
+src/pages/_debug
public/oss-licenses.json
/**/**/src/cores/@*
diff --git a/packages/app/config/excuses.json b/packages/app/config/excuses.json
new file mode 100644
index 00000000..8681514c
--- /dev/null
+++ b/packages/app/config/excuses.json
@@ -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."
+]
\ No newline at end of file
diff --git a/packages/app/config/languages.json b/packages/app/config/languages.json
index 2074958e..ee556cb9 100644
--- a/packages/app/config/languages.json
+++ b/packages/app/config/languages.json
@@ -1,611 +1,8 @@
{
- "ab": "Abkhazian",
- "ace": "Achinese",
- "ach": "Acoli",
- "ada": "Adangme",
- "ady": "Adyghe",
- "aa": "Afar",
- "afh": "Afrihili",
- "af": "Afrikaans",
- "agq": "Aghem",
- "ain": "Ainu",
- "ak": "Akan",
- "akk": "Akkadian",
- "bss": "Akoose",
- "akz": "Alabama",
- "sq": "Albanian",
- "ale": "Aleut",
- "arq": "Algerian Arabic",
- "am": "Amarik",
- "en_US": "American English",
- "ase": "American Sign Language",
- "egy": "Ancient Egyptian",
- "grc": "Ancient Greek",
- "anp": "Angika",
- "njo": "Ao Naga",
- "ar": "Arabik",
- "an": "Aragonese",
- "arc": "Aramaic",
- "aro": "Araona",
- "arp": "Arapaho",
- "arw": "Arawak",
- "hy": "Armenian",
- "rup": "Aromanian",
- "frp": "Arpitan",
- "as": "Assamese",
- "ast": "Asturian",
- "asa": "Asu",
- "cch": "Atsam",
- "en_AU": "Australian English",
- "de_AT": "Austrian German",
- "av": "Avaric",
- "ae": "Avestan",
- "awa": "Awadhi",
- "ay": "Aymara",
- "az": "Azerbaijani",
- "bfq": "Badaga",
- "ksf": "Bafia",
- "bfd": "Bafut",
- "bqi": "Bakhtiari",
- "ban": "Balinese",
- "bal": "Baluchi",
- "bm": "Bambara",
- "bax": "Bamun",
- "bjn": "Banjar",
- "bas": "Basaa",
- "ba": "Bashkir",
- "eu": "Basque",
- "bbc": "Batak Toba",
- "bar": "Bavarian",
- "bej": "Beja",
- "be": "Belarus kasa",
- "bem": "Bemba",
- "bez": "Bena",
- "bn": "Bengali kasa",
- "bew": "Betawi",
- "my": "B\u025b\u025bmis kasa",
- "bho": "Bhojpuri",
- "bik": "Bikol",
- "bin": "Bini",
- "bpy": "Bishnupriya",
- "bi": "Bislama",
- "byn": "Blin",
- "zbl": "Blissymbols",
- "brx": "Bodo",
- "en": "Bor\u0254fo",
- "bs": "Bosnian",
- "bg": "B\u0254lgeria kasa",
- "brh": "Brahui",
- "bra": "Braj",
- "pt_BR": "Brazilian Portuguese",
- "br": "Breton",
- "en_GB": "British English",
- "bug": "Buginese",
- "bum": "Bulu",
- "bua": "Buriat",
- "cad": "Caddo",
- "frc": "Cajun French",
- "en_CA": "Canadian English",
- "fr_CA": "Canadian French",
- "yue": "Cantonese",
- "cps": "Capiznon",
- "car": "Carib",
- "ca": "Catalan",
- "cay": "Cayuga",
- "ceb": "Cebuano",
- "tzm": "Central Atlas Tamazight",
- "dtp": "Central Dusun",
- "ckb": "Central Kurdish",
- "esu": "Central Yupik",
- "shu": "Chadian Arabic",
- "chg": "Chagatai",
- "ch": "Chamorro",
- "ce": "Chechen",
- "chr": "Cherokee",
- "chy": "Cheyenne",
- "chb": "Chibcha",
- "cgg": "Chiga",
- "qug": "Chimborazo Highland Quichua",
- "chn": "Chinook Jargon",
- "chp": "Chipewyan",
- "cho": "Choctaw",
- "cu": "Church Slavic",
- "chk": "Chuukese",
- "cv": "Chuvash",
- "nwc": "Classical Newari",
- "syc": "Classical Syriac",
- "ksh": "Colognian",
- "swb": "Comorian",
- "swc": "Congo Swahili",
- "cop": "Coptic",
- "kw": "Cornish",
- "co": "Corsican",
- "cr": "Cree",
- "mus": "Creek",
- "crh": "Crimean Turkish",
- "hr": "Croatian",
- "dak": "Dakota",
- "da": "Danish",
- "dar": "Dargwa",
- "dzg": "Dazaga",
- "del": "Delaware",
- "nl": "D\u025b\u025bkye",
- "din": "Dinka",
- "dv": "Divehi",
- "doi": "Dogri",
- "dgr": "Dogrib",
- "dua": "Duala",
- "dyu": "Dyula",
- "dz": "Dzongkha",
- "frs": "Eastern Frisian",
- "efi": "Efik",
- "arz": "Egyptian Arabic",
- "eka": "Ekajuk",
- "elx": "Elamite",
- "ebu": "Embu",
- "egl": "Emilian",
- "myv": "Erzya",
- "eo": "Esperanto",
- "et": "Estonian",
- "pt_PT": "Portuguese",
- "es_ES": "Español",
- "ee": "Ewe",
- "ewo": "Ewondo",
- "ext": "Extremaduran",
- "fan": "Fang",
- "fat": "Fanti",
- "fo": "Faroese",
- "hif": "Fiji Hindi",
- "fj": "Fijian",
- "fil": "Filipino",
- "fi": "Finnish",
- "nl_BE": "Flemish",
- "fon": "Fon",
- "gur": "Frafra",
- "fr": "Fr\u025bnkye",
- "fur": "Friulian",
- "ff": "Fulah",
- "gaa": "Ga",
- "gag": "Gagauz",
- "gl": "Galician",
- "gan": "Gan Chinese",
- "lg": "Ganda",
- "gay": "Gayo",
- "gba": "Gbaya",
- "gez": "Geez",
- "ka": "Georgian",
- "aln": "Gheg Albanian",
- "bbj": "Ghomala",
- "glk": "Gilaki",
- "gil": "Gilbertese",
- "gom": "Goan Konkani",
- "gon": "Gondi",
- "gor": "Gorontalo",
- "got": "Gothic",
- "grb": "Grebo",
- "el": "Greek kasa",
- "gn": "Guarani",
- "gu": "Gujarati",
- "guz": "Gusii",
- "gwi": "Gwich\u02bcin",
- "de": "Gyaaman",
- "jv": "Gyabanis kasa",
- "ja": "Gyapan kasa",
- "hai": "Haida",
- "ht": "Haitian",
- "hak": "Hakka Chinese",
- "hu": "Hangri kasa",
- "ha": "Hausa",
- "haw": "Hawaiian",
- "he": "Hebrew",
- "hz": "Herero",
- "hil": "Hiligaynon",
- "hi": "Hindi",
- "ho": "Hiri Motu",
- "hit": "Hittite",
- "hmn": "Hmong",
- "hup": "Hupa",
- "iba": "Iban",
- "ibb": "Ibibio",
- "is": "Icelandic",
- "io": "Ido",
- "ig": "Igbo",
- "ilo": "Iloko",
- "smn": "Inari Sami",
- "id": "Indonihyia kasa",
- "izh": "Ingrian",
- "inh": "Ingush",
- "ia": "Interlingua",
- "ie": "Interlingue",
- "iu": "Inuktitut",
- "ik": "Inupiaq",
- "ga": "Irish",
- "it": "Italy kasa",
- "jam": "Jamaican Creole English",
- "kaj": "Jju",
- "dyo": "Jola-Fonyi",
- "jrb": "Judeo-Arabic",
- "jpr": "Judeo-Persian",
- "jut": "Jutish",
- "kbd": "Kabardian",
- "kea": "Kabuverdianu",
- "kab": "Kabyle",
- "kac": "Kachin",
- "kgp": "Kaingang",
- "kkj": "Kako",
- "kl": "Kalaallisut",
- "kln": "Kalenjin",
- "xal": "Kalmyk",
- "kam": "Kamba",
- "km": "Kambodia kasa",
- "kbl": "Kanembu",
- "kn": "Kannada",
- "kr": "Kanuri",
- "kaa": "Kara-Kalpak",
- "krc": "Karachay-Balkar",
- "krl": "Karelian",
- "ks": "Kashmiri",
- "csb": "Kashubian",
- "kaw": "Kawi",
- "kk": "Kazakh",
- "ken": "Kenyang",
- "kha": "Khasi",
- "kho": "Khotanese",
- "khw": "Khowar",
- "ki": "Kikuyu",
- "kmb": "Kimbundu",
- "krj": "Kinaray-a",
- "kiu": "Kirmanjki",
- "tlh": "Klingon",
- "bkm": "Kom",
- "kv": "Komi",
- "koi": "Komi-Permyak",
- "kg": "Kongo",
- "kok": "Konkani",
- "ko": "Korea kasa",
- "kfo": "Koro",
- "kos": "Kosraean",
- "avk": "Kotava",
- "khq": "Koyra Chiini",
- "ses": "Koyraboro Senni",
- "kpe": "Kpelle",
- "kri": "Krio",
- "kj": "Kuanyama",
- "kum": "Kumyk",
- "ku": "Kurdish",
- "kru": "Kurukh",
- "kut": "Kutenai",
- "nmg": "Kwasio",
- "zh": "Kyaena kasa",
- "cs": "Ky\u025bk kasa",
- "ky": "Kyrgyz",
- "quc": "K\u02bciche\u02bc",
- "lad": "Ladino",
- "lah": "Lahnda",
- "lkt": "Lakota",
- "lam": "Lamba",
- "lag": "Langi",
- "lo": "Lao",
- "ltg": "Latgalian",
- "la": "Latin",
- "es_419": "Latin American Spanish",
- "lv": "Latvian",
- "lzz": "Laz",
- "lez": "Lezghian",
- "lij": "Ligurian",
- "li": "Limburgish",
- "ln": "Lingala",
- "lfn": "Lingua Franca Nova",
- "lzh": "Literary Chinese",
- "lt": "Lithuanian",
- "liv": "Livonian",
- "jbo": "Lojban",
- "lmo": "Lombard",
- "nds": "Low German",
- "sli": "Lower Silesian",
- "dsb": "Lower Sorbian",
- "loz": "Lozi",
- "lu": "Luba-Katanga",
- "lua": "Luba-Lulua",
- "lui": "Luiseno",
- "smj": "Lule Sami",
- "lun": "Lunda",
- "luo": "Luo",
- "lb": "Luxembourgish",
- "luy": "Luyia",
- "mde": "Maba",
- "mk": "Macedonian",
- "jmc": "Machame",
- "mad": "Madurese",
- "maf": "Mafa",
- "mag": "Magahi",
- "vmf": "Main-Franconian",
- "mai": "Maithili",
- "mak": "Makasar",
- "mgh": "Makhuwa-Meetto",
- "kde": "Makonde",
- "mg": "Malagasy",
- "ms": "Malay kasa",
- "ml": "Malayalam",
- "mt": "Maltese",
- "mnc": "Manchu",
- "mdr": "Mandar",
- "man": "Mandingo",
- "mni": "Manipuri",
- "gv": "Manx",
- "mi": "Maori",
- "arn": "Mapuche",
- "mr": "Marathi",
- "chm": "Mari",
- "mh": "Marshallese",
- "mwr": "Marwari",
- "mas": "Masai",
- "mzn": "Mazanderani",
- "byv": "Medumba",
- "men": "Mende",
- "mwv": "Mentawai",
- "mer": "Meru",
- "mgo": "Meta\u02bc",
- "es_MX": "Mexican Spanish",
- "mic": "Micmac",
- "dum": "Middle Dutch",
- "enm": "Middle English",
- "frm": "Middle French",
- "gmh": "Middle High German",
- "mga": "Middle Irish",
- "nan": "Min Nan Chinese",
- "min": "Minangkabau",
- "xmf": "Mingrelian",
- "mwl": "Mirandese",
- "lus": "Mizo",
- "ar_001": "Modern Standard Arabic",
- "moh": "Mohawk",
- "mdf": "Moksha",
- "ro_MD": "Moldavian",
- "lol": "Mongo",
- "mn": "Mongolian",
- "mfe": "Morisyen",
- "ary": "Moroccan Arabic",
- "mos": "Mossi",
- "mul": "Multiple Languages",
- "mua": "Mundang",
- "ttt": "Muslim Tat",
- "mye": "Myene",
- "naq": "Nama",
- "na": "Nauru",
- "nv": "Navajo",
- "ng": "Ndonga",
- "nap": "Neapolitan",
- "new": "Newari",
- "ne": "N\u025bpal kasa",
- "sba": "Ngambay",
- "nnh": "Ngiemboon",
- "jgo": "Ngomba",
- "yrl": "Nheengatu",
- "nia": "Nias",
- "niu": "Niuean",
- "zxx": "No linguistic content",
- "nog": "Nogai",
- "nd": "North Ndebele",
- "frr": "Northern Frisian",
- "se": "Northern Sami",
- "nso": "Northern Sotho",
- "no": "Norwegian",
- "nb": "Norwegian Bokm\u00e5l",
- "nn": "Norwegian Nynorsk",
- "nov": "Novial",
- "nus": "Nuer",
- "nym": "Nyamwezi",
- "ny": "Nyanja",
- "nyn": "Nyankole",
- "tog": "Nyasa Tonga",
- "nyo": "Nyoro",
- "nzi": "Nzima",
- "nqo": "N\u02bcKo",
- "oc": "Occitan",
- "oj": "Ojibwa",
- "ang": "Old English",
- "fro": "Old French",
- "goh": "Old High German",
- "sga": "Old Irish",
- "non": "Old Norse",
- "peo": "Old Persian",
- "pro": "Old Proven\u00e7al",
- "or": "Oriya",
- "om": "Oromo",
- "osa": "Osage",
- "os": "Ossetic",
- "ota": "Ottoman Turkish",
- "pal": "Pahlavi",
- "pfl": "Palatine German",
- "pau": "Palauan",
- "pi": "Pali",
- "pam": "Pampanga",
- "pag": "Pangasinan",
- "pap": "Papiamento",
- "ps": "Pashto",
- "pdc": "Pennsylvania German",
- "fa": "P\u025b\u025bhyia kasa",
- "phn": "Phoenician",
- "pcd": "Picard",
- "pms": "Piedmontese",
- "pdt": "Plautdietsch",
- "pon": "Pohnpeian",
- "pnt": "Pontic",
- "pl": "P\u0254land kasa",
- "pt": "P\u0254\u0254tugal kasa",
- "prg": "Prussian",
- "pa": "Pungyabi kasa",
- "qu": "Quechua",
- "ru": "Rahyia kasa",
- "raj": "Rajasthani",
- "rap": "Rapanui",
- "rar": "Rarotongan",
- "rw": "Rewanda kasa",
- "rif": "Riffian",
- "rgn": "Romagnol",
- "rm": "Romansh",
- "rom": "Romany",
- "rof": "Rombo",
- "ro": "Romenia kasa",
- "root": "Root",
- "rtm": "Rotuman",
- "rug": "Roviana",
- "rn": "Rundi",
- "rue": "Rusyn",
- "rwk": "Rwa",
- "ssy": "Saho",
- "sah": "Sakha",
- "sam": "Samaritan Aramaic",
- "saq": "Samburu",
- "sm": "Samoan",
- "sgs": "Samogitian",
- "sad": "Sandawe",
- "sg": "Sango",
- "sbp": "Sangu",
- "sa": "Sanskrit",
- "sat": "Santali",
- "sc": "Sardinian",
- "sas": "Sasak",
- "sdc": "Sassarese Sardinian",
- "stq": "Saterland Frisian",
- "saz": "Saurashtra",
- "sco": "Scots",
- "gd": "Scottish Gaelic",
- "sly": "Selayar",
- "sel": "Selkup",
- "seh": "Sena",
- "see": "Seneca",
- "sr": "Serbian",
- "sh": "Serbo-Croatian",
- "srr": "Serer",
- "sei": "Seri",
- "ksb": "Shambala",
- "shn": "Shan",
- "sn": "Shona",
- "ii": "Sichuan Yi",
- "scn": "Sicilian",
- "sid": "Sidamo",
- "bla": "Siksika",
- "szl": "Silesian",
- "zh_Hans": "Simplified Chinese",
- "sd": "Sindhi",
- "si": "Sinhala",
- "sms": "Skolt Sami",
- "den": "Slave",
- "sk": "Slovak",
- "sl": "Slovenian",
- "xog": "Soga",
- "sog": "Sogdien",
- "so": "Somalia kasa",
- "snk": "Soninke",
- "azb": "South Azerbaijani",
- "nr": "South Ndebele",
- "alt": "Southern Altai",
- "sma": "Southern Sami",
- "st": "Southern Sotho",
- "es": "Spain kasa",
- "srn": "Sranan Tongo",
- "zgh": "Standard Moroccan Tamazight",
- "suk": "Sukuma",
- "sux": "Sumerian",
- "su": "Sundanese",
- "sus": "Susu",
- "sw": "Swahili",
- "ss": "Swati",
- "sv": "Sweden kasa",
- "fr_CH": "Swiss French",
- "gsw": "Swiss German",
- "de_CH": "Swiss High German",
- "syr": "Syriac",
- "shi": "Tachelhit",
- "th": "Taeland kasa",
- "tl": "Tagalog",
- "ty": "Tahitian",
- "dav": "Taita",
- "tg": "Tajik",
- "tly": "Talysh",
- "tmh": "Tamashek",
- "ta": "Tamil kasa",
- "trv": "Taroko",
- "twq": "Tasawaq",
- "tt": "Tatar",
- "te": "Telugu",
- "ter": "Tereno",
- "teo": "Teso",
- "tet": "Tetum",
- "tr": "T\u025b\u025bki kasa",
- "bo": "Tibetan",
- "tig": "Tigre",
- "ti": "Tigrinya",
- "tem": "Timne",
- "tiv": "Tiv",
- "tli": "Tlingit",
- "tpi": "Tok Pisin",
- "tkl": "Tokelau",
- "to": "Tongan",
- "fit": "Tornedalen Finnish",
- "zh_Hant": "Traditional Chinese",
- "tkr": "Tsakhur",
- "tsd": "Tsakonian",
- "tsi": "Tsimshian",
- "ts": "Tsonga",
- "tn": "Tswana",
- "tcy": "Tulu",
- "tum": "Tumbuka",
- "aeb": "Tunisian Arabic",
- "tk": "Turkmen",
- "tru": "Turoyo",
- "tvl": "Tuvalu",
- "tyv": "Tuvinian",
- "tw": "Twi",
- "kcg": "Tyap",
- "udm": "Udmurt",
- "uga": "Ugaritic",
- "uk": "Ukren kasa",
- "umb": "Umbundu",
- "und": "Unknown Language",
- "hsb": "Upper Sorbian",
- "ur": "Urdu kasa",
- "ug": "Uyghur",
- "uz": "Uzbek",
- "vai": "Vai",
- "ve": "Venda",
- "vec": "Venetian",
- "vep": "Veps",
- "vi": "Vi\u025btnam kasa",
- "vo": "Volap\u00fck",
- "vro": "V\u00f5ro",
- "vot": "Votic",
- "vun": "Vunjo",
- "wa": "Walloon",
- "wae": "Walser",
- "war": "Waray",
- "wbp": "Warlpiri",
- "was": "Washo",
- "guc": "Wayuu",
- "cy": "Welsh",
- "vls": "West Flemish",
- "fy": "Western Frisian",
- "mrj": "Western Mari",
- "wal": "Wolaytta",
- "wo": "Wolof",
- "wuu": "Wu Chinese",
- "xh": "Xhosa",
- "hsn": "Xiang Chinese",
- "yav": "Yangben",
- "yao": "Yao",
- "yap": "Yapese",
- "ybb": "Yemba",
- "yi": "Yiddish",
- "yo": "Yoruba",
- "zap": "Zapotec",
- "dje": "Zarma",
- "zza": "Zaza",
- "zea": "Zeelandic",
- "zen": "Zenaga",
- "za": "Zhuang",
- "gbz": "Zoroastrian Dari",
- "zu": "Zulu",
- "zun": "Zuni"
-}
\ No newline at end of file
+ "en": "English",
+ "es": "Español",
+ "fr": "Français",
+ "de": "Deutsch",
+ "it": "Italiano",
+ "pt": "Português"
+}
diff --git a/packages/app/config/randomErrorImages.json b/packages/app/config/randomErrorImages.json
new file mode 100644
index 00000000..95f1c06e
--- /dev/null
+++ b/packages/app/config/randomErrorImages.json
@@ -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"
+]
diff --git a/packages/app/config/routes.js b/packages/app/config/routes.js
index dcef44ba..91947106 100755
--- a/packages/app/config/routes.js
+++ b/packages/app/config/routes.js
@@ -14,6 +14,11 @@ export default [
useLayout: "default",
public: true,
},
+ {
+ path: "/tv/*",
+ useLayout: "default",
+ centeredContent: false,
+ },
{
path: "/featured-event/*",
useLayout: "default",
@@ -31,7 +36,7 @@ export default [
{
path: "/music/*",
useLayout: "default",
- centeredContent: true,
+ centeredContent: false,
},
{
path: "/nfc/*",
diff --git a/packages/app/eslint.config.js b/packages/app/eslint.config.js
index e52a61a5..1ea58ebc 100644
--- a/packages/app/eslint.config.js
+++ b/packages/app/eslint.config.js
@@ -14,4 +14,10 @@ export default defineConfig([
languageOptions: { globals: globals.browser },
},
pluginReact.configs.flat.recommended,
+ {
+ rules: {
+ "react/jsx-uses-react": "off",
+ "react/react-in-jsx-scope": "off",
+ },
+ },
])
diff --git a/packages/app/package.json b/packages/app/package.json
index c3ae0ea1..995487c3 100755
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,91 +1,91 @@
{
- "name": "@comty/app",
- "version": "1.43.0@alpha",
- "license": "ComtyLicense",
- "main": "electron/main",
- "type": "module",
- "author": "RageStudio",
- "description": "A prototype of a social network.",
- "scripts": {
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview",
- "release": "node ./scripts/release.js",
- "postinstall": "./scripts/postinstall.sh",
- "eslint": "eslint"
- },
- "dependencies": {
- "@ant-design/icons": "^5.4.0",
- "@dnd-kit/core": "^6.0.8",
- "@dnd-kit/modifiers": "^9.0.0",
- "@dnd-kit/sortable": "^7.0.2",
- "@dnd-kit/utilities": "^3.2.2",
- "@emotion/react": "^11.13.0",
- "@emotion/styled": "^11.13.0",
- "@ffmpeg/ffmpeg": "^0.12.10",
- "@ffmpeg/util": "^0.12.1",
- "@mui/material": "^5.11.9",
- "@ragestudio/cordova-nfc": "^1.2.0",
- "@ragestudio/vessel": "^0.20.0",
- "@sentry/browser": "^7.64.0",
- "@tauri-apps/api": "^1.5.4",
- "@tsmx/human-readable": "^1.0.7",
- "antd": "^5.20.6",
- "axios": "^1.7.7",
- "bear-react-carousel": "^4.0.10-alpha.0",
- "classnames": "2.3.1",
- "comty.js": "^0.67.0",
- "d3": "^7.9.0",
- "dompurify": "^3.0.0",
- "fast-average-color": "^9.2.0",
- "fuse.js": "6.5.3",
- "hls.js": "^1.5.17",
- "howler": "2.2.3",
- "i18next": "21.6.6",
- "js-cookie": "3.0.1",
- "jsmediatags": "^3.9.7",
- "lottie-react": "^2.4.0",
- "luxon": "^3.0.4",
- "mime": "^3.0.0",
- "moment": "2.29.4",
- "motion": "^12.4.2",
- "music-metadata": "^11.2.1",
- "plyr": "^3.7.8",
- "prop-types": "^15.8.1",
- "qs": "^6.14.0",
- "react": "18.3.1",
- "react-beautiful-dnd": "^13.1.1",
- "react-color": "2.19.3",
- "react-countup": "^6.4.1",
- "react-dom": "18.3.1",
- "react-fast-marquee": "^1.3.5",
- "react-i18next": "11.15.3",
- "react-icons": "^5.4.0",
- "react-lazy-load-image-component": "^1.5.4",
- "react-markdown": "^8.0.3",
- "react-modal-image": "^2.6.0",
- "react-player": "^2.16.0",
- "react-rnd": "^10.4.14",
- "react-router-dom": "^6.26.2",
- "react-transition-group": "^4.4.5",
- "react-useanimations": "^2.10.0",
- "remark-gfm": "^3.0.1",
- "rxjs": "^7.5.5",
- "shaka-player": "^4.14.12",
- "store": "^2.0.12",
- "swapy": "^1.0.5",
- "ua-parser-js": "^1.0.36",
- "vaul": "^1.1.2",
- "vite": "^6.2.6"
- },
- "devDependencies": {
- "@eslint/js": "^9.26.0",
- "@octokit/rest": "^21.1.1",
- "7zip-min": "1.4.3",
- "dotenv": "16.0.3",
- "eslint": "^9.26.0",
- "eslint-plugin-react": "^7.37.5",
- "form-data": "^4.0.0",
- "globals": "^16.1.0"
- }
+ "name": "@comty/app",
+ "version": "1.44.0@alpha",
+ "license": "ComtyLicense",
+ "main": "electron/main",
+ "type": "module",
+ "author": "RageStudio",
+ "description": "A prototype of a social network.",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "release": "node ./scripts/release.js",
+ "postinstall": "./scripts/postinstall.sh",
+ "eslint": "eslint"
+ },
+ "dependencies": {
+ "@ant-design/icons": "^5.4.0",
+ "@dnd-kit/core": "^6.0.8",
+ "@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^7.0.2",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@emotion/react": "^11.13.0",
+ "@emotion/styled": "^11.13.0",
+ "@ffmpeg/ffmpeg": "^0.12.10",
+ "@ffmpeg/util": "^0.12.1",
+ "@mui/material": "^5.11.9",
+ "@ragestudio/cordova-nfc": "^1.2.0",
+ "@ragestudio/vessel": "^0.20.0",
+ "@sentry/browser": "^7.64.0",
+ "@tauri-apps/api": "^1.5.4",
+ "@tsmx/human-readable": "^1.0.7",
+ "antd": "^5.20.6",
+ "axios": "^1.7.7",
+ "bear-react-carousel": "^4.0.10-alpha.0",
+ "classnames": "2.3.1",
+ "comty.js": "^0.68.0",
+ "d3": "^7.9.0",
+ "dashjs": "^5.0.3",
+ "dompurify": "^3.0.0",
+ "fast-average-color": "^9.2.0",
+ "fuse.js": "6.5.3",
+ "hls.js": "^1.5.17",
+ "howler": "2.2.3",
+ "i18next": "21.6.6",
+ "js-cookie": "3.0.1",
+ "jsmediatags": "^3.9.7",
+ "lottie-react": "^2.4.0",
+ "luxon": "^3.0.4",
+ "mime": "^3.0.0",
+ "moment": "2.29.4",
+ "motion": "^12.4.2",
+ "music-metadata": "^11.2.1",
+ "plyr": "^3.7.8",
+ "prop-types": "^15.8.1",
+ "qs": "^6.14.0",
+ "react": "18.3.1",
+ "react-beautiful-dnd": "^13.1.1",
+ "react-color": "2.19.3",
+ "react-countup": "^6.4.1",
+ "react-dom": "18.3.1",
+ "react-fast-marquee": "^1.3.5",
+ "react-i18next": "11.15.3",
+ "react-icons": "^5.4.0",
+ "react-lazy-load-image-component": "^1.5.4",
+ "react-markdown": "^8.0.3",
+ "react-modal-image": "^2.6.0",
+ "react-player": "^2.16.0",
+ "react-rnd": "^10.4.14",
+ "react-router": "^7.6.2",
+ "react-transition-group": "^4.4.5",
+ "react-useanimations": "^2.10.0",
+ "remark-gfm": "^3.0.1",
+ "rxjs": "^7.5.5",
+ "store": "^2.0.12",
+ "swapy": "^1.0.5",
+ "ua-parser-js": "^1.0.36",
+ "vaul": "^1.1.2",
+ "vite": "^6.2.6"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.26.0",
+ "@octokit/rest": "^21.1.1",
+ "7zip-min": "1.4.3",
+ "dotenv": "16.0.3",
+ "eslint": "^9.26.0",
+ "eslint-plugin-react": "^7.37.5",
+ "form-data": "^4.0.0",
+ "globals": "^16.1.0"
+ }
}
diff --git a/packages/app/src/App.jsx b/packages/app/src/App.jsx
index 68462acc..58a521c3 100755
--- a/packages/app/src/App.jsx
+++ b/packages/app/src/App.jsx
@@ -1,5 +1,10 @@
import React from "react"
import { Runtime } from "@ragestudio/vessel"
+import * as Router from "@ragestudio/vessel/router"
+import routesDeclarations from "@config/routes"
+
+import onPageMount from "@hooks/onPageMount"
+
import { Helmet } from "react-helmet"
import * as Sentry from "@sentry/browser"
import { invoke } from "@tauri-apps/api/tauri"
@@ -14,7 +19,6 @@ import DesktopTopBar from "@components/DesktopTopBar"
import { ThemeProvider } from "@cores/style/style.core.jsx"
import Layout from "./layout"
-import * as Router from "./router"
import StaticMethods from "./statics/methods"
import StaticEvents from "./statics/events"
@@ -117,19 +121,19 @@ class ComtyApp extends React.Component {
/>
-
-
- {window.__TAURI__ && }
-
- {this.state.firstInitialized && (
-
- )}
-
-
-
+
+
+ {window.__TAURI__ && }
+
+ {this.state.firstInitialized && (
+
+ )}
+
+
)
}
diff --git a/packages/app/src/classes/AuthManager/index.js b/packages/app/src/classes/AuthManager/index.js
index 6203eeed..b439266c 100644
--- a/packages/app/src/classes/AuthManager/index.js
+++ b/packages/app/src/classes/AuthManager/index.js
@@ -70,6 +70,11 @@ export default class AuthManager {
},
}
+ handleUserDataUpdate = (data) => {
+ this.state.user = data
+ app.eventBus.emit("self:user:update", data)
+ }
+
initialize = async () => {
const token = await SessionModel.token
@@ -87,6 +92,8 @@ export default class AuthManager {
app.userData = user
this.state.user = user
+
+ return user
}
flush = async () => {
diff --git a/packages/app/src/components/ErrorCatcher/index.jsx b/packages/app/src/components/ErrorCatcher/index.jsx
new file mode 100644
index 00000000..557ef628
--- /dev/null
+++ b/packages/app/src/components/ErrorCatcher/index.jsx
@@ -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 (
+
+
+
+
+
+
+ Something went wrong
+
+
+ Path: {props.path || "Unknown"}
+
+
+ ID: {errorId}
+
+
+
+
+
+ Message:
+
+ {error?.message || "Unknown error"}
+
+
+
+
+
+
+ Error Stack
+
+
+ {error?.stack || "No stack trace available"}
+
+
+
+
+
+ Component Stack
+
+
+ {errorInfo?.componentStack ||
+ "No component stack available"}
+
+
+
+
+
+ Excuse
+
+
+ {excuses[Math.floor(Math.random() * excuses.length)]}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default PageErrorBoundary
diff --git a/packages/app/src/components/FollowButton/index.jsx b/packages/app/src/components/FollowButton/index.jsx
index 403544a3..431555cc 100755
--- a/packages/app/src/components/FollowButton/index.jsx
+++ b/packages/app/src/components/FollowButton/index.jsx
@@ -1,26 +1,28 @@
-import React from "react"
import { Button } from "antd"
import classnames from "classnames"
import "./index.less"
-export default (props) => {
- return
-
- {props.count}
- {props.self && " Followers"}
-
- {
- !props.self &&
- }
-
+const FollowButton = (props) => {
+ return (
+
+
+ {props.count}
+ {props.self && " Followers"}
+
+ {!props.self && (
+
+ )}
+
+ )
}
+
+export default FollowButton
diff --git a/packages/app/src/components/FollowersList/index.jsx b/packages/app/src/components/FollowersList/index.jsx
index d8508f3d..18a36fab 100755
--- a/packages/app/src/components/FollowersList/index.jsx
+++ b/packages/app/src/components/FollowersList/index.jsx
@@ -1,101 +1,98 @@
import React from "react"
import * as antd from "antd"
import { Icons } from "@components/Icons"
+import LoadMore from "@components/LoadMore"
+import UserPreview from "@components/UserPreview"
import FollowsModel from "@models/follows"
import "./index.less"
-const FollowerItem = ({
- follower,
- onClick,
- index
-}) => {
- return
-
-
-
-
- {follower.fullName ?? follower.username}
-
-
-
-
- @{follower.username}
-
-
-
-
+const FollowerItem = React.memo(({ data }) => {
+ return
+})
+
+FollowerItem.displayName = "FollowerItem"
+
+const FollowersList = (props) => {
+ const [loading, setLoading] = React.useState(false)
+ const [followers, setFollowers] = React.useState(props.followers ?? [])
+ const [hasMore, setHasMore] = React.useState(true)
+
+ const page = React.useRef(0)
+ const userId = React.useRef(props.user_id)
+
+ const loadFollowers = React.useCallback(async () => {
+ setLoading(true)
+
+ console.log(
+ `Loading Followers for [${userId.current}] page [${page.current}]`,
+ )
+
+ const followers = await FollowsModel.getFollowers(userId.current, {
+ fetchData: true,
+ limit: 10,
+ page: page.current,
+ }).catch((err) => {
+ console.error(err)
+ app.message.error("Failed to fetch followers")
+
+ 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 (
+ }>
+ It's seems this user has no followers, yet.
+ Maybe you can help them out?
+
+ )
+ }
+
+ return (
+
+ {followers.map((data) => {
+ return
+ })}
+
+ )
}
-export default (props) => {
- 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
- }
-
- if (followers.length === 0) {
- return }
- >
-
- It's seems this user has no followers, yet.
-
-
- Maybe you can help them out?
-
-
- }
-
- return
- {
- followers.map((follower, index) => {
- return goToProfile(follower.username)}
- />
- })
- }
-
-}
\ No newline at end of file
+export default FollowersList
diff --git a/packages/app/src/components/FollowersList/index.less b/packages/app/src/components/FollowersList/index.less
index 1e4c0c7c..48645442 100755
--- a/packages/app/src/components/FollowersList/index.less
+++ b/packages/app/src/components/FollowersList/index.less
@@ -33,37 +33,18 @@
}
.followersList {
- .follower {
- display: inline-flex;
- align-items: center;
+ display: flex;
+ flex-direction: column;
- width: 100%;
+ gap: 10px;
- margin-bottom: 10px;
- padding: 10px;
+ .userPreview {
+ background-color: var(--background-color-primary);
border-radius: 8px;
- border: 1px solid var(--border-color);
- cursor: pointer;
+ padding: 5px 10px;
- h2 {
- margin: 0;
- font-size: 22px;
- line-height: 26px;
- }
-
- >div {
- margin-right: 10px;
- }
-
- .names {
- display: flex;
- flex-direction: column;
- }
+ width: 100%;
}
-
- .follower:last-child {
- margin-bottom: 0;
- }
-}
\ No newline at end of file
+}
diff --git a/packages/app/src/components/Image/index.jsx b/packages/app/src/components/Image/index.jsx
index 7415bee6..49b3d002 100755
--- a/packages/app/src/components/Image/index.jsx
+++ b/packages/app/src/components/Image/index.jsx
@@ -3,4 +3,4 @@ import { LazyLoadImage } from "react-lazy-load-image-component"
import "react-lazy-load-image-component/src/effects/blur.css"
-export default (props) =>
\ No newline at end of file
+export default (props) =>
diff --git a/packages/app/src/components/LiveChat/index.jsx b/packages/app/src/components/LiveChat/index.jsx
index f53dc31f..b56f1dc8 100755
--- a/packages/app/src/components/LiveChat/index.jsx
+++ b/packages/app/src/components/LiveChat/index.jsx
@@ -102,6 +102,9 @@ export default class LiveChat extends React.Component {
}
leaveSocketRoom = () => {
+ if (!this.socket) {
+ return false
+ }
if (this.state.connectionEnd) {
return false
}
diff --git a/packages/app/src/components/LoadMore/index.jsx b/packages/app/src/components/LoadMore/index.jsx
index 9ed6f0a3..b1557f51 100755
--- a/packages/app/src/components/LoadMore/index.jsx
+++ b/packages/app/src/components/LoadMore/index.jsx
@@ -3,59 +3,65 @@ import classnames from "classnames"
import "./index.less"
-export default React.forwardRef((props, ref) => {
- const {
- className,
- children,
- hasMore,
- loadingComponent,
- noResultComponent,
- contentProps = {},
- } = props
+const LoadMore = React.forwardRef((props, ref) => {
+ const {
+ className,
+ children,
+ hasMore = false,
+ loadingComponent,
+ contentProps = {},
+ } = props
- let observer = null
+ const nodeRef = React.useRef(null)
- const insideViewportCb = (entries) => {
- const { fetching, onBottom } = props
+ let observer = null
- entries.forEach(element => {
- if (element.intersectionRatio > 0 && !fetching) {
- onBottom()
- }
- })
- }
+ const insideViewportCb = (entries) => {
+ const { fetching, onBottom, hasMore } = props
- React.useEffect(() => {
- try {
- const node = document.getElementById("bottom")
+ if (!hasMore) {
+ return false
+ }
- observer = new IntersectionObserver(insideViewportCb)
- observer.observe(node)
- } catch (err) {
- console.log("err in finding node", err)
- }
+ entries.forEach((element) => {
+ if (element.intersectionRatio > 0 && !fetching) {
+ onBottom()
+ }
+ })
+ }
- return () => {
- observer.disconnect()
- observer = null
- }
- }, [])
+ React.useEffect(() => {
+ try {
+ observer = new IntersectionObserver(insideViewportCb)
+ observer.observe(nodeRef.current)
+ } catch (err) {
+ console.log("err in finding node", err)
+ }
- return
- {children}
+ return () => {
+ observer.disconnect()
+ observer = null
+ }
+ }, [])
-
+ return (
+
+ {children}
-
- {loadingComponent && React.createElement(loadingComponent)}
-
-
-})
\ No newline at end of file
+ {/*
*/}
+
+
+ {loadingComponent && React.createElement(loadingComponent)}
+
+
+ )
+})
+
+LoadMore.displayName = "LoadMore"
+
+export default LoadMore
diff --git a/packages/app/src/components/Music/PlaylistView/list.jsx b/packages/app/src/components/Music/PlaylistView/list.jsx
index 9fcfe14d..58dd0561 100644
--- a/packages/app/src/components/Music/PlaylistView/list.jsx
+++ b/packages/app/src/components/Music/PlaylistView/list.jsx
@@ -1,9 +1,10 @@
-import React from "react"
import * as antd from "antd"
import classnames from "classnames"
-import { WithPlayerContext } from "@contexts/WithPlayerContext"
-import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
+import {
+ WithPlayerContext,
+ usePlayerStateContext,
+} from "@contexts/WithPlayerContext"
import LoadMore from "@components/LoadMore"
import { Icons } from "@components/Icons"
@@ -24,6 +25,8 @@ const TrackList = ({
hasMore,
noHeader = false,
}) => {
+ const [{ track_manifest, playback_status }] = usePlayerStateContext()
+
const showListHeader = !noHeader && (tracks.length > 0 || searchResults)
if (!searchResults && tracks.length === 0) {
@@ -62,7 +65,7 @@ const TrackList = ({
key={item._id}
order={item._id} // Consider using index if order matters
track={item}
- onPlay={() => onTrackClick(item)}
+ onPlay={onTrackClick}
changeState={(update) =>
onTrackStateChange(item._id, update)
}
@@ -76,19 +79,19 @@ const TrackList = ({
onBottom={onLoadMore}
hasMore={hasMore}
>
-
- {tracks.map((item, index) => (
- onTrackClick(item)}
- changeState={(update) =>
- onTrackStateChange(item._id, update)
- }
- />
- ))}
-
+ {tracks.map((item, index) => (
+
+ ))}
)}
diff --git a/packages/app/src/components/Music/Track/index.jsx b/packages/app/src/components/Music/Track/index.jsx
index e185eb4b..179879b3 100755
--- a/packages/app/src/components/Music/Track/index.jsx
+++ b/packages/app/src/components/Music/Track/index.jsx
@@ -2,140 +2,37 @@ import React from "react"
import * as antd from "antd"
import classnames from "classnames"
-import RGBStringToValues from "@utils/rgbToValues"
-
import ImageViewer from "@components/ImageViewer"
import { Icons } from "@components/Icons"
-import MusicModel from "@models/music"
+import MenuItemsBase from "./menuItems"
+import MenuHandlers from "./menuHandlers"
-import { usePlayerStateContext } from "@contexts/WithPlayerContext"
+import RGBStringToValues from "@utils/rgbToValues"
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
import "./index.less"
-const handlers = {
- like: async (ctx, track) => {
- await MusicModel.toggleItemFavourite("track", track._id, true)
+function secondsToIsoTime(seconds) {
+ const minutes = Math.floor(seconds / 60)
- ctx.changeState({
- liked: true,
- })
- ctx.closeMenu()
- },
- unlike: async (ctx, track) => {
- await MusicModel.toggleItemFavourite("track", track._id, false)
-
- ctx.changeState({
- liked: false,
- })
- ctx.closeMenu()
- },
- add_to_playlist: async (ctx, track) => {},
- add_to_queue: async (ctx, track) => {
- await app.cores.player.queue.add(track)
- },
- play_next: async (ctx, track) => {
- await app.cores.player.queue.add(track, { next: true })
- },
+ return `${minutes}:${Math.floor(seconds % 60)
+ .toString()
+ .padStart(2, "0")}`
}
-const Track = (props) => {
- const [{ loading, track_manifest, playback_status }] =
- usePlayerStateContext()
-
+const Track = React.memo((props) => {
const playlist_ctx = React.useContext(PlaylistContext)
const [moreMenuOpened, setMoreMenuOpened] = React.useState(false)
+ const [liked, setLiked] = React.useState(props.track.liked)
- const isCurrent = track_manifest?._id === props.track._id
- const isPlaying = isCurrent && playback_status === "playing"
+ const trackDuration = React.useMemo(() => {
+ return props.track?.metadata?.duration ?? props.track?.duration
+ }, [props.track])
- const handleClickPlayBtn = React.useCallback(() => {
- if (typeof props.onPlay === "function") {
- return props.onPlay(props.track)
- }
-
- if (typeof props.onClickPlayBtn === "function") {
- props.onClickPlayBtn(props.track)
- }
-
- if (!isCurrent) {
- app.cores.player.start(props.track)
- } else {
- app.cores.player.playback.toggle()
- }
- })
-
- const handleOnClickItem = React.useCallback(() => {
- if (props.onClick) {
- props.onClick(props.track)
- }
-
- if (app.isMobile) {
- handleClickPlayBtn()
- }
- })
-
- const handleMoreMenuOpen = () => {
- if (app.isMobile) {
- return
- }
-
- return setMoreMenuOpened((prev) => {
- return !prev
- })
- }
-
- const handleMoreMenuItemClick = (e) => {
- const { key } = e
-
- if (typeof handlers[key] === "function") {
- return handlers[key](
- {
- closeMenu: () => {
- setMoreMenuOpened(false)
- },
- changeState: props.changeState,
- },
- props.track,
- )
- }
- }
-
- const moreMenuItems = React.useMemo(() => {
- const items = [
- {
- key: "like",
- icon: ,
- label: "Like",
- },
- {
- key: "share",
- icon: ,
- label: "Share",
- disabled: true,
- },
- {
- key: "add_to_playlist",
- icon: ,
- label: "Add to playlist",
- disabled: true,
- },
- {
- type: "divider",
- },
- {
- key: "add_to_queue",
- icon: ,
- label: "Add to queue",
- },
- {
- key: "play_next",
- icon: ,
- label: "Play next",
- },
- ]
+ const menuItems = React.useMemo(() => {
+ const items = [...MenuItemsBase]
if (props.track.liked) {
items[0] = {
@@ -162,19 +59,68 @@ const Track = (props) => {
return items
}, [props.track])
+ const handleClickPlayBtn = React.useCallback(() => {
+ if (typeof props.onPlay === "function") {
+ return props.onPlay(props.track)
+ }
+
+ if (typeof props.onClickPlayBtn === "function") {
+ props.onClickPlayBtn(props.track)
+ }
+
+ if (!props.isCurrent) {
+ app.cores.player.start(props.track)
+ } else {
+ app.cores.player.playback.toggle()
+ }
+ }, [props.isCurrent])
+
+ const handleOnClickItem = React.useCallback(() => {
+ if (props.onClick) {
+ props.onClick(props.track)
+ }
+
+ if (app.isMobile) {
+ handleClickPlayBtn()
+ }
+ }, [props.track])
+
+ const handleMoreMenuOpen = React.useCallback(() => {
+ if (app.isMobile) {
+ return
+ }
+
+ return setMoreMenuOpened((prev) => {
+ return !prev
+ })
+ }, [])
+
+ const handleMoreMenuItemClick = React.useCallback(
+ (e) => {
+ const { key } = e
+
+ if (typeof MenuHandlers[key] === "function") {
+ return MenuHandlers[key](
+ {
+ close: () => {
+ setMoreMenuOpened(false)
+ },
+ setLiked: setLiked,
+ },
+ props.track,
+ )
+ }
+ },
+ [props.track],
+ )
+
return (
@@ -193,7 +139,7 @@ const Track = (props) => {
type="primary"
shape="circle"
icon={
- isPlaying ? (
+ props.isPlaying ? (
) : (
@@ -214,13 +160,19 @@ const Track = (props) => {
className="music-track_details"
onClick={handleOnClickItem}
>
-
-
+
+
{props.track.service === "tidal" && (
)}
{props.track.title}
+
+ {props.track.version && (
+
+ ({props.track.version})
+
+ )}
@@ -233,24 +185,31 @@ const Track = (props) => {
+ {trackDuration && (
+
+
+ {secondsToIsoTime(trackDuration)}
+
+ )}
+
- }
- />
+
+
+
)
-}
+})
+
+Track.displayName = "Track"
export default Track
diff --git a/packages/app/src/components/Music/Track/index.less b/packages/app/src/components/Music/Track/index.less
index 482e9351..a9d27e0a 100755
--- a/packages/app/src/components/Music/Track/index.less
+++ b/packages/app/src/components/Music/Track/index.less
@@ -49,6 +49,11 @@ html {
.music-track_orderIndex {
opacity: 0;
}
+
+ .music-track_play_duration {
+ opacity: 1;
+ width: fit-content;
+ }
}
}
}
@@ -113,6 +118,13 @@ html {
.music-track_play {
position: relative;
+ display: flex;
+ flex-direction: row;
+
+ align-items: center;
+
+ gap: 10px;
+
transition: all 150ms ease-in-out;
cursor: pointer;
@@ -200,13 +212,31 @@ html {
display: flex;
flex-direction: column;
- width: 100%;
+ width: 75%;
color: var(--text-color);
- .music-track_title {
- font-size: 1rem;
- //font-family: "Space Grotesk", sans-serif;
+ .music-track_titles {
+ display: flex;
+ flex-direction: row;
+
+ align-items: center;
+
+ gap: 5px;
+
+ .music-track_title {
+ display: flex;
+ flex-direction: row;
+
+ align-items: center;
+ font-size: 1rem;
+ gap: 5px;
+ }
+
+ .music-track_version {
+ font-size: 0.8rem;
+ opacity: 0.8;
+ }
}
.music-track_artist {
@@ -225,10 +255,27 @@ html {
align-items: center;
justify-content: center;
- gap: 10px;
+ gap: 5px;
margin-left: auto;
color: var(--text-color);
+
+ .music-track_play_duration {
+ display: flex;
+ flex-direction: row;
+
+ align-items: center;
+ justify-content: center;
+
+ gap: 5px;
+
+ font-size: 0.8rem;
+ }
+
+ .music-track_more-menu {
+ padding: 10px;
+ cursor: pointer;
+ }
}
}
diff --git a/packages/app/src/components/Music/Track/menuHandlers.js b/packages/app/src/components/Music/Track/menuHandlers.js
new file mode 100644
index 00000000..5dd7ede3
--- /dev/null
+++ b/packages/app/src/components/Music/Track/menuHandlers.js
@@ -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)
+ },
+}
diff --git a/packages/app/src/components/Music/Track/menuItems.jsx b/packages/app/src/components/Music/Track/menuItems.jsx
new file mode 100644
index 00000000..1a53cc2e
--- /dev/null
+++ b/packages/app/src/components/Music/Track/menuItems.jsx
@@ -0,0 +1,42 @@
+import { Icons } from "@components/Icons"
+
+export default [
+ {
+ key: "like",
+ icon:
,
+ label: "Like",
+ },
+ {
+ key: "share",
+ icon:
,
+ label: "Share",
+ disabled: true,
+ },
+ {
+ key: "add_to_playlist",
+ icon:
,
+ label: "Add to playlist",
+ disabled: true,
+ },
+ {
+ type: "divider",
+ },
+ {
+ key: "add_to_queue",
+ icon:
,
+ label: "Add to queue",
+ },
+ {
+ key: "play_next",
+ icon:
,
+ label: "Play next",
+ },
+ {
+ type: "divider",
+ },
+ {
+ key: "copy_id",
+ icon:
,
+ label: "Copy ID",
+ },
+]
diff --git a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/LyricsEditor/index.jsx b/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/LyricsEditor/index.jsx
deleted file mode 100644
index b4d00173..00000000
--- a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/LyricsEditor/index.jsx
+++ /dev/null
@@ -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 (
-
-
-
-
- Lyrics
-
-
-
-
Language:
-
-
- (option?.label.toLowerCase() ?? "").includes(
- input.toLowerCase(),
- )
- }
- filterSort={(optionA, optionB) =>
- (optionA?.label.toLowerCase() ?? "")
- .toLowerCase()
- .localeCompare(
- (
- optionB?.label.toLowerCase() ?? ""
- ).toLowerCase(),
- )
- }
- onChange={setSelectedLang}
- />
-
- {selectedLang && (
- {
- updateCurrentLang(data.url)
- }}
- accept={["text/*"]}
- />
- )}
-
-
-
- {!langs[selectedLang] && (
-
No lyrics uploaded for this language
- )}
-
- {langs[selectedLang] && (
-
- )}
-
- )
-}
-
-export default LyricsEditor
diff --git a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/LyricsEditor/index.less b/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/LyricsEditor/index.less
deleted file mode 100644
index be94fc7b..00000000
--- a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/LyricsEditor/index.less
+++ /dev/null
@@ -1,11 +0,0 @@
-.lyrics-editor {
- display: flex;
- flex-direction: column;
-
- gap: 20px;
- padding: 15px;
-
- border-radius: 12px;
-
- background-color: var(--background-color-accent);
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/VideoEditor/index.jsx b/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/VideoEditor/index.jsx
deleted file mode 100644
index 52020552..00000000
--- a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/VideoEditor/index.jsx
+++ /dev/null
@@ -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 (
-
-
-
- Video
-
-
- {!props.videoSourceURL && (
-
}
- description="No video"
- />
- )}
-
- {props.videoSourceURL && (
-
-
-
- )}
-
-
-
-
-
- Start video sync at
-
-
- {props.startSyncAt ?? "not set"}
-
-
-
-
Set to:
-
-
{
- handleChange("startSyncAt", str)
- }}
- />
-
-
-
-
-
{
- handleChange("videoSourceURL", response.url)
- }}
- accept={["video/*"]}
- headers={{
- transformations: "mq-hls",
- }}
- disabled={props.loading}
- >
- Upload video
-
- or
-
{
- handleChange("videoSourceURL", e.target.value)
- }}
- value={props.videoSourceURL}
- disabled={props.loading}
- />
-
-
- )
-}
-
-export default VideoEditor
diff --git a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/VideoEditor/index.less b/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/VideoEditor/index.less
deleted file mode 100644
index 6def957e..00000000
--- a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/components/VideoEditor/index.less
+++ /dev/null
@@ -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;
- }
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/index.jsx b/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/index.jsx
deleted file mode 100644
index 39f7fb0f..00000000
--- a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/index.jsx
+++ /dev/null
@@ -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
- }
-
- return
-
{this.props.track.title}
-
- {
- this.setState({
- videoOptions: {
- ...this.state.videoOptions,
- [key]: value
- }
- })
- }}
- />
-
- {
- this.setState({
- lyricsOptions: {
- ...this.state.lyricsOptions,
- [key]: value
- }
- })
- }}
- />
-
- }
-}
-
-export default EnhancedLyricsEditor
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/index.less b/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/index.less
deleted file mode 100644
index 84319bb9..00000000
--- a/packages/app/src/components/MusicStudio/EnhancedLyricsEditor/index.less
+++ /dev/null
@@ -1,6 +0,0 @@
-.enhanced_lyrics_editor-wrapper {
- display: flex;
- flex-direction: column;
-
- gap: 20px;
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/LyricsTextView/index.jsx b/packages/app/src/components/MusicStudio/LyricsTextView/index.jsx
deleted file mode 100644
index b30828b7..00000000
--- a/packages/app/src/components/MusicStudio/LyricsTextView/index.jsx
+++ /dev/null
@@ -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
- }
-
- if (loading) {
- return
- }
-
- if (!lyrics) {
- return
No lyrics provided
- }
-
- return
- {
- lyrics?.map((line, index) => {
- return
- {line}
-
- })
- }
-
-}
-
-export default LyricsTextView
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/LyricsTextView/index.less b/packages/app/src/components/MusicStudio/LyricsTextView/index.less
deleted file mode 100644
index 9abe5f1b..00000000
--- a/packages/app/src/components/MusicStudio/LyricsTextView/index.less
+++ /dev/null
@@ -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;
- }
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/MyReleasesList/index.jsx b/packages/app/src/components/MusicStudio/MyReleasesList/index.jsx
deleted file mode 100644
index 973a515b..00000000
--- a/packages/app/src/components/MusicStudio/MyReleasesList/index.jsx
+++ /dev/null
@@ -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
-
-
Your Releases
-
-
- {
- L_MyReleases && !E_MyReleases &&
- }
- {
- E_MyReleases &&
- }
- {
- !L_MyReleases && !E_MyReleases && R_MyReleases && R_MyReleases.items.length === 0 &&
- }
-
- {
- !L_MyReleases && !E_MyReleases && R_MyReleases && R_MyReleases.items.length > 0 &&
- {
- R_MyReleases.items.map((item) => {
- return
- })
- }
-
- }
-
-}
-
-export default MyReleasesList
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx
deleted file mode 100644
index ae6e7576..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx
+++ /dev/null
@@ -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 (
-
- )
- }
-
- if (loading) {
- return
- }
-
- const Tab = Tabs.find(({ key }) => key === selectedTab)
-
- const CustomPageProps = {
- close: () => {
- renderCustomPage(null, null)
- },
- }
-
- return (
-
-
- {customPage && (
-
- {customPage.header && (
-
-
-
}
- onClick={() =>
- renderCustomPage(null, null)
- }
- />
-
-
{customPage.header}
-
-
- {Array.isArray(customPageActions) &&
- customPageActions.map((action, index) => {
- return (
-
{
- if (
- typeof action.onClick ===
- "function"
- ) {
- await action.onClick()
- }
-
- if (action.fireEvent) {
- app.eventBus.emit(
- action.fireEvent,
- )
- }
- }}
- disabled={action.disabled}
- >
- {action.label}
-
- )
- })}
-
- )}
-
- {customPage.content &&
- (React.isValidElement(customPage.content)
- ? React.cloneElement(customPage.content, {
- ...CustomPageProps,
- ...customPage.props,
- })
- : React.createElement(customPage.content, {
- ...CustomPageProps,
- ...customPage.props,
- }))}
-
- )}
- {!customPage && (
- <>
-
-
setSelectedTab(e.key)}
- selectedKeys={[selectedTab]}
- items={Tabs}
- mode="vertical"
- />
-
-
-
- ) : (
-
- )
- }
- disabled={
- submitting || loading || !canFinish()
- }
- loading={submitting}
- >
- {release_id !== "new" ? "Save" : "Release"}
-
-
- {release_id !== "new" ? (
-
}
- disabled={loading}
- onClick={handleDelete}
- >
- Delete
-
- ) : null}
-
- {release_id !== "new" ? (
-
}
- onClick={() =>
- app.location.push(
- `/music/list/${globalState._id}`,
- )
- }
- >
- Go to release
-
- ) : null}
-
-
-
-
- {submitError && (
-
- )}
- {!Tab && (
-
- )}
- {Tab &&
- React.createElement(Tab.render, {
- release: globalState,
-
- state: globalState,
- setState: setGlobalState,
-
- references: {
- basic: basicInfoRef,
- },
- })}
-
- >
- )}
-
-
- )
-}
-
-export default ReleaseEditor
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/index.less b/packages/app/src/components/MusicStudio/ReleaseEditor/index.less
deleted file mode 100644
index 80e4f075..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/index.less
+++ /dev/null
@@ -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;
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/BasicInformation/index.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/BasicInformation/index.jsx
deleted file mode 100644
index 78d64114..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/BasicInformation/index.jsx
+++ /dev/null
@@ -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:
,
- },
- {
- value: "ep",
- label: "Episode",
- icon:
,
- },
- {
- value: "album",
- label: "Album",
- icon:
,
- },
- {
- value: "compilation",
- label: "Compilation",
- icon:
,
- }
-]
-
-const BasicInformation = (props) => {
- const { release, onFinish, setState, state } = props
-
- async function onFormChange(change) {
- setState((globalState) => {
- return {
- ...globalState,
- ...change
- }
- })
- }
-
- return
-
Release Information
-
-
-
-
-
-
- {
- release._id && ID>}
- name="_id"
- initialValue={release._id}
- disabled
- >
-
-
- }
-
- Title>}
- name="title"
- rules={[{ required: true, message: "Input a title for the release" }]}
- initialValue={state?.title}
- >
-
-
-
- Type>}
- name="type"
- rules={[{ required: true, message: "Select a type for the release" }]}
- initialValue={state?.type}
- >
-
-
-
- Public>}
- name="public"
- initialValue={state?.public}
- >
-
-
-
-
-}
-
-export default BasicInformation
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.jsx
deleted file mode 100644
index 23f4fcec..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.jsx
+++ /dev/null
@@ -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:
,
- props: {
- track: track,
- },
- })
- }
-
- async function onClickRemoveTrack() {
- props.onDelete(track.uid)
- }
-
- return (
-
-
-
-
- {props.index + 1}
-
-
- {progress !== null &&
}
-
-
-
-
{getTitleString({ track, progress })}
-
-
-
- }
- disabled={props.disabled}
- />
-
-
}
- onClick={onClickEditTrack}
- disabled={props.disabled}
- />
-
-
-
-
-
-
- )
-}
-
-export default TrackListItem
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.less b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.less
deleted file mode 100644
index 5b67c3f3..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.less
+++ /dev/null
@@ -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;
- }
- }
- }
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.jsx
deleted file mode 100644
index 14c2c29d..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.jsx
+++ /dev/null
@@ -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 (
-
-
- {this.state.items.length === 0 ? (
-
- ) : (
- }
- >
- Add another
-
- )}
-
-
-
- {this.state.items.length === 0 && (
-
- )}
-
- {this.state.items.map((track, index) => {
- const progress = this.getUploadProgress(track.uid)
-
- return (
-
- 0}
- />
-
- )
- })}
-
-
- )
- }
-}
-
-const ReleaseTracks = (props) => {
- const { state, setState } = props
-
- return (
-
-
Tracks
-
- {
- setState({
- ...state,
- ...managerState,
- })
- }}
- />
-
- )
-}
-
-export default ReleaseTracks
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.less b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.less
deleted file mode 100644
index 9ab8d7e2..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.less
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/sortableList.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/sortableList.jsx
deleted file mode 100644
index ae29526f..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/sortableList.jsx
+++ /dev/null
@@ -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 (
-
- {children({
- ...attributes,
- ...listeners,
- ref: setActivatorNodeRef,
- style: { cursor: "grab", touchAction: "none" },
- })}
-
- )
-}
-
-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 (
-
-
- {items.map((item, index) => (
-
- {(handleProps) => (
-
- {renderItem(item, index)}
-
-
- )}
-
- ))}
-
-
- )
-}
diff --git a/packages/app/src/components/MusicStudio/ReleaseItem/index.jsx b/packages/app/src/components/MusicStudio/ReleaseItem/index.jsx
deleted file mode 100644
index 431f6a15..00000000
--- a/packages/app/src/components/MusicStudio/ReleaseItem/index.jsx
+++ /dev/null
@@ -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
-
-
-
- {release.title}
-
-
-
-
-
- {release.type}
-
-
-
-
- {release._id}
-
-
- {/*
-
- {release.analytics?.listen_count ?? 0}
-
*/}
-
-
-}
-
-export default ReleaseItem
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/TrackEditor/index.jsx b/packages/app/src/components/MusicStudio/TrackEditor/index.jsx
deleted file mode 100644
index caf1edd5..00000000
--- a/packages/app/src/components/MusicStudio/TrackEditor/index.jsx
+++ /dev/null
@@ -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 (
-
-
-
-
- Cover
-
-
-
handleChange("cover", url)}
- extraActions={[
-
- Use Parent
- ,
- ]}
- />
-
-
-
-
-
- Title
-
-
-
handleChange("title", e.target.value)}
- />
-
-
-
-
-
- Artist
-
-
-
handleChange("artist", e.target.value)}
- />
-
-
-
-
-
- Album
-
-
-
handleChange("album", e.target.value)}
- />
-
-
-
-
-
- Explicit
-
-
-
handleChange("explicit", value)}
- />
-
-
-
-
-
- Public
-
-
-
handleChange("public", value)}
- />
-
-
-
-
-
- Enhanced Lyrics
-
-
-
-
- Edit
-
-
- {!track.params._id && (
-
- You cannot edit Video and Lyrics without release
- first
-
- )}
-
-
-
- )
-}
-
-export default TrackEditor
diff --git a/packages/app/src/components/MusicStudio/TrackEditor/index.less b/packages/app/src/components/MusicStudio/TrackEditor/index.less
deleted file mode 100644
index 1b8bdacb..00000000
--- a/packages/app/src/components/MusicStudio/TrackEditor/index.less
+++ /dev/null
@@ -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%;
- }
- }
-}
\ No newline at end of file
diff --git a/packages/app/src/components/PostCard/components/actions/likeButton/index.jsx b/packages/app/src/components/PostCard/components/actions/likeButton/index.jsx
index cb7cc217..f6aa55a2 100755
--- a/packages/app/src/components/PostCard/components/actions/likeButton/index.jsx
+++ b/packages/app/src/components/PostCard/components/actions/likeButton/index.jsx
@@ -4,56 +4,56 @@ import CountUp from "react-countup"
import "./index.less"
-export default (props) => {
- const [liked, setLiked] = React.useState(props.defaultLiked ?? false)
- const [clicked, setCliked] = React.useState(false)
+const LikeButtonAction = (props) => {
+ const [liked, setLiked] = React.useState(props.defaultLiked ?? false)
+ const [clicked, setCliked] = React.useState(false)
- const handleClick = async () => {
- let to = !liked
+ const handleClick = async () => {
+ let to = !liked
- setCliked(to)
+ setCliked(to)
- if (typeof props.onClick === "function") {
- const result = await props.onClick(to)
- if (typeof result === "boolean") {
- to = result
- }
- }
+ if (typeof props.onClick === "function") {
+ const result = await props.onClick(to)
- setLiked(to)
- }
+ if (typeof result === "boolean") {
+ to = result
+ }
+ }
- return
-}
\ No newline at end of file
+ setLiked(to)
+ }
+
+ return (
+
+ )
+}
+
+export default LikeButtonAction
diff --git a/packages/app/src/components/PostsList/index.jsx b/packages/app/src/components/PostsList/index.jsx
index afda9260..866dde9c 100755
--- a/packages/app/src/components/PostsList/index.jsx
+++ b/packages/app/src/components/PostsList/index.jsx
@@ -1,5 +1,6 @@
import React from "react"
import * as antd from "antd"
+import lodash from "lodash"
import { AnimatePresence } from "motion/react"
import { Icons } from "@components/Icons"
@@ -23,353 +24,8 @@ const LoadingComponent = () => {
)
}
-const NoResultComponent = () => {
- return (
-
- )
-}
-
-const typeToComponent = {
- post: (args) =>
,
- //"playlist": (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 (
-
- {!props.realtimeUpdates && !app.isMobile && (
-
- )}
-
-
- {props.list.map((data) => {
- return
- })}
-
-
- )
-})
-
-export class PostsListsComponent extends React.Component {
- state = {
- openPost: null,
-
- loading: false,
- resumingLoading: false,
- initialLoading: true,
- scrollingToTop: false,
-
- topVisible: true,
-
- realtimeUpdates: true,
-
- hasMore: true,
- list: this.props.list ?? [],
- pageCount: 0,
- }
-
- parentRef = this.props.innerRef
- listRef = React.createRef()
-
- timelineWsEvents = {
- "post:new": (data) => {
- console.log("[WS] Recived a post >", data)
-
- this.setState({
- list: [data, ...this.state.list],
- })
- },
- "post:delete": (id) => {
- console.log("[WS] Received a post delete >", id)
-
- this.setState({
- list: this.state.list.filter((post) => {
- return post._id !== id
- }),
- })
- },
- "post:update": (data) => {
- console.log("[WS] Received a post update >", data)
-
- this.setState({
- list: this.state.list.map((post) => {
- if (post._id === data._id) {
- return data
- }
-
- return post
- }),
- })
- },
- }
-
- handleLoad = async (fn, params = {}) => {
- if (this.state.loading === true) {
- console.warn(`Please wait to load the post before load more`)
- return
- }
-
- this.setState({
- loading: true,
- })
-
- let payload = {
- page: this.state.pageCount,
- limit: app.cores.settings.get("feed_max_fetch"),
- }
-
- if (this.props.loadFromModelProps) {
- payload = {
- ...payload,
- ...this.props.loadFromModelProps,
- }
- }
-
- const result = await fn(payload).catch((err) => {
- console.error(err)
-
- app.message.error("Failed to load more posts")
-
- return null
- })
-
- if (result) {
- if (result.length === 0) {
- return this.setState({
- hasMore: false,
- })
- }
-
- if (params.replace) {
- this.setState({
- list: result,
- pageCount: 0,
- })
- } else {
- this.setState({
- list: [...this.state.list, ...result],
- pageCount: this.state.pageCount + 1,
- })
- }
- }
-
- this.setState({
- loading: false,
- })
- }
-
- addPost = (post) => {
- this.setState({
- list: [post, ...this.state.list],
- })
- }
-
- removePost = (id) => {
- this.setState({
- list: this.state.list.filter((post) => {
- return post._id !== id
- }),
- })
- }
-
- _hacks = {
- addPost: this.addPost,
- removePost: this.removePost,
- addRandomPost: () => {
- const randomId = Math.random().toString(36).substring(7)
-
- this.addPost({
- _id: randomId,
- message: `Random post ${randomId}`,
- user: {
- _id: randomId,
- username: "random user",
- avatar: `https://api.dicebear.com/7.x/thumbs/svg?seed=${randomId}`,
- },
- })
- },
- listRef: this.listRef,
- }
-
- onResumeRealtimeUpdates = async () => {
- console.log("Resuming realtime updates")
-
- this.setState({
- resumingLoading: true,
- scrollingToTop: true,
- })
-
- this.listRef.current.scrollTo({
- top: 0,
- behavior: "smooth",
- })
-
- // reload posts
- await this.handleLoad(this.props.loadFromModel, {
- replace: true,
- })
-
- this.setState({
- realtimeUpdates: true,
- resumingLoading: false,
- })
- }
-
- onScrollList = (e) => {
- const { scrollTop } = e.target
-
- if (this.state.scrollingToTop && scrollTop === 0) {
- this.setState({
- scrollingToTop: false,
- })
- }
-
- if (scrollTop > 200) {
- if (this.state.topVisible) {
- this.setState({
- topVisible: false,
- })
-
- if (typeof this.props.onTopVisibility === "function") {
- this.props.onTopVisibility(false)
- }
- }
-
- if (
- !this.props.realtime ||
- this.state.resumingLoading ||
- this.state.scrollingToTop
- ) {
- return null
- }
-
- this.setState({
- realtimeUpdates: false,
- })
- } else {
- if (!this.state.topVisible) {
- this.setState({
- topVisible: true,
- })
-
- if (typeof this.props.onTopVisibility === "function") {
- this.props.onTopVisibility(true)
- }
-
- // if (this.props.realtime || !this.state.realtimeUpdates && !this.state.resumingLoading && scrollTop < 5) {
- // this.onResumeRealtimeUpdates()
- // }
- }
- }
- }
-
- componentDidMount = async () => {
- if (typeof this.props.loadFromModel === "function") {
- await this.handleLoad(this.props.loadFromModel)
- }
-
- this.setState({
- initialLoading: false,
- })
-
- if (this.props.realtime) {
- for (const [event, handler] of Object.entries(
- this.timelineWsEvents,
- )) {
- app.cores.api.listenEvent(event, handler, "posts")
- }
-
- app.cores.api.joinTopic(
- "posts",
- this.props.customTopic ?? "realtime:feed",
- )
- }
-
- if (this.listRef && this.listRef.current) {
- this.listRef.current.addEventListener("scroll", this.onScrollList)
- }
-
- window._hacks = this._hacks
- }
-
- componentWillUnmount = async () => {
- if (this.props.realtime) {
- for (const [event, handler] of Object.entries(
- this.timelineWsEvents,
- )) {
- app.cores.api.unlistenEvent(event, handler, "posts")
- }
-
- app.cores.api.leaveTopic(
- "posts",
- this.props.customTopic ?? "realtime:feed",
- )
- }
-
- if (this.listRef && this.listRef.current) {
- this.listRef.current.removeEventListener(
- "scroll",
- this.onScrollList,
- )
- }
-
- window._hacks = null
- }
-
- componentDidUpdate = async (prevProps, prevState) => {
- if (prevProps.list !== this.props.list) {
- this.setState({
- list: this.props.list,
- })
- }
- }
-
- onLikePost = async (data) => {
+const PostActions = {
+ onClickLike: async (data) => {
let result = await PostModel.toggleLike({ post_id: data._id }).catch(
() => {
antd.message.error("Failed to like post")
@@ -379,9 +35,8 @@ export class PostsListsComponent extends React.Component {
)
return result
- }
-
- onSavePost = async (data) => {
+ },
+ onClickSave: async (data) => {
let result = await PostModel.toggleSave({ post_id: data._id }).catch(
() => {
antd.message.error("Failed to save post")
@@ -391,25 +46,8 @@ export class PostsListsComponent extends React.Component {
)
return result
- }
-
- onEditPost = (data) => {
- app.controls.openPostCreator({
- edit_post: data._id,
- })
- }
-
- onReplyPost = (data) => {
- app.controls.openPostCreator({
- reply_to: data._id,
- })
- }
-
- onDoubleClickPost = (data) => {
- app.navigation.goToPost(data._id)
- }
-
- onDeletePost = async (data) => {
+ },
+ onClickDelete: async (data) => {
antd.Modal.confirm({
title: "Are you sure you want to delete this post?",
content: "This action is irreversible",
@@ -422,74 +60,218 @@ export class PostsListsComponent extends React.Component {
})
},
})
- }
+ },
+ onClickEdit: async (data) => {
+ app.controls.openPostCreator({
+ edit_post: data._id,
+ })
+ },
+ onClickReply: async (data) => {
+ app.controls.openPostCreator({
+ reply_to: data._id,
+ })
+ },
+ onDoubleClick: async (data) => {
+ app.navigation.goToPost(data._id)
+ },
+}
- ontoggleOpen = (to, data) => {
- if (typeof this.props.onOpenPost === "function") {
- this.props.onOpenPost(to, data)
+const Entry = (props) => {
+ const { data } = props
+
+ return (
+
+ )
+}
+
+const PostList = React.forwardRef((props, ref) => {
+ return (
+
+
+ {props.list.map((data) => {
+ return
+ })}
+
+
+ )
+})
+
+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 () => {
- if (typeof this.props.onLoadMore === "function") {
- return this.handleLoad(this.props.onLoadMore)
- } else if (this.props.loadFromModel) {
- return this.handleLoad(this.props.loadFromModel)
+ const onLoadMore = React.useCallback(() => {
+ if (typeof props.onLoadMore === "function") {
+ return handleLoad(props.onLoadMore)
+ } else if (props.loadFromModel) {
+ return handleLoad(props.loadFromModel)
}
- }
+ }, [props])
- render() {
- if (this.state.initialLoading) {
- return
+ React.useEffect(() => {
+ if (
+ !lodash.isEqual(props.loadFromModelProps, loadModelPropsRef.current)
+ ) {
+ loadModelPropsRef.current = props.loadFromModelProps
+
+ page.current = 0
+ loading.current = false
+
+ setHasMore(true)
+ setList([])
+ handleLoad(props.loadFromModel)
+ }
+ }, [
+ props.loadFromModel,
+ props.loadFromModelProps,
+ firstLoad.current === false,
+ ])
+
+ React.useEffect(() => {
+ if (props.loadFromModelProps) {
+ loadModelPropsRef.current = props.loadFromModelProps
}
- if (this.state.list.length === 0) {
- if (typeof this.props.emptyListRender === "function") {
- return React.createElement(this.props.emptyListRender)
+ if (typeof props.loadFromModel === "function") {
+ handleLoad(props.loadFromModel)
+ }
+
+ if (props.realtime) {
+ for (const [event, handler] of Object.entries(
+ timelineWsEvents.current,
+ )) {
+ app.cores.api.listenEvent(event, handler, "posts")
}
- return (
-
-
-
Whoa, nothing on here...
-
+ app.cores.api.joinTopic(
+ "posts",
+ props.customTopic ?? "realtime:feed",
)
}
- const PostListProps = {
- list: this.state.list,
+ return () => {
+ if (props.realtime) {
+ for (const [event, handler] of Object.entries(
+ timelineWsEvents.current,
+ )) {
+ app.cores.api.unlistenEvent(event, handler, "posts")
+ }
- disableReplyTag: this.props.disableReplyTag,
- disableHasReplies: this.props.disableHasReplies,
-
- onLikePost: this.onLikePost,
- onSavePost: this.onSavePost,
- onDeletePost: this.onDeletePost,
- onEditPost: this.onEditPost,
- onReplyPost: this.onReplyPost,
- onDoubleClick: this.onDoubleClickPost,
-
- onLoadMore: this.onLoadMore,
- hasMore: this.state.hasMore,
- loading: this.state.loading,
-
- realtimeUpdates: this.state.realtimeUpdates,
- resumingLoading: this.state.resumingLoading,
- onResumeRealtimeUpdates: this.onResumeRealtimeUpdates,
+ app.cores.api.leaveTopic(
+ "posts",
+ props.customTopic ?? "realtime:feed",
+ )
+ }
}
+ }, [])
- if (app.isMobile) {
- return
- }
-
- return (
-
- )
- }
+ return (
+
+ )
}
-export default React.forwardRef((props, ref) => (
-
-))
+export default PostsListsComponent
diff --git a/packages/app/src/components/UserCard/index.jsx b/packages/app/src/components/UserCard/index.jsx
index 1401bcb0..24c1e851 100755
--- a/packages/app/src/components/UserCard/index.jsx
+++ b/packages/app/src/components/UserCard/index.jsx
@@ -10,256 +10,223 @@ import linksDecorators from "@config/linksDecorators"
import "./index.less"
function processValue(value, decorator) {
- if (decorator.hrefResolve) {
- if (!String(value).includes(decorator.hrefResolve)) {
- return `${decorator.hrefResolve}${value}`
- }
- }
+ if (decorator.hrefResolve) {
+ if (!String(value).includes(decorator.hrefResolve)) {
+ return `${decorator.hrefResolve}${value}`
+ }
+ }
- return value
+ return value
}
const UserLinkViewer = (props) => {
- const { link, decorator } = props
+ const { link, decorator } = props
- return
-
- {
- createIconRender(decorator.icon ?? "MdLink")
- }
-
-
-
-
- {
- link.value
- }
-
-
-
+ return (
+
+
+ {createIconRender(decorator.icon ?? "MdLink")}
+
+
+
+ )
}
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 = () => {
- if (!hasHref) {
- if (app.isMobile) {
- app.layout.drawer.open("link_viewer", UserLinkViewer, {
- componentProps: {
- link: link,
- decorator: decorator
- }
- })
- }
- return false
- }
+ const handleOnClick = () => {
+ if (!hasHref) {
+ if (app.isMobile) {
+ app.layout.drawer.open("link_viewer", UserLinkViewer, {
+ componentProps: {
+ link: link,
+ decorator: decorator,
+ },
+ })
+ }
+ return false
+ }
- window.open(link.value, "_blank")
- }
+ window.open(link.value, "_blank")
+ }
- const renderName = () => {
- if (decorator.hrefResolve) {
- return decorator.label ?? link.value
- }
+ const renderName = () => {
+ if (decorator.hrefResolve) {
+ return decorator.label ?? link.value
+ }
- return link.value
- }
+ return link.value
+ }
- return
- {
- createIconRender(decorator.icon ?? "MdLink")
- }
+ return (
+
+ {createIconRender(decorator.icon ?? "MdLink")}
- {
- !app.isMobile &&
- {
- renderName()
- }
-
- }
-
+ {!app.isMobile &&
{renderName()}
}
+
+ )
}
-export const UserCard = React.forwardRef((props, ref) => {
- const [user, setUser] = React.useState(props.user)
+export const UserCard = (props) => {
+ const [user, setUser] = React.useState(props.user)
- // TODO: Support API user data fetching
+ React.useEffect(() => {
+ setUser(props.user)
+ }, [props.user])
- return
-
-
-
+ // TODO: Support API user data fetching
-
-
-
- {user.public_name || user.username}
- {user.verified && }
-
-
- @{user.username}
-
-
+ return (
+
+
+
+
- {
- user.badges?.length > 0 &&
- }
-
+
+
+
+ {user.public_name || user.username}
+ {user.verified && }
+
+ @{user.username}
+
-
-
- {user.description}
-
-
+ {user.badges?.length > 0 &&
}
+
- {
- user.links && Array.isArray(user.links) && user.links.length > 0 &&
- {
- user.links.map((link, index) => {
- return
- })
- }
-
- }
-
-})
+
+ {user.description}
+
-export const MobileUserCard = React.forwardRef((props, ref) => {
- return
-
- {
- props.user.cover &&
-
+ {user.links &&
+ Array.isArray(user.links) &&
+ user.links.length > 0 && (
+
+ {user.links.map((link, index) => {
+ return (
+
+ )
+ })}
+
+ )}
+
+ )
+}
-
-
- }
+export const MobileUserCard = (props, ref) => {
+ return (
+
+
+ {props.user.cover && (
+
+
- {
- !props.user.cover &&
-
-
- }
+
+
+ )}
-
-
-
- {
- props.user.fullName ?? `@${props.user.username}`
- }
- {
- props.user.verified &&
- }
-
+ {!props.user.cover && (
+
+
+
+ )}
- {
- props.user.fullName &&
- @{props.user.username}
-
- }
-
+
+
+
+ {props.user.fullName ?? `@${props.user.username}`}
+ {props.user.verified && (
+
+ )}
+
-
- {
- props.user.badges?.length > 0 &&
- }
-
+ {props.user.fullName && (
+
@{props.user.username}
+ )}
+
-
-
- {
- props.user.description
- }
-
-
-
+
+ {props.user.badges?.length > 0 && (
+
+ )}
+
- {
- props.user.links
- && Array.isArray(props.user.links)
- && props.user.links.length > 0
- &&
- {
- props.user.links.map((link, index) => {
- return
- })
- }
-
- }
-
+
+
{props.user.description}
+
+
-
- {
- props.followers &&
- }
+ {props.user.links &&
+ Array.isArray(props.user.links) &&
+ props.user.links.length > 0 && (
+
+ {props.user.links.map((link, index) => {
+ return
+ })}
+
+ )}
+
-
}
- disabled
- />
+
+ {props.followers && (
+
+ )}
-
}
- />
-
-
-})
+
}
+ disabled
+ />
-export default UserCard
\ No newline at end of file
+
} />
+
+
+ )
+}
+
+export default UserCard
diff --git a/packages/app/src/components/UserPreview/index.jsx b/packages/app/src/components/UserPreview/index.jsx
index da00f0c4..f62047e5 100755
--- a/packages/app/src/components/UserPreview/index.jsx
+++ b/packages/app/src/components/UserPreview/index.jsx
@@ -59,6 +59,7 @@ const UserPreview = (props) => {
return (
{},
-}
-
-export const ReleaseEditorStateContext = React.createContext(
- DefaultReleaseEditorState,
-)
-
-export default ReleaseEditorStateContext
diff --git a/packages/app/src/contexts/WithPlayerContext/index.jsx b/packages/app/src/contexts/WithPlayerContext/index.jsx
index 1531b4a4..1fbe4c9a 100755
--- a/packages/app/src/contexts/WithPlayerContext/index.jsx
+++ b/packages/app/src/contexts/WithPlayerContext/index.jsx
@@ -56,7 +56,9 @@ export class WithPlayerContext extends React.Component {
events = {
"player.state.update": async (state) => {
- this.setState(state)
+ if (state !== this.state) {
+ this.setState(state)
+ }
},
}
diff --git a/packages/app/src/cores/player/classes/AudioBase.js b/packages/app/src/cores/player/classes/AudioBase.js
index 67f668b4..4b9f4645 100644
--- a/packages/app/src/cores/player/classes/AudioBase.js
+++ b/packages/app/src/cores/player/classes/AudioBase.js
@@ -1,12 +1,12 @@
-import shaka from "shaka-player/dist/shaka-player.compiled.js"
+import * as dashjs from "dashjs"
+
+import MPDParser from "../mpd_parser"
import PlayerProcessors from "./PlayerProcessors"
import AudioPlayerStorage from "../player.storage"
import TrackManifest from "../classes/TrackManifest"
-import findInitializationChunk from "../helpers/findInitializationChunk"
import parseSourceFormatMetadata from "../helpers/parseSourceFormatMetadata"
-import handleInlineDashManifest from "../helpers/handleInlineDashManifest"
export default class AudioBase {
constructor(player) {
@@ -45,14 +45,14 @@ export default class AudioBase {
this.audio.addEventListener(key, value)
}
- // setup shaka player for mpd
+ // setup dash.js player for mpd
this.createDemuxer()
// create element source with low latency buffer
this.elementSource = this.context.createMediaElementSource(this.audio)
- await this.processorsManager.initialize(),
- await this.processorsManager.attachAllNodes()
+ await this.processorsManager.initialize()
+ await this.processorsManager.attachAllNodes()
}
itemInit = async (manifest) => {
@@ -60,29 +60,49 @@ export default class AudioBase {
return null
}
+ if (manifest._initialized) {
+ return manifest
+ }
+
+ this.console.time("itemInit()")
+
if (
typeof manifest === "string" ||
(!manifest.source && !manifest.dash_manifest)
) {
- this.console.time("resolve")
+ this.console.time("resolve manifest")
manifest = await this.player.serviceProviders.resolve(manifest)
- this.console.timeEnd("resolve")
+ this.console.timeEnd("resolve manifest")
}
if (!(manifest instanceof TrackManifest)) {
- this.console.time("init manifest")
+ this.console.time("instanciate class")
manifest = new TrackManifest(manifest, this.player)
- this.console.timeEnd("init manifest")
+ this.console.timeEnd("instanciate class")
}
- if (manifest.mpd_mode === true && !manifest.dash_manifest) {
- this.console.time("fetch dash manifest")
- manifest.dash_manifest = await fetch(manifest.source).then((r) =>
- r.text(),
+ if (
+ manifest.mpd_mode === true &&
+ !manifest.dash_manifest &&
+ this.demuxer
+ ) {
+ this.console.time("fetch")
+ const manifestString = await fetch(manifest.source).then((res) =>
+ res.text(),
)
- this.console.timeEnd("fetch dash manifest")
+ this.console.timeEnd("fetch")
+
+ this.console.time("parse mpd")
+ manifest.dash_manifest = await MPDParser(
+ manifestString,
+ manifest.source,
+ )
+ this.console.timeEnd("parse mpd")
}
+ manifest._initialized = true
+ this.console.timeEnd("itemInit()")
+
return manifest
}
@@ -110,28 +130,32 @@ export default class AudioBase {
this.processors.gain.set(this.player.state.volume)
}
- if (this.audio.paused) {
- try {
- this.console.time("play")
- await this.audio.play()
- this.console.timeEnd("play")
- } catch (error) {
- this.console.error(
- "Error during audio.play():",
- error,
- "State:",
- this.audio.readyState,
- )
- }
+ if (manifest.mpd_mode && this.demuxer) {
+ this.console.time("play")
+ await this.demuxer.play()
+ this.console.timeEnd("play")
+ }
+
+ if (!manifest.mpd_mode && this.audio.paused) {
+ this.console.time("play")
+ await this.audio.play()
+ this.console.timeEnd("play")
}
let initChunk = manifest.source
if (this.demuxer && manifest.dash_manifest) {
- initChunk = findInitializationChunk(
- manifest.source,
- manifest.dash_manifest,
+ let initializationTemplate =
+ manifest.dash_manifest["Period"][0]["AdaptationSet"][0][
+ "Representation"
+ ][0]["SegmentTemplate"]["initialization"]
+
+ initializationTemplate = initializationTemplate.replace(
+ "$RepresentationID$",
+ "0",
)
+
+ initChunk = new URL(initializationTemplate, manifest.source)
}
try {
@@ -172,79 +196,67 @@ export default class AudioBase {
if (!this.demuxer) {
this.console.log("Creating demuxer cause not initialized")
- this.createDemuxer()
+ await this.createDemuxer()
}
- if (manifest._preloaded) {
- this.console.log(
- `using preloaded source >`,
- manifest._preloaded,
- )
+ await this.demuxer.attachSource(manifest.dash_manifest, 0)
- return await this.demuxer.load(manifest._preloaded)
- }
-
- const inlineManifest =
- "inline://" + manifest.source + "::" + manifest.dash_manifest
-
- return await this.demuxer
- .load(inlineManifest, 0, "application/dash+xml")
- .catch((err) => {
- this.console.error("Error loading inline manifest", err)
- })
+ return manifest.source
}
// if not using demuxer, destroy previous instance
if (this.demuxer) {
- await this.demuxer.unload()
- await this.demuxer.destroy()
+ try {
+ this.demuxer.reset()
+ this.demuxer.destroy()
+ } catch (error) {
+ this.console.warn("Error destroying demuxer:", error)
+ }
+
this.demuxer = null
}
// load source
this.audio.src = manifest.source
- return this.audio.load()
+ this.audio.load()
+
+ return manifest.source
}
async createDemuxer() {
// Destroy previous instance if exists
if (this.demuxer) {
- await this.demuxer.unload()
- await this.demuxer.detach()
- await this.demuxer.destroy()
+ try {
+ this.demuxer.reset()
+ this.demuxer.destroy()
+ } catch (error) {
+ this.console.warn("Error destroying previous demuxer:", error)
+ }
}
- this.demuxer = new shaka.Player()
+ this.demuxer = dashjs.MediaPlayer().create()
- this.demuxer.attach(this.audio)
+ try {
+ this.demuxer.initialize(this.audio)
+ } catch (error) {
+ this.console.error("Error initializing DASH.js player:", error)
+ throw error
+ }
- this.demuxer.configure({
- manifest: {
- //updatePeriod: 5,
- disableVideo: true,
- disableText: true,
- dash: {
- ignoreMinBufferTime: true,
- ignoreMaxSegmentDuration: true,
- autoCorrectDrift: false,
- enableFastSwitching: true,
- useStreamOnceInPeriodFlattening: false,
- },
- },
+ this.demuxer.updateSettings({
streaming: {
- bufferingGoal: 15,
- rebufferingGoal: 1,
- bufferBehind: 30,
- stallThreshold: 0.5,
+ //cacheInitSegments: true,
+ buffer: {
+ bufferTimeAtTopQuality: 15,
+ initialBufferLevel: 1,
+ },
},
})
- shaka.net.NetworkingEngine.registerScheme(
- "inline",
- handleInlineDashManifest,
- )
+ //this.demuxer.setAutoPlay(false)
- this.demuxer.addEventListener("error", (event) => {
+ // Event listeners
+ this.demuxer.on(dashjs.MediaPlayer.events.ERROR, (event) => {
console.error("Demuxer error", event)
})
}
@@ -263,40 +275,21 @@ export default class AudioBase {
// if remaining time is less than 3s, try to init next item
if (parseInt(remainingTime) <= 10) {
// check if queue has next item
- if (this.player.queue.nextItems[0]) {
+ if (
+ this.player.queue.nextItems[0] &&
+ !this.player.queue.nextItems[0]._initialized
+ ) {
this.player.queue.nextItems[0] = await this.itemInit(
this.player.queue.nextItems[0],
)
-
- if (
- this.demuxer &&
- this.player.queue.nextItems[0].source &&
- this.player.queue.nextItems[0].mpd_mode &&
- !this.player.queue.nextItems[0]._preloaded
- ) {
- const manifest = this.player.queue.nextItems[0]
-
- // preload next item
- this.console.time("preload next item")
- this.player.queue.nextItems[0]._preloaded =
- await this.demuxer.preload(
- "inline://" +
- manifest.source +
- "::" +
- manifest.dash_manifest,
- 0,
- "application/dash+xml",
- )
- this.console.timeEnd("preload next item")
- }
}
}
}
- flush() {
+ async flush() {
this.audio.pause()
this.audio.currentTime = 0
- this.createDemuxer()
+ await this.createDemuxer()
}
audioEvents = {
diff --git a/packages/app/src/cores/player/classes/SyncRoom.js b/packages/app/src/cores/player/classes/SyncRoom.js
index 6b332b4b..3495111f 100644
--- a/packages/app/src/cores/player/classes/SyncRoom.js
+++ b/packages/app/src/cores/player/classes/SyncRoom.js
@@ -72,6 +72,8 @@ export default class SyncRoom {
track_manifest = {
...currentItem.toSeriableObject(),
}
+
+ delete track_manifest.source
}
// check if has changed the track
@@ -98,8 +100,6 @@ export default class SyncRoom {
}
syncState = async (data) => {
- console.log(data)
-
if (!data || !data.track_manifest) {
return false
}
@@ -129,11 +129,12 @@ export default class SyncRoom {
const currentTime = this.player.seek()
const offset = serverTime - currentTime
- console.log({
+ this.player.console.debug("sync_state", {
+ serverPayload: data,
serverTime: serverTime,
currentTime: currentTime,
- maxTimeOffset: SyncRoom.maxTimeOffset,
offset: offset,
+ maxTimeOffset: SyncRoom.maxTimeOffset,
})
if (
@@ -182,13 +183,13 @@ export default class SyncRoom {
this.state.joined_room = null
if (this.socket) {
- await this.socket.disconnect()
+ await this.socket.destroy()
}
}
createSocket = async () => {
if (this.socket) {
- await this.socket.disconnect()
+ await this.socket.destroy()
}
this.socket = new RTEngineClient({
diff --git a/packages/app/src/cores/player/classes/TrackManifest.js b/packages/app/src/cores/player/classes/TrackManifest.js
index 2bd15498..941bb198 100644
--- a/packages/app/src/cores/player/classes/TrackManifest.js
+++ b/packages/app/src/cores/player/classes/TrackManifest.js
@@ -45,12 +45,8 @@ export default class TrackManifest {
this.source = params.source
}
- if (typeof params.dash_manifest !== "undefined") {
- this.dash_manifest = params.dash_manifest
- }
-
- if (typeof params.encoded_manifest !== "undefined") {
- this.encoded_manifest = params.encoded_manifest
+ if (typeof params.mpd_string !== "undefined") {
+ this.mpd_string = params.mpd_string
}
if (typeof params.metadata !== "undefined") {
@@ -128,7 +124,7 @@ export default class TrackManifest {
}
serviceOperations = {
- fetchLyrics: async () => {
+ fetchLyrics: async (options) => {
if (!this._id) {
return null
}
@@ -137,6 +133,7 @@ export default class TrackManifest {
"resolveLyrics",
this.service,
this,
+ options,
)
if (this.overrides) {
@@ -195,8 +192,7 @@ export default class TrackManifest {
album: this.album,
artist: this.artist,
source: this.source,
- dash_manifest: this.dash_manifest,
- encoded_manifest: this.encoded_manifest,
+ mpd_string: this.mpd_string,
metadata: this.metadata,
liked: this.liked,
service: this.service,
diff --git a/packages/app/src/cores/player/mpd_parser/constants.js b/packages/app/src/cores/player/mpd_parser/constants.js
new file mode 100644
index 00000000..8c6276cf
--- /dev/null
+++ b/packages/app/src/cores/player/mpd_parser/constants.js
@@ -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",
+}
diff --git a/packages/app/src/cores/player/mpd_parser/index.js b/packages/app/src/cores/player/mpd_parser/index.js
new file mode 100644
index 00000000..b07f1f7a
--- /dev/null
+++ b/packages/app/src/cores/player/mpd_parser/index.js
@@ -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
+}
diff --git a/packages/app/src/cores/player/mpd_parser/matchers/base.js b/packages/app/src/cores/player/mpd_parser/matchers/base.js
new file mode 100644
index 00000000..5f2308ba
--- /dev/null
+++ b/packages/app/src/cores/player/mpd_parser/matchers/base.js
@@ -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
diff --git a/packages/app/src/cores/player/mpd_parser/matchers/datetime.js b/packages/app/src/cores/player/mpd_parser/matchers/datetime.js
new file mode 100644
index 00000000..4fbdbebd
--- /dev/null
+++ b/packages/app/src/cores/player/mpd_parser/matchers/datetime.js
@@ -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
diff --git a/packages/app/src/cores/player/mpd_parser/matchers/duration.js b/packages/app/src/cores/player/mpd_parser/matchers/duration.js
new file mode 100644
index 00000000..8d3d3c6f
--- /dev/null
+++ b/packages/app/src/cores/player/mpd_parser/matchers/duration.js
@@ -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
diff --git a/packages/app/src/cores/player/mpd_parser/matchers/lang.js b/packages/app/src/cores/player/mpd_parser/matchers/lang.js
new file mode 100644
index 00000000..2bf04b35
--- /dev/null
+++ b/packages/app/src/cores/player/mpd_parser/matchers/lang.js
@@ -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
diff --git a/packages/app/src/cores/player/mpd_parser/matchers/numeric.js b/packages/app/src/cores/player/mpd_parser/matchers/numeric.js
new file mode 100644
index 00000000..ec4dff10
--- /dev/null
+++ b/packages/app/src/cores/player/mpd_parser/matchers/numeric.js
@@ -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
diff --git a/packages/app/src/hooks/onPageMount.js b/packages/app/src/hooks/onPageMount.js
new file mode 100644
index 00000000..dc532e89
--- /dev/null
+++ b/packages/app/src/hooks/onPageMount.js
@@ -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()
+ }
+ }
+}
diff --git a/packages/app/src/hooks/useCenteredContainer.js b/packages/app/src/hooks/useCenteredContainer.js
new file mode 100755
index 00000000..4de7183f
--- /dev/null
+++ b/packages/app/src/hooks/useCenteredContainer.js
@@ -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)
+ }
+ }, [])
+}
diff --git a/packages/app/src/hooks/useCenteredContainer/index.js b/packages/app/src/hooks/useCenteredContainer/index.js
deleted file mode 100755
index df867d98..00000000
--- a/packages/app/src/hooks/useCenteredContainer/index.js
+++ /dev/null
@@ -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)
- }
- }, [])
-}
\ No newline at end of file
diff --git a/packages/app/src/hooks/useChat/index.js b/packages/app/src/hooks/useChat.js
similarity index 100%
rename from packages/app/src/hooks/useChat/index.js
rename to packages/app/src/hooks/useChat.js
diff --git a/packages/app/src/hooks/useClickNavById/index.js b/packages/app/src/hooks/useClickNavById.js
similarity index 100%
rename from packages/app/src/hooks/useClickNavById/index.js
rename to packages/app/src/hooks/useClickNavById.js
diff --git a/packages/app/src/hooks/useCoverAnalysis/index.js b/packages/app/src/hooks/useCoverAnalysis.js
similarity index 73%
rename from packages/app/src/hooks/useCoverAnalysis/index.js
rename to packages/app/src/hooks/useCoverAnalysis.js
index 424bcf9e..7eba16e3 100644
--- a/packages/app/src/hooks/useCoverAnalysis/index.js
+++ b/packages/app/src/hooks/useCoverAnalysis.js
@@ -32,12 +32,14 @@ export default (trackManifest) => {
}
}, [trackManifest])
- const dominantColor = {
- "--dominant-color": getDominantColorStr(coverAnalysis),
- }
-
return {
coverAnalysis,
- dominantColor,
+ dominantColor: getDominantColorStr(coverAnalysis),
+ cssVars: {
+ "--dominant-color": getDominantColorStr(coverAnalysis),
+ "--dominant-color-r": coverAnalysis?.value?.[0],
+ "--dominant-color-g": coverAnalysis?.value?.[1],
+ "--dominant-color-b": coverAnalysis?.value?.[2],
+ },
}
}
diff --git a/packages/app/src/hooks/useDebounce/index.js b/packages/app/src/hooks/useDebounce.js
similarity index 100%
rename from packages/app/src/hooks/useDebounce/index.js
rename to packages/app/src/hooks/useDebounce.js
diff --git a/packages/app/src/hooks/useDefaultVisibility/index.js b/packages/app/src/hooks/useDefaultVisibility.js
similarity index 100%
rename from packages/app/src/hooks/useDefaultVisibility/index.js
rename to packages/app/src/hooks/useDefaultVisibility.js
diff --git a/packages/app/src/hooks/useFullScreen/index.js b/packages/app/src/hooks/useFullScreen.js
similarity index 100%
rename from packages/app/src/hooks/useFullScreen/index.js
rename to packages/app/src/hooks/useFullScreen.js
diff --git a/packages/app/src/hooks/useGetMainOrigin/index.js b/packages/app/src/hooks/useGetMainOrigin.js
similarity index 100%
rename from packages/app/src/hooks/useGetMainOrigin/index.js
rename to packages/app/src/hooks/useGetMainOrigin.js
diff --git a/packages/app/src/hooks/useHacks/index.js b/packages/app/src/hooks/useHacks.js
similarity index 100%
rename from packages/app/src/hooks/useHacks/index.js
rename to packages/app/src/hooks/useHacks.js
diff --git a/packages/app/src/hooks/useHideOnMouseStop/index.jsx b/packages/app/src/hooks/useHideOnMouseStop.jsx
similarity index 100%
rename from packages/app/src/hooks/useHideOnMouseStop/index.jsx
rename to packages/app/src/hooks/useHideOnMouseStop.jsx
diff --git a/packages/app/src/hooks/useHideToolsBar/index.js b/packages/app/src/hooks/useHideToolsBar.js
similarity index 100%
rename from packages/app/src/hooks/useHideToolsBar/index.js
rename to packages/app/src/hooks/useHideToolsBar.js
diff --git a/packages/app/src/hooks/useLayoutInterface/index.js b/packages/app/src/hooks/useLayoutInterface.js
similarity index 100%
rename from packages/app/src/hooks/useLayoutInterface/index.js
rename to packages/app/src/hooks/useLayoutInterface.js
diff --git a/packages/app/src/hooks/useLongPress/index.jsx b/packages/app/src/hooks/useLongPress.jsx
similarity index 100%
rename from packages/app/src/hooks/useLongPress/index.jsx
rename to packages/app/src/hooks/useLongPress.jsx
diff --git a/packages/app/src/hooks/useLyrics.js b/packages/app/src/hooks/useLyrics.js
new file mode 100644
index 00000000..bce68371
--- /dev/null
+++ b/packages/app/src/hooks/useLyrics.js
@@ -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,
+ }
+}
diff --git a/packages/app/src/hooks/useLyrics/index.js b/packages/app/src/hooks/useLyrics/index.js
deleted file mode 100644
index 0a8df714..00000000
--- a/packages/app/src/hooks/useLyrics/index.js
+++ /dev/null
@@ -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,
- }
-}
diff --git a/packages/app/src/hooks/useMaxScreen.js b/packages/app/src/hooks/useMaxScreen.js
new file mode 100644
index 00000000..c6e2cb8b
--- /dev/null
+++ b/packages/app/src/hooks/useMaxScreen.js
@@ -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()
+ }
+ }, [])
+}
diff --git a/packages/app/src/hooks/useMaxScreen/index.js b/packages/app/src/hooks/useMaxScreen/index.js
deleted file mode 100644
index 120a5df7..00000000
--- a/packages/app/src/hooks/useMaxScreen/index.js
+++ /dev/null
@@ -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()
- }
- }, [])
-}
\ No newline at end of file
diff --git a/packages/app/src/hooks/usePageWidgets/index.js b/packages/app/src/hooks/usePageWidgets.js
similarity index 100%
rename from packages/app/src/hooks/usePageWidgets/index.js
rename to packages/app/src/hooks/usePageWidgets.js
diff --git a/packages/app/src/hooks/useRandomFeaturedWallpaperUrl/index.js b/packages/app/src/hooks/useRandomFeaturedWallpaperUrl.js
similarity index 100%
rename from packages/app/src/hooks/useRandomFeaturedWallpaperUrl/index.js
rename to packages/app/src/hooks/useRandomFeaturedWallpaperUrl.js
diff --git a/packages/app/src/hooks/useSyncRoom/index.js b/packages/app/src/hooks/useSyncRoom.js
similarity index 100%
rename from packages/app/src/hooks/useSyncRoom/index.js
rename to packages/app/src/hooks/useSyncRoom.js
diff --git a/packages/app/src/hooks/useTextRoom/index.jsx b/packages/app/src/hooks/useTextRoom.jsx
similarity index 100%
rename from packages/app/src/hooks/useTextRoom/index.jsx
rename to packages/app/src/hooks/useTextRoom.jsx
diff --git a/packages/app/src/hooks/useTopBar/index.jsx b/packages/app/src/hooks/useTopBar.jsx
similarity index 100%
rename from packages/app/src/hooks/useTopBar/index.jsx
rename to packages/app/src/hooks/useTopBar.jsx
diff --git a/packages/app/src/hooks/useTotalWindowHeight/index.js b/packages/app/src/hooks/useTotalWindowHeight.js
similarity index 100%
rename from packages/app/src/hooks/useTotalWindowHeight/index.js
rename to packages/app/src/hooks/useTotalWindowHeight.js
diff --git a/packages/app/src/hooks/useTrackManifest/index.js b/packages/app/src/hooks/useTrackManifest.js
similarity index 100%
rename from packages/app/src/hooks/useTrackManifest/index.js
rename to packages/app/src/hooks/useTrackManifest.js
diff --git a/packages/app/src/hooks/useUrlQueryActiveKey.js b/packages/app/src/hooks/useUrlQueryActiveKey.js
new file mode 100755
index 00000000..a3c03aa1
--- /dev/null
+++ b/packages/app/src/hooks/useUrlQueryActiveKey.js
@@ -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]
+}
diff --git a/packages/app/src/hooks/useUrlQueryActiveKey/index.js b/packages/app/src/hooks/useUrlQueryActiveKey/index.js
deleted file mode 100755
index 5098933c..00000000
--- a/packages/app/src/hooks/useUrlQueryActiveKey/index.js
+++ /dev/null
@@ -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,
- ]
-}
\ No newline at end of file
diff --git a/packages/app/src/hooks/useUserRemoteConfig/index.jsx b/packages/app/src/hooks/useUserRemoteConfig.jsx
similarity index 100%
rename from packages/app/src/hooks/useUserRemoteConfig/index.jsx
rename to packages/app/src/hooks/useUserRemoteConfig.jsx
diff --git a/packages/app/src/hooks/useWsEvents/index.js b/packages/app/src/hooks/useWsEvents.js
similarity index 100%
rename from packages/app/src/hooks/useWsEvents/index.js
rename to packages/app/src/hooks/useWsEvents.js
diff --git a/packages/app/src/layouts/components/drawer/component.jsx b/packages/app/src/layouts/components/drawer/component.jsx
new file mode 100644
index 00000000..4b9c5dec
--- /dev/null
+++ b/packages/app/src/layouts/components/drawer/component.jsx
@@ -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 (
+
+ {header && }
+
+
+ {React.createElement(children, enhancedComponentProps)}
+
+
+ )
+ }),
+)
+
+Drawer.displayName = "Drawer"
+
+Drawer.propTypes = {
+ id: PropTypes.string.isRequired,
+ children: PropTypes.elementType.isRequired,
+ options: PropTypes.object,
+ controller: PropTypes.object.isRequired,
+}
+
+export default Drawer
diff --git a/packages/app/src/layouts/components/drawer/header.jsx b/packages/app/src/layouts/components/drawer/header.jsx
new file mode 100644
index 00000000..03faf5d2
--- /dev/null
+++ b/packages/app/src/layouts/components/drawer/header.jsx
@@ -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 (
+
+
+ {title &&
{title}
}
+
+ {actions && (
+
{actions}
+ )}
+ {showCloseButton && (
+
+ )}
+
+
+
+ )
+}
+
+DrawerHeader.propTypes = {
+ title: PropTypes.string,
+ actions: PropTypes.node,
+ onClose: PropTypes.func.isRequired,
+ showCloseButton: PropTypes.bool,
+}
+
+export default DrawerHeader
diff --git a/packages/app/src/layouts/components/drawer/hooks.js b/packages/app/src/layouts/components/drawer/hooks.js
new file mode 100644
index 00000000..e1e5669b
--- /dev/null
+++ b/packages/app/src/layouts/components/drawer/hooks.js
@@ -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,
+ }
+}
diff --git a/packages/app/src/layouts/components/drawer/index.jsx b/packages/app/src/layouts/components/drawer/index.jsx
index 779f7ffa..c87fbd30 100755
--- a/packages/app/src/layouts/components/drawer/index.jsx
+++ b/packages/app/src/layouts/components/drawer/index.jsx
@@ -1,153 +1,56 @@
-import React from "react"
+import React, {
+ useState,
+ useEffect,
+ useCallback,
+ useMemo,
+ useRef,
+ createContext,
+ useContext,
+} from "react"
import classnames from "classnames"
import { AnimatePresence, motion } from "motion/react"
+import Drawer from "./component"
+
import "./index.less"
-export class Drawer extends React.Component {
- options = this.props.options ?? {}
+// Context for drawer management
+const DrawerContext = createContext()
- state = {
- visible: false,
+// Hook to use drawer context
+export const useDrawer = () => {
+ const context = useContext(DrawerContext)
+
+ if (!context) {
+ throw new Error("useDrawer must be used within a DrawerProvider")
}
- toggleVisibility = (to) => {
- to = to ?? !this.state.visible
-
- this.setState({ visible: to })
- }
-
- close = async () => {
- if (typeof this.options.onClose === "function") {
- this.options.onClose()
- }
-
- this.toggleVisibility(false)
-
- this.props.controller.close(this.props.id, {
- transition: 150,
- })
- }
-
- handleDone = (...context) => {
- if (typeof this.options.onDone === "function") {
- this.options.onDone(this, ...context)
- }
- }
-
- handleFail = (...context) => {
- if (typeof this.options.onFail === "function") {
- this.options.onFail(this, ...context)
- }
- }
-
- componentDidMount = async () => {
- if (typeof this.props.controller === "undefined") {
- throw new Error(`Cannot mount an drawer without an controller`)
- }
-
- if (typeof this.props.children === "undefined") {
- throw new Error(`Empty component`)
- }
-
- this.toggleVisibility(true)
- }
-
- render() {
- const componentProps = {
- ...this.options.props,
- close: this.close,
- handleDone: this.handleDone,
- handleFail: this.handleFail,
- }
- return (
-
-
- {React.createElement(this.props.children, componentProps)}
-
-
- )
- }
+ return context
}
-export default class DrawerController extends React.Component {
- constructor(props) {
- super(props)
+function DrawerController() {
+ const [state, setState] = useState({
+ addresses: {},
+ refs: {},
+ drawers: [],
+ maskVisible: false,
+ })
- this.state = {
- addresses: {},
- refs: {},
- drawers: [],
+ const stateRef = useRef(state)
+ stateRef.current = state
- maskVisible: false,
- }
+ const toggleMaskVisibility = useCallback((to) => {
+ setState((prev) => ({
+ ...prev,
+ maskVisible: to ?? !prev.maskVisible,
+ }))
+ }, [])
- this.interface = {
- open: this.open,
- close: this.close,
- closeAll: this.closeAll,
- drawers: () => this.state.drawers,
- drawersLength: () => this.state.drawers.length,
- isMaskVisible: () => this.state.maskVisible,
- }
- }
+ const handleEscKeyPress = useCallback((event) => {
+ const currentState = stateRef.current
- componentDidMount = () => {
- app.layout["drawer"] = this.interface
-
- 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
+ if (currentState.drawers.length === 0) {
+ return null
}
let isEscape = false
@@ -159,145 +62,223 @@ export default class DrawerController extends React.Component {
}
if (isEscape) {
- this.closeLastDrawer()
+ closeLastDrawer()
}
- }
+ }, [])
- getLastDrawer = () => {
- return this.state.drawers[this.state.drawers.length - 1].ref.current
- }
+ const getLastDrawer = useCallback(() => {
+ const currentState = stateRef.current
+ const lastDrawerId =
+ currentState.drawers[currentState.drawers.length - 1]?.id
- closeLastDrawer = () => {
- const lastDrawer = this.getLastDrawer()
+ if (!lastDrawerId) {
+ return null
+ }
- if (lastDrawer) {
- if (app.layout.modal && lastDrawer.options.confirmOnOutsideClick) {
+ return {
+ 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({
descriptionText:
- lastDrawer.options.confirmOnOutsideClickText ??
+ lastDrawer.options.confirmOnOutsideClickText ||
"Are you sure you want to close this drawer?",
onConfirm: () => {
- lastDrawer.close()
+ close(lastDrawer.id)
},
})
}
- lastDrawer.close()
+ close(lastDrawer.id)
}
- }
+ }, [getLastDrawer])
- toggleMaskVisibility = async (to) => {
- this.setState({
- maskVisible: to ?? !this.state.maskVisible,
- })
- }
+ const close = useCallback(
+ async (id, { transition = 0 } = {}) => {
+ const currentState = stateRef.current
+ const index = currentState.addresses[id]
+ const ref = currentState.refs[id]?.current
- open = (id, component, options) => {
- const refs = this.state.refs ?? {}
- const drawers = this.state.drawers ?? []
- const addresses = this.state.addresses ?? {}
+ if (typeof ref === "undefined") {
+ console.warn("This drawer does not exist")
+ return
+ }
- const instance = {
- id: id,
- ref: React.createRef(),
- children: component,
- options: options,
- controller: this,
- }
+ if (currentState.drawers.length === 1) {
+ toggleMaskVisibility(false)
+ }
- if (typeof addresses[id] === "undefined") {
- drawers.push(
)
+ if (transition > 0) {
+ await new Promise((resolve) => {
+ setTimeout(resolve, transition)
+ })
+ }
- addresses[id] = drawers.length - 1
- refs[id] = instance.ref
- } else {
- drawers[addresses[id]] =
- refs[id] = instance.ref
- }
+ setState((prev) => {
+ const newDrawers = prev.drawers.filter((_, i) => i !== index)
+ const newAddresses = { ...prev.addresses }
+ const newRefs = { ...prev.refs }
- this.setState({
- refs,
- addresses,
- drawers,
- })
+ delete newAddresses[id]
+ delete newRefs[id]
- this.toggleMaskVisibility(true)
- }
-
- close = async (id, { transition = 0 } = {}) => {
- let { addresses, drawers, refs } = this.state
-
- const index = addresses[id]
- const ref = this.state.refs[id]?.current
-
- 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)
+ return {
+ ...prev,
+ refs: newRefs,
+ addresses: newAddresses,
+ drawers: newDrawers,
+ }
})
+ },
+ [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: (
+
+ ),
+ })
+ newAddresses[id] = newDrawers.length - 1
+ newRefs[id] = drawerRef
+ } else {
+ newDrawers[newAddresses[id]] = {
+ ...instance,
+ element: (
+
+ ),
+ }
+ 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") {
- drawers = drawers.filter((value, i) => i !== index)
+ return () => {
+ if (app.layout) {
+ delete app.layout["drawer"]
+ }
}
+ }, [interface_])
- delete addresses[id]
- delete refs[id]
+ useEffect(() => {
+ document.addEventListener("keydown", handleEscKeyPress)
- this.setState({
- refs,
- addresses,
- drawers,
- })
- }
+ return () => {
+ document.removeEventListener("keydown", handleEscKeyPress)
+ }
+ }, [handleEscKeyPress])
- closeAll = () => {
- this.state.drawers.forEach((drawer) => {
- drawer.ref.current.close()
- })
- }
+ // Handle sidebar visibility based on mask visibility
+ useEffect(() => {
+ if (app.layout?.sidebar) {
+ app.layout.sidebar.toggleVisibility(!state.maskVisible)
+ }
+ }, [state.maskVisible])
- render() {
- return (
- <>
-
- {this.state.maskVisible && (
- this.closeLastDrawer()}
- initial={{
- opacity: 0,
- }}
- animate={{
- opacity: 1,
- }}
- exit={{
- opacity: 0,
- }}
- transition={{
- type: "spring",
- stiffness: 100,
- damping: 20,
- }}
- />
- )}
+ return (
+
+
+ {state.maskVisible && (
+
+ )}
+
+
+
+
+ {state.drawers.map((drawer) => drawer.element)}
-
-
- {this.state.drawers}
-
- >
- )
- }
+
+
+ )
}
+
+export default DrawerController
diff --git a/packages/app/src/layouts/components/drawer/index.less b/packages/app/src/layouts/components/drawer/index.less
index e407cf9b..8ffbe106 100644
--- a/packages/app/src/layouts/components/drawer/index.less
+++ b/packages/app/src/layouts/components/drawer/index.less
@@ -1,87 +1,206 @@
@import "@styles/vars.less";
.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;
- left: 0;
-
- z-index: 1200;
-
- display: flex;
- flex-direction: row;
-
- padding: @sidebar_padding;
-
- margin-left: calc(@sidebar_padding * 2);
-
- height: 100dvh;
- height: 100vh;
-
- &.hidden {
- display: none;
- }
+ &.hidden {
+ display: none;
+ }
}
.drawers-mask {
- position: absolute;
-
- top: 0;
- left: 0;
-
- z-index: 1100;
-
- width: 100vw;
- height: 100vh;
-
- background-color: rgba(0, 0, 0, 0.1);
- backdrop-filter: blur(4px);
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1100;
+ width: 100vw;
+ height: 100vh;
+ background-color: rgba(0, 0, 0, 0.1);
+ backdrop-filter: blur(4px);
+ cursor: pointer;
}
.drawer {
- position: relative;
+ position: relative;
+ z-index: 1300;
- z-index: 1300;
+ top: 0;
+ bottom: 0;
- top: 0;
- left: 0;
- bottom: 0;
+ display: flex;
+ flex-direction: column;
+ pointer-events: auto;
- width: fit-content;
- min-width: 320px;
- height: 100%;
+ width: fit-content;
+ min-width: 320px;
+ max-width: 90vw;
+ height: 100%;
- padding: 20px;
+ background-color: var(--background-color-accent);
+ border-radius: @sidebar_borderRadius;
+ box-shadow: @card-shadow;
+ border: 1px solid var(--sidebar-background-color);
- background-color: var(--background-color-accent);
+ overflow: hidden;
- border-radius: @sidebar_borderRadius;
- box-shadow: @card-shadow;
- border: 1px solid var(--sidebar-background-color);
+ &.drawer-left {
+ left: 0;
+ margin-right: 20px;
+ }
- overflow-x: hidden;
- overflow-y: overlay;
+ &.drawer-right {
+ right: 0;
+ margin-left: 20px;
+ }
+
+ .drawer-header {
+ flex-shrink: 0;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border-color);
+ background-color: var(--background-color);
+
+ .drawer-header-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+
+ .drawer-title {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-color);
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .drawer-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+
+ .drawer-custom-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .drawer-close-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: none;
+ background-color: transparent;
+ color: var(--text-color);
+ font-size: 24px;
+ line-height: 1;
+ cursor: pointer;
+ border-radius: 6px;
+ transition: all 0.2s ease;
+
+ &:active {
+ transform: scale(0.95);
+ }
+ }
+ }
+ }
+ }
+
+ .drawer-content {
+ flex: 1;
+ padding: 20px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ min-height: 0;
+
+ color: var(--text-color);
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background-color: var(--scrollbar-thumb-color);
+ border-radius: 3px;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background-color: var(--scrollbar-thumb-hover-color);
+ }
+ }
}
+// Responsive design
+@media (max-width: 768px) {
+ .drawers-wrapper {
+ margin-left: 0;
+ padding: 10px;
+ }
+
+ .drawer {
+ min-width: 280px;
+ max-width: calc(100vw - 20px);
+
+ .drawer-header {
+ padding: 12px 16px;
+
+ .drawer-header-content {
+ .drawer-title {
+ font-size: 16px;
+ }
+
+ .drawer-header-actions {
+ .drawer-close-button {
+ width: 28px;
+ height: 28px;
+ font-size: 20px;
+ }
+ }
+ }
+ }
+
+ .drawer-content {
+ padding: 16px;
+ }
+ }
+}
+
+// Confirm dialog styles
.drawer_close_confirm {
- display: flex;
- flex-direction: column;
+ display: flex;
+ 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_content {
- display: flex;
- flex-direction: column;
-
- gap: 2px;
- }
-
- .drawer_close_confirm_actions {
- display: flex;
- flex-direction: row;
-
- gap: 10px;
- }
-}
\ No newline at end of file
+ .drawer_close_confirm_actions {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ }
+}
diff --git a/packages/app/src/layouts/components/header/index.jsx b/packages/app/src/layouts/components/header/index.jsx
index 9bd5a736..5f97f48e 100755
--- a/packages/app/src/layouts/components/header/index.jsx
+++ b/packages/app/src/layouts/components/header/index.jsx
@@ -5,7 +5,7 @@ import useLayoutInterface from "@hooks/useLayoutInterface"
import "./index.less"
-export default (props) => {
+const HeaderBar = (props) => {
const [render, setRender] = React.useState(null)
useLayoutInterface("header", {
@@ -33,15 +33,23 @@ export default (props) => {
{render && (
{
)
}
+
+export default HeaderBar
diff --git a/packages/app/src/layouts/default/index.jsx b/packages/app/src/layouts/default/index.jsx
index e8040ec5..7193f3ec 100755
--- a/packages/app/src/layouts/default/index.jsx
+++ b/packages/app/src/layouts/default/index.jsx
@@ -17,58 +17,63 @@ import TopBar from "@layouts/components/@mobile/topBar"
import BackgroundDecorator from "@components/BackgroundDecorator"
const DesktopLayout = (props) => {
- return <>
-
-
-
+ return (
+ <>
+
+
+
-
-
+
+
-
-
+
+
- {
- props.children && React.cloneElement(props.children, props)
- }
-
+ {props.children &&
+ React.cloneElement(props.children, props)}
+
-
-
+
+
-
- >
+
+ >
+ )
}
const MobileLayout = (props) => {
- return
-
-
+ return (
+
+
+
-
- {
- props.children && React.cloneElement(props.children, props)
- }
-
+
+ {props.children && React.cloneElement(props.children, props)}
+
-
-
+
+
+ )
}
export default (props) => {
- return window.app.isMobile ? :
-}
\ No newline at end of file
+ return window.app.isMobile ? (
+
+ ) : (
+
+ )
+}
diff --git a/packages/app/src/pages/_debug/audiomatcher/index.jsx b/packages/app/src/pages/_debug/audiomatcher/index.jsx
deleted file mode 100644
index cad9acbc..00000000
--- a/packages/app/src/pages/_debug/audiomatcher/index.jsx
+++ /dev/null
@@ -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 (
-
-
-
-
- {startTime !== null && (
-
Offset de sincronización: {startTime.toFixed(2)} segundos
- )}
-
- );
-}
-
-export default AudioSyncApp;
\ No newline at end of file
diff --git a/packages/app/src/pages/_debug/audiomatcher/worker.js b/packages/app/src/pages/_debug/audiomatcher/worker.js
deleted file mode 100644
index 5d500af4..00000000
--- a/packages/app/src/pages/_debug/audiomatcher/worker.js
+++ /dev/null
@@ -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" });
- }
-};
\ No newline at end of file
diff --git a/packages/app/src/pages/_debug/audiometadata/index.jsx b/packages/app/src/pages/_debug/audiometadata/index.jsx
deleted file mode 100644
index c9aea4c8..00000000
--- a/packages/app/src/pages/_debug/audiometadata/index.jsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import TrackManifest from "@cores/player/classes/TrackManifest"
-
-const D_Manifest = () => {
- const [manifest, setManifest] = React.useState(null)
-
- function selectLocalFile() {
- const input = document.createElement("input")
- input.type = "file"
- input.accept = "audio/*"
- input.onchange = (e) => {
- loadManifest(e.target.files[0])
- }
- input.click()
- }
-
- async function loadManifest(file) {
- let track = new TrackManifest({ file: file })
-
- await track.initialize()
-
- console.log(track)
-
- setManifest(track)
- }
-
- return (
-
-
Select a local file to view & create a track manifest
-
-
-
- {manifest?.cover && (
-

- )}
-
-
- {JSON.stringify(manifest)}
-
-
- )
-}
-
-export default D_Manifest
diff --git a/packages/app/src/pages/_debug/loqui/index.jsx b/packages/app/src/pages/_debug/loqui/index.jsx
deleted file mode 100644
index 913d974a..00000000
--- a/packages/app/src/pages/_debug/loqui/index.jsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from "react"
-
-const defaultURL = "ws://localhost:19236"
-
-function useLoquiWs() {
- const [socket, setSocket] = React.useState(null)
-
- function create() {
- const s = new WebSocket(defaultURL)
-
- s.addEventListener("open", (event) => {
- console.log("WebSocket connection opened")
- })
-
- s.addEventListener("close", (event) => {
- console.log("WebSocket connection closed")
- })
-
- s.addEventListener("error", (event) => {
- console.log("WebSocket error", event)
- })
-
- s.addEventListener("message", (event) => {
- console.log("Message from server ", event.data)
- })
-
- setSocket(s)
- }
-
- React.useEffect(() => {
- create()
-
- return () => {
- if (socket) {
- socket.close()
- }
- }
- }, [])
-
- return [socket]
-}
-
-const Loqui = () => {
- const [socket] = useLoquiWs()
-
- return {defaultURL}
-}
-
-export default Loqui
diff --git a/packages/app/src/pages/_debug/videosegmentedupload/index.jsx b/packages/app/src/pages/_debug/videosegmentedupload/index.jsx
deleted file mode 100644
index 643e5f23..00000000
--- a/packages/app/src/pages/_debug/videosegmentedupload/index.jsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import React from "react"
-import { Progress } from "antd"
-import UploadButton from "@components/UploadButton"
-
-const VideoSegmentedUpload = () => {
- const [result, setResult] = React.useState(null)
- const [progress, setProgress] = React.useState(null)
-
- return (
-
-
{
- setResult(response)
- }}
- onProgress={(id, progress) => {
- setProgress({
- id,
- progress,
- })
- }}
- accept={["video/*"]}
- headers={{
- transmux: "mq-hls",
- }}
- >
- Upload video
-
-
- {progress && (
-
- )}
-
- {result &&
{JSON.stringify(result, null, 2)}
}
-
- )
-}
-
-export default VideoSegmentedUpload
diff --git a/packages/app/src/pages/account/[username].jsx b/packages/app/src/pages/account/[username].jsx
deleted file mode 100755
index 78a391e1..00000000
--- a/packages/app/src/pages/account/[username].jsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import React from "react"
-import Account from "."
-
-export default (props) => {
- const username = props.params.username
-
- return
-}
\ No newline at end of file
diff --git a/packages/app/src/pages/account/[username].mobile.jsx b/packages/app/src/pages/account/[username].mobile.jsx
deleted file mode 100755
index 99372b0a..00000000
--- a/packages/app/src/pages/account/[username].mobile.jsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import React from "react"
-import Account from "./index.mobile"
-
-export default (props) => {
- const username = props.params.username
-
- return
-}
\ No newline at end of file
diff --git a/packages/app/src/pages/account/[username]/index.jsx b/packages/app/src/pages/account/[username]/index.jsx
new file mode 100644
index 00000000..b13882da
--- /dev/null
+++ b/packages/app/src/pages/account/[username]/index.jsx
@@ -0,0 +1,246 @@
+import React, { useState, useEffect, useRef } from "react"
+import * as antd from "antd"
+import classnames from "classnames"
+import { motion, AnimatePresence } from "motion/react"
+
+import { Icons } from "@components/Icons"
+import FollowButton from "@components/FollowButton"
+import UserCard from "@components/UserCard"
+
+import GenerateMenuItems from "@utils/generateMenuItems"
+
+import UserModel from "@models/user"
+import FollowsModel from "@models/follows"
+
+import DetailsTab from "./tabs/details"
+import PostsTab from "./tabs/posts"
+import FollowersTab from "./tabs/followers"
+
+import "./index.less"
+
+const TabsComponent = {
+ posts: PostsTab,
+ followers: FollowersTab,
+ details: DetailsTab,
+}
+
+const Account = ({ params }) => {
+ const [requestedUser, setRequestedUser] = useState(null)
+ const [user, setUser] = useState(null)
+ const [isSelf, setIsSelf] = useState(false)
+ const [followersCount, setFollowersCount] = useState(0)
+ const [following, setFollowing] = useState(false)
+ const [tabActiveKey, setTabActiveKey] = useState("posts")
+ const [isNotExistent, setIsNotExistent] = useState(false)
+ const [coverExpanded, setCoverExpanded] = useState(false)
+
+ const contentRef = useRef()
+
+ const loadUserData = async () => {
+ const requestedUsername = params.username ?? app.userData.username
+
+ let isSelfUser = false
+ let userData = null
+ let followersCountData = 0
+
+ if (requestedUsername != null) {
+ if (app.userData.username === requestedUsername) {
+ isSelfUser = true
+ }
+
+ userData = await UserModel.data({
+ username: requestedUsername,
+ }).catch((error) => {
+ console.error(error)
+ return false
+ })
+
+ if (!userData) {
+ setIsNotExistent(true)
+ return false
+ }
+
+ console.log(`Loaded User [${userData.username}] :`, userData)
+
+ const followersResult = await FollowsModel.getFollowers(
+ userData._id,
+ ).catch((error) => {
+ console.error(error)
+ return false
+ })
+
+ if (followersResult) {
+ followersCountData = followersResult.count
+ }
+ }
+
+ setIsSelf(isSelfUser)
+ setRequestedUser(requestedUsername)
+ setUser(userData)
+ setFollowing(userData?.following || false)
+ setFollowersCount(followersCountData)
+ }
+
+ const onClickFollow = async () => {
+ const result = await FollowsModel.toggleFollow({
+ user_id: user._id,
+ }).catch((error) => {
+ console.error(error)
+ antd.message.error(error.message)
+ return false
+ })
+
+ setFollowing(result.following)
+ setFollowersCount(result.count)
+ }
+
+ const toggleCoverExpanded = (to) => {
+ setCoverExpanded(to ?? !coverExpanded)
+ }
+
+ const handlePageTransition = (key) => {
+ if (typeof key !== "string") {
+ console.error(
+ "Cannot handle page transition. Invalid key, only valid passing string",
+ key,
+ )
+ return
+ }
+
+ const normalizedKey = key.toLowerCase()
+
+ if (tabActiveKey === normalizedKey) {
+ return false
+ }
+
+ setTabActiveKey(normalizedKey)
+ }
+
+ useEffect(() => {
+ loadUserData()
+ }, [params.username])
+
+ if (isNotExistent) {
+ return (
+
+ )
+ }
+
+ if (!user) {
+ return
+ }
+
+ const state = {
+ requestedUser,
+ user,
+ isSelf,
+ followersCount,
+ following,
+ tabActiveKey,
+ isNotExistent,
+ coverExpanded,
+ }
+
+ return (
+
+ {user.cover && (
+
toggleCoverExpanded()}
+ id="profile-cover"
+ />
+ )}
+
+
+
+
+
+
+
+
+ {!isSelf && (
+
}
+ onClick={() =>
+ app.location.push(`/messages/${user._id}`)
+ }
+ />
+ )}
+
+
+
+
+
+
+ {React.createElement(TabsComponent[tabActiveKey], {
+ state: state,
+ })}
+
+
+
+
+
+
handlePageTransition(e.key)}
+ items={GenerateMenuItems([
+ {
+ id: "posts",
+ label: "Posts",
+ icon: "FiBookOpen",
+ },
+ {
+ id: "followers",
+ label: "Followers",
+ icon: "FiUsers",
+ },
+ {
+ id: "details",
+ label: "Details",
+ icon: "FiInfo",
+ },
+ ])}
+ />
+
+
+
+ )
+}
+
+Account.options = {
+ layout: {
+ type: "default",
+ centeredContent: false,
+ },
+}
+
+export default Account
diff --git a/packages/app/src/pages/account/index.less b/packages/app/src/pages/account/[username]/index.less
similarity index 99%
rename from packages/app/src/pages/account/index.less
rename to packages/app/src/pages/account/[username]/index.less
index 0553d4d4..7e526235 100755
--- a/packages/app/src/pages/account/index.less
+++ b/packages/app/src/pages/account/[username]/index.less
@@ -206,7 +206,7 @@
.followersList {
background-color: var(--background-color-accent);
- padding: 20px;
+ padding: 15px;
border-radius: @borderRadius;
width: 100%;
@@ -256,4 +256,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/packages/app/src/pages/account/index.mobile.jsx b/packages/app/src/pages/account/[username]/index.mobile.jsx
similarity index 100%
rename from packages/app/src/pages/account/index.mobile.jsx
rename to packages/app/src/pages/account/[username]/index.mobile.jsx
diff --git a/packages/app/src/pages/account/index.mobile.less b/packages/app/src/pages/account/[username]/index.mobile.less
similarity index 100%
rename from packages/app/src/pages/account/index.mobile.less
rename to packages/app/src/pages/account/[username]/index.mobile.less
diff --git a/packages/app/src/pages/account/tabs/details/index.jsx b/packages/app/src/pages/account/[username]/tabs/details/index.jsx
similarity index 100%
rename from packages/app/src/pages/account/tabs/details/index.jsx
rename to packages/app/src/pages/account/[username]/tabs/details/index.jsx
diff --git a/packages/app/src/pages/account/tabs/details/index.less b/packages/app/src/pages/account/[username]/tabs/details/index.less
similarity index 100%
rename from packages/app/src/pages/account/tabs/details/index.less
rename to packages/app/src/pages/account/[username]/tabs/details/index.less
diff --git a/packages/app/src/pages/account/[username]/tabs/followers/index.jsx b/packages/app/src/pages/account/[username]/tabs/followers/index.jsx
new file mode 100755
index 00000000..a49c9ca2
--- /dev/null
+++ b/packages/app/src/pages/account/[username]/tabs/followers/index.jsx
@@ -0,0 +1,11 @@
+import React from "react"
+
+import FollowersList from "@components/FollowersList"
+
+const FollowersTab = React.memo((props) => {
+ return
+})
+
+FollowersTab.displayName = "FollowersTab"
+
+export default FollowersTab
diff --git a/packages/app/src/pages/account/[username]/tabs/posts/index.jsx b/packages/app/src/pages/account/[username]/tabs/posts/index.jsx
new file mode 100755
index 00000000..ab59e909
--- /dev/null
+++ b/packages/app/src/pages/account/[username]/tabs/posts/index.jsx
@@ -0,0 +1,32 @@
+import React from "react"
+import { Result } from "antd"
+
+import { useNavigation } from "react-router"
+
+import PostsList from "@components/PostsList"
+import { Icons } from "@components/Icons"
+
+import PostModel from "@models/post"
+
+const emptyListRender = () => {
+ return (
+
}>
+
It's seems this user has no public post, yet.
+
+ )
+}
+
+const UserPosts = (props) => {
+ return (
+
+ )
+}
+
+export default UserPosts
diff --git a/packages/app/src/pages/account/index.jsx b/packages/app/src/pages/account/index.jsx
deleted file mode 100755
index fce56175..00000000
--- a/packages/app/src/pages/account/index.jsx
+++ /dev/null
@@ -1,260 +0,0 @@
-import React from "react"
-import * as antd from "antd"
-import classnames from "classnames"
-import { motion, AnimatePresence } from "motion/react"
-
-import { Icons } from "@components/Icons"
-import FollowButton from "@components/FollowButton"
-import UserCard from "@components/UserCard"
-
-import GenerateMenuItems from "@utils/generateMenuItems"
-
-import SessionModel from "@models/session"
-import UserModel from "@models/user"
-import FollowsModel from "@models/follows"
-
-import DetailsTab from "./tabs/details"
-import PostsTab from "./tabs/posts"
-import MusicTab from "./tabs/music"
-import FollowersTab from "./tabs/followers"
-
-import "./index.less"
-
-const TabsComponent = {
- posts: PostsTab,
- followers: FollowersTab,
- details: DetailsTab,
- music: MusicTab,
-}
-
-export default class Account extends React.Component {
- state = {
- requestedUser: null,
-
- user: null,
- isSelf: false,
-
- followersCount: 0,
- following: false,
-
- tabActiveKey: "posts",
-
- isNotExistent: false,
- }
-
- contentRef = React.createRef()
-
- componentDidMount = async () => {
- app.layout.toggleCenteredContent(false)
-
- const token = await SessionModel.getDecodedToken()
- const requestedUser = this.props.username ?? token?.username
-
- let isSelf = false
- let user = null
- let followersCount = 0
-
- if (requestedUser != null) {
- if (token.username === requestedUser) {
- isSelf = true
- }
-
- user = await UserModel.data({
- username: requestedUser,
- }).catch((error) => {
- console.error(error)
-
- return false
- })
-
- if (!user) {
- this.setState({
- isNotExistent: true,
- })
-
- return false
- }
-
- console.log(`Loaded User [${user.username}] >`, user)
-
- const followersResult = await FollowsModel.getFollowers(
- user._id,
- ).catch(() => false)
-
- if (followersResult) {
- followersCount = followersResult.count
- }
- }
-
- await this.setState({
- isSelf,
- requestedUser,
- user,
-
- following: user.following,
- followersCount: followersCount,
- })
- }
-
- onClickFollow = async () => {
- const result = await FollowsModel.toggleFollow({
- user_id: this.state.user._id,
- }).catch((error) => {
- console.error(error)
- antd.message.error(error.message)
-
- return false
- })
-
- await this.setState({
- following: result.following,
- followersCount: result.count,
- })
- }
-
- toggleCoverExpanded = async (to) => {
- this.setState({
- coverExpanded: to ?? !this.state.coverExpanded,
- })
- }
-
- handlePageTransition = (key) => {
- if (typeof key !== "string") {
- console.error(
- "Cannot handle page transition. Invalid key, only valid passing string",
- key,
- )
- return
- }
-
- key = key.toLowerCase()
-
- if (this.state.tabActiveKey === key) {
- return false
- }
-
- this.setState({
- tabActiveKey: key,
- })
- }
-
- render() {
- const user = this.state.user
-
- if (this.state.isNotExistent) {
- return (
-
- )
- }
-
- if (!user) {
- return
- }
-
- return (
-
- {user.cover && (
-
this.toggleCoverExpanded()}
- id="profile-cover"
- />
- )}
-
-
-
-
-
-
-
-
- {!this.state.isSelf && (
-
}
- onClick={() =>
- app.location.push(
- `/messages/${user._id}`,
- )
- }
- />
- )}
-
-
-
-
-
-
- {React.createElement(
- TabsComponent[this.state.tabActiveKey],
- {
- onTopVisibility:
- this.onPostListTopVisibility,
- state: this.state,
- },
- )}
-
-
-
-
-
-
this.handlePageTransition(e.key)}
- items={GenerateMenuItems([
- {
- id: "posts",
- label: "Posts",
- icon: "FiBookOpen",
- },
- {
- id: "music",
- label: "Music",
- icon: "MdAlbum",
- },
- {
- id: "followers",
- label: "Followers",
- icon: "FiUsers",
- },
- {
- id: "details",
- label: "Details",
- icon: "FiInfo",
- },
- ])}
- />
-
-
-
- )
- }
-}
diff --git a/packages/app/src/pages/account/tabs/followers/index.jsx b/packages/app/src/pages/account/tabs/followers/index.jsx
deleted file mode 100755
index 3789d91f..00000000
--- a/packages/app/src/pages/account/tabs/followers/index.jsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import React from "react"
-
-import FollowersList from "@components/FollowersList"
-
-import "./index.less"
-
-export default React.memo((props) => {
- return
-})
\ No newline at end of file
diff --git a/packages/app/src/pages/account/tabs/followers/index.less b/packages/app/src/pages/account/tabs/followers/index.less
deleted file mode 100755
index 69718b67..00000000
--- a/packages/app/src/pages/account/tabs/followers/index.less
+++ /dev/null
@@ -1,3 +0,0 @@
-.followersList {
- width: 100%;
-}
\ No newline at end of file
diff --git a/packages/app/src/pages/account/tabs/music/index.jsx b/packages/app/src/pages/account/tabs/music/index.jsx
deleted file mode 100755
index 10666047..00000000
--- a/packages/app/src/pages/account/tabs/music/index.jsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import React from "react"
-import * as antd from "antd"
-import classnames from "classnames"
-
-import Playlist from "@components/Music/Playlist"
-
-import MusicModel from "@models/music"
-
-import "./index.less"
-
-export default (props) => {
- const user_id = props.state.user._id
-
- const [L_Releases, R_Releases, E_Releases, M_Releases] =
- app.cores.api.useRequest(MusicModel.getAllReleases, {
- user_id: user_id,
- })
-
- if (E_Releases) {
- return (
-
- )
- }
-
- if (L_Releases) {
- return
- }
-
- const isEmpty = R_Releases.items.length === 0
-
- return (
-
- {isEmpty && (
-
- )}
- {R_Releases.items.map((r) => {
- return
- })}
-
- )
-}
diff --git a/packages/app/src/pages/account/tabs/music/index.less b/packages/app/src/pages/account/tabs/music/index.less
deleted file mode 100755
index 82f54fe4..00000000
--- a/packages/app/src/pages/account/tabs/music/index.less
+++ /dev/null
@@ -1,31 +0,0 @@
-.profile_releases {
- display: grid;
-
- width: 100%;
-
- grid-auto-columns: auto;
-
- grid-template-columns: repeat(3, 1fr);
- grid-template-rows: 1fr;
- grid-column-gap: 20px;
- grid-row-gap: 20px;
-
- @media (min-width: 2000px) {
- grid-template-columns: repeat(4, 1fr);
- }
-
- @media (min-width: 2300px) {
- grid-template-columns: repeat(5, 1fr);
- }
-
- .playlistItem {
- justify-self: center;
- }
-
- &.empty {
- display: flex;
-
- align-items: center;
- justify-content: center;
- }
-}
\ No newline at end of file
diff --git a/packages/app/src/pages/account/tabs/posts/index.jsx b/packages/app/src/pages/account/tabs/posts/index.jsx
deleted file mode 100755
index 234472ac..00000000
--- a/packages/app/src/pages/account/tabs/posts/index.jsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import React from "react"
-import { Result } from "antd"
-
-import PostsList from "@components/PostsList"
-import { Icons } from "@components/Icons"
-
-import PostModel from "@models/post"
-
-const emptyListRender = () => {
- return
}
- >
-
- It's seems this user has no public post, yet.
-
-
-}
-
-export default class UserPosts extends React.Component {
- render() {
- return
- }
-}
\ No newline at end of file
diff --git a/packages/app/src/pages/app/[id]/index.jsx b/packages/app/src/pages/app/[id]/index.jsx
index 6689d8c1..b07d0f98 100644
--- a/packages/app/src/pages/app/[id]/index.jsx
+++ b/packages/app/src/pages/app/[id]/index.jsx
@@ -15,10 +15,9 @@ const AppPage = (props) => {
useTotalWindowHeight(true)
const [loading, setLoading] = React.useState(true)
-
- const [customApp, setCustomApp] = React.useState({})
- const [extensionRef, setExtensionRef] = React.useState({})
- const [Render, setRender] = React.useState(() => () => null)
+ const [appMetadata, setAppMetadata] = React.useState({})
+ const [ctx, setCtx] = React.useState({})
+ const extensionRef = React.useRef(null)
async function loadApp() {
setLoading(true)
@@ -29,13 +28,30 @@ const AppPage = (props) => {
throw new Error(`Extension with id ${id} not found`)
}
+ if (!extension.main.app.component) {
+ throw new Error(`Missing component for extension with id [${id}]`)
+ }
+
+ setAppMetadata({
+ title: extension.main.app.title,
+ description: extension.main.app.description,
+ icon: extension.main.app.icon,
+ })
+
if (typeof extension.main.app.onLoad === "function") {
await extension.main.app.onLoad()
}
- setExtensionRef(extension)
- setCustomApp(extension.main.app)
- setRender(extension.main.app.renderComponent)
+ if (typeof extension.main.app.component.onMount === "function") {
+ await extension.main.app.component.onMount({
+ extension,
+ ctx,
+ setCtx,
+ })
+ }
+
+ // set to ref
+ extensionRef.current = extension
setLoading(false)
}
@@ -48,8 +64,8 @@ const AppPage = (props) => {
return (
- {customApp.icon && (
-
+ {appMetadata.icon && (
+
)}
@@ -64,7 +80,11 @@ const AppPage = (props) => {
return (
-
+ {React.createElement(extensionRef.current.main.app.component, {
+ extension: extensionRef.current,
+ ctx,
+ setCtx,
+ })}
)
diff --git a/packages/app/src/pages/app/[id]/index.less b/packages/app/src/pages/app/[id]/index.less
index e5a0a64f..eb6fba71 100644
--- a/packages/app/src/pages/app/[id]/index.less
+++ b/packages/app/src/pages/app/[id]/index.less
@@ -6,7 +6,7 @@
width: 100%;
height: 100%;
- border-radius: 24px;
+// border-radius: 24px;
overflow: hidden;
}
diff --git a/packages/app/src/pages/lyrics/components/controller/index.jsx b/packages/app/src/pages/lyrics/components/controller/index.jsx
index b2b4b3ae..adcff569 100644
--- a/packages/app/src/pages/lyrics/components/controller/index.jsx
+++ b/packages/app/src/pages/lyrics/components/controller/index.jsx
@@ -23,7 +23,7 @@ function isOverflown(element) {
)
}
-const PlayerController = (props, ref) => {
+const PlayerController = (props) => {
const [playerState] = usePlayerStateContext()
const titleRef = React.useRef()
diff --git a/packages/app/src/pages/lyrics/components/text/index.jsx b/packages/app/src/pages/lyrics/components/text/index.jsx
index 76d1f734..3fe85416 100644
--- a/packages/app/src/pages/lyrics/components/text/index.jsx
+++ b/packages/app/src/pages/lyrics/components/text/index.jsx
@@ -19,8 +19,8 @@ const LyricsText = React.forwardRef((props, textRef) => {
const lineIndex = lyrics.synced_lyrics.findIndex((line) => {
return (
- currentTrackTime >= line.startTimeMs &&
- currentTrackTime <= line.endTimeMs
+ currentTrackTime >= (line.startTimeMs ?? line.start_ms) &&
+ currentTrackTime <= (line.endTimeMs ?? line.end_ms)
)
})
@@ -123,40 +123,38 @@ const LyricsText = React.forwardRef((props, textRef) => {
return (
- {visible && (
-
- {lyrics.synced_lyrics.map((line, index) => {
- return (
-
- {line.text}
-
- )
- })}
-
- )}
+
+ {lyrics.synced_lyrics.map((line, index) => {
+ return (
+
+ {line.text}
+
+ )
+ })}
+
)
diff --git a/packages/app/src/pages/lyrics/components/video/index.jsx b/packages/app/src/pages/lyrics/components/video/index.jsx
index f5e0ebc0..38adf972 100644
--- a/packages/app/src/pages/lyrics/components/video/index.jsx
+++ b/packages/app/src/pages/lyrics/components/video/index.jsx
@@ -33,7 +33,7 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
if (
!lyrics ||
!lyrics.video_source ||
- typeof lyrics.sync_audio_at_ms === "undefined" ||
+ typeof lyrics.video_starts_at_ms === "undefined" ||
!videoRef.current
) {
return null
@@ -42,9 +42,13 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
const currentTrackTime = window.app.cores.player.controls.seek()
setSyncingVideo(true)
- let newTime =
- currentTrackTime + lyrics.sync_audio_at_ms / 1000 + 150 / 1000
- newTime -= 5 / 1000
+ let newTime = currentTrackTime + lyrics.video_starts_at_ms / 1000
+
+ // dec some ms to ensure the video seeks correctly
+ newTime -= 10 / 1000
+
+ // sum the audio gradual time fade
+ newTime = newTime + 150 / 1000
videoRef.current.currentTime = newTime
}, [lyrics, videoRef, setSyncingVideo])
@@ -55,7 +59,7 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
!videoRef.current ||
!lyrics ||
!lyrics.video_source ||
- typeof lyrics.sync_audio_at_ms === "undefined"
+ typeof lyrics.video_starts_at_ms === "undefined"
) {
stopSyncInterval()
return
@@ -68,7 +72,7 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
const currentTrackTime = window.app.cores.player.controls.seek()
const currentVideoTime =
- videoRef.current.currentTime - lyrics.sync_audio_at_ms / 1000
+ videoRef.current.currentTime - lyrics.video_starts_at_ms / 1000
const maxOffset = maxLatencyInMs / 1000
const currentVideoTimeDiff = Math.abs(
currentVideoTime - currentTrackTime,
@@ -135,7 +139,7 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
}
}
- if (typeof lyrics.sync_audio_at_ms !== "undefined") {
+ if (typeof lyrics.video_starts_at_ms !== "undefined") {
videoElement.loop = false
syncPlayback(true)
} else {
@@ -177,7 +181,7 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
return
}
- const shouldSync = typeof lyrics.sync_audio_at_ms !== "undefined"
+ const shouldSync = typeof lyrics.video_starts_at_ms !== "undefined"
if (playerState.playback_status === "playing") {
videoElement
@@ -255,4 +259,6 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
)
})
+LyricsVideo.displayName = "LyricsVideo"
+
export default LyricsVideo
diff --git a/packages/app/src/pages/lyrics/index.jsx b/packages/app/src/pages/lyrics/index.jsx
index 61e2002e..5c96c791 100644
--- a/packages/app/src/pages/lyrics/index.jsx
+++ b/packages/app/src/pages/lyrics/index.jsx
@@ -1,5 +1,6 @@
import React from "react"
import classnames from "classnames"
+import { motion } from "motion/react"
import useFullScreen from "@hooks/useFullScreen"
import useSyncRoom from "@hooks/useSyncRoom"
@@ -25,12 +26,12 @@ const EnhancedLyricsPage = () => {
const textRef = React.useRef()
const { toggleFullScreen } = useFullScreen({
- onExit: () => app?.location?.last && app.location.back(),
+ onExit: () => app.location.back(),
})
const { trackManifest } = useTrackManifest(playerState.track_manifest)
- const { dominantColor } = useCoverAnalysis(trackManifest)
+ const { dominantColor, cssVars } = useCoverAnalysis(trackManifest)
const { syncRoom, subscribeLyricsUpdates, unsubscribeLyricsUpdates } =
useSyncRoom()
@@ -39,7 +40,6 @@ const EnhancedLyricsPage = () => {
trackManifest,
})
- // Inicialización y limpieza
React.useEffect(() => {
toggleFullScreen(true)
@@ -61,9 +61,15 @@ const EnhancedLyricsPage = () => {
className={classnames("lyrics", {
stopped: playerState.playback_status !== "playing",
})}
- style={dominantColor}
+ style={cssVars}
>
-
+
{playerState.playback_status === "stopped" && (
diff --git a/packages/app/src/pages/lyrics/index.less b/packages/app/src/pages/lyrics/index.less
index 0f713cfc..15d2aabb 100644
--- a/packages/app/src/pages/lyrics/index.less
+++ b/packages/app/src/pages/lyrics/index.less
@@ -1,5 +1,7 @@
.lyrics {
- position: relative;
+ position: fixed;
+ top: 0;
+ left: 0;
z-index: 100;
@@ -46,10 +48,6 @@
width: 100%;
height: 100%;
-
- background:
- linear-gradient(0deg, rgb(var(--dominant-color)), rgba(0, 0, 0, 0)),
- url("data:image/svg+xml,%3Csvg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0' numOctaves='10' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
}
.lyrics-background-wrapper {
diff --git a/packages/app/src/pages/music/index.jsx b/packages/app/src/pages/music/index.jsx
index ebe9f414..ea5ce9cb 100755
--- a/packages/app/src/pages/music/index.jsx
+++ b/packages/app/src/pages/music/index.jsx
@@ -1,10 +1,6 @@
-import React from "react"
-
import { Icons } from "@components/Icons"
import { PagePanelWithNavMenu } from "@components/PagePanels"
-import useCenteredContainer from "@hooks/useCenteredContainer"
-
import Tabs from "./tabs"
const NavMenuHeader = (
@@ -14,9 +10,7 @@ const NavMenuHeader = (
)
-export default () => {
- useCenteredContainer(false)
-
+const MusicPage = () => {
return (
{
/>
)
}
+
+export default MusicPage
diff --git a/packages/app/src/pages/post/[post_id].jsx b/packages/app/src/pages/post/[post_id].jsx
index 7b04b4e0..583ce0ae 100755
--- a/packages/app/src/pages/post/[post_id].jsx
+++ b/packages/app/src/pages/post/[post_id].jsx
@@ -12,54 +12,56 @@ import useCenteredContainer from "@hooks/useCenteredContainer"
import "./index.less"
-const PostPage = (props) => {
- const post_id = props.params.post_id
+const PostPage = ({ params }) => {
+ const post_id = params.post_id
- useCenteredContainer(true)
+ useCenteredContainer(true)
- const [loading, result, error, repeat] = app.cores.api.useRequest(PostService.getPost, {
- post_id,
- })
+ const [loading, result, error, repeat] = app.cores.api.useRequest(
+ PostService.getPost,
+ {
+ post_id,
+ },
+ )
- if (error) {
- return
- }
+ if (error) {
+ return (
+
+ )
+ }
- if (loading) {
- return
- }
+ if (loading) {
+ return
+ }
- return
-
-
-
- Post
-
+ return (
+
+ {!!result.hasReplies && (
+
+
+
+ Replies
+
- {
- !!result.hasReplies &&
- }
-
+
+
+ )}
+
+ )
}
-export default PostPage
\ No newline at end of file
+export default PostPage
diff --git a/packages/app/src/pages/studio/music/[release_id]/index.jsx b/packages/app/src/pages/studio/music/[release_id]/index.jsx
deleted file mode 100644
index d51afc29..00000000
--- a/packages/app/src/pages/studio/music/[release_id]/index.jsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from "react"
-
-import ReleaseEditor from "@components/MusicStudio/ReleaseEditor"
-
-const ReleaseEditorPage = (props) => {
- const { release_id } = props.params
-
- return
-}
-
-export default ReleaseEditorPage
\ No newline at end of file
diff --git a/packages/app/src/pages/studio/music/components/MyReleasesList/index.jsx b/packages/app/src/pages/studio/music/components/MyReleasesList/index.jsx
new file mode 100644
index 00000000..f13c9fff
--- /dev/null
+++ b/packages/app/src/pages/studio/music/components/MyReleasesList/index.jsx
@@ -0,0 +1,62 @@
+import React from "react"
+import * as antd from "antd"
+
+import ReleaseItem from "../ReleaseItem"
+
+import MusicModel from "@models/music"
+
+import "./index.less"
+
+const MyReleasesList = () => {
+ const [loading, response, error] = app.cores.api.useRequest(MusicModel.getMyReleases, {
+ offset: 0,
+ limit: 100,
+ })
+
+ const handleReleaseClick = React.useCallback((release) => {
+ app.location.push(`/studio/music/release/${release._id}`)
+ }, [])
+
+ const renderContent = () => {
+ if (loading) {
+ return
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ if (!response?.items?.length) {
+ return
+ }
+
+ return (
+
+ {response.items.map((release) => (
+
+ ))}
+
+ )
+ }
+
+ return (
+
+
+
Your Releases
+
+ {renderContent()}
+
+ )
+}
+
+export default MyReleasesList
diff --git a/packages/app/src/components/MusicStudio/MyReleasesList/index.less b/packages/app/src/pages/studio/music/components/MyReleasesList/index.less
similarity index 100%
rename from packages/app/src/components/MusicStudio/MyReleasesList/index.less
rename to packages/app/src/pages/studio/music/components/MyReleasesList/index.less
diff --git a/packages/app/src/pages/studio/music/components/ReleaseItem/index.jsx b/packages/app/src/pages/studio/music/components/ReleaseItem/index.jsx
new file mode 100644
index 00000000..fa6645d8
--- /dev/null
+++ b/packages/app/src/pages/studio/music/components/ReleaseItem/index.jsx
@@ -0,0 +1,57 @@
+import React from "react"
+
+import { Icons } from "@components/Icons"
+import Image from "@components/Image"
+
+import "./index.less"
+
+const ReleaseItem = ({ release, onClick }) => {
+ const handleClick = React.useCallback(() => {
+ onClick?.(release)
+ }, [onClick, release])
+
+ const handleKeyDown = React.useCallback((e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ handleClick()
+ }
+ }, [handleClick])
+
+ return (
+
+
+
+ {release.title}
+
+
+
+
+
+ {release.type}
+
+
+
+
+ {release._id}
+
+
+ {/*
+
+ {release.analytics?.listen_count ?? 0}
+
*/}
+
+
+ )
+}
+
+export default ReleaseItem
\ No newline at end of file
diff --git a/packages/app/src/components/MusicStudio/ReleaseItem/index.less b/packages/app/src/pages/studio/music/components/ReleaseItem/index.less
similarity index 100%
rename from packages/app/src/components/MusicStudio/ReleaseItem/index.less
rename to packages/app/src/pages/studio/music/components/ReleaseItem/index.less
diff --git a/packages/app/src/pages/studio/music/hooks/useReleaseEditor.js b/packages/app/src/pages/studio/music/hooks/useReleaseEditor.js
new file mode 100644
index 00000000..69bd4de7
--- /dev/null
+++ b/packages/app/src/pages/studio/music/hooks/useReleaseEditor.js
@@ -0,0 +1,188 @@
+import React from "react"
+import MusicModel from "@models/music"
+import TrackManifest from "@cores/player/classes/TrackManifest"
+
+const DEFAULT_RELEASE_STATE = {
+ title: "Untitled",
+ type: "single",
+ public: false,
+ cover: "",
+ items: [],
+ description: "",
+ explicit: false,
+}
+
+const useReleaseEditor = (releaseId) => {
+ const [loading, setLoading] = React.useState(true)
+ const [submitting, setSubmitting] = React.useState(false)
+ const [loadError, setLoadError] = React.useState(null)
+ const [submitError, setSubmitError] = React.useState(null)
+
+ const [data, setData] = React.useState(DEFAULT_RELEASE_STATE)
+ const [initialValues, setInitialValues] = React.useState(
+ DEFAULT_RELEASE_STATE,
+ )
+
+ const fetchData = React.useCallback(async () => {
+ if (releaseId === "new") {
+ setLoading(false)
+ return
+ }
+
+ try {
+ setLoading(true)
+ setLoadError(null)
+
+ const data = await MusicModel.getReleaseData(releaseId)
+
+ if (Array.isArray(data.items)) {
+ data.items = data.items.map((item) => new TrackManifest(item))
+ }
+
+ setData(data)
+ setInitialValues(data)
+ } catch (error) {
+ console.error("Failed to load release data:", error)
+ setLoadError(error)
+ } finally {
+ setLoading(false)
+ }
+ }, [releaseId])
+
+ const changeData = React.useCallback((updates) => {
+ setData((prev) => {
+ let newData
+
+ if (typeof updates === "function") {
+ newData = updates(prev)
+ } else {
+ newData = { ...prev, ...updates }
+ }
+
+ // Prevent unnecessary updates
+ if (JSON.stringify(newData) === JSON.stringify(prev)) {
+ return prev
+ }
+
+ return newData
+ })
+ }, [])
+
+ const hasChanges = React.useMemo(() => {
+ return JSON.stringify(data) !== JSON.stringify(initialValues)
+ }, [data, initialValues])
+
+ const releaseDataRef = React.useRef(data)
+ const hasChangesRef = React.useRef(hasChanges)
+
+ releaseDataRef.current = data
+ hasChangesRef.current = hasChanges
+
+ const submitRelease = React.useCallback(async () => {
+ if (!hasChangesRef.current) {
+ app.message.warning("No changes to save")
+ return
+ }
+
+ try {
+ setSubmitting(true)
+ setSubmitError(null)
+
+ const currentReleaseData = releaseDataRef.current
+
+ // Submit tracks first if there are any
+ let trackIds = []
+ if (
+ currentReleaseData.items &&
+ currentReleaseData.items.length > 0
+ ) {
+ const tracks = await MusicModel.putTrack({
+ items: currentReleaseData.items,
+ })
+ trackIds = tracks.items.map((item) => item._id)
+ }
+
+ // Then submit release
+ const releasePayload = {
+ _id: currentReleaseData._id,
+ title: currentReleaseData.title,
+ description: currentReleaseData.description,
+ public: currentReleaseData.public,
+ cover: currentReleaseData.cover,
+ explicit: currentReleaseData.explicit,
+ type: currentReleaseData.type,
+ items: trackIds,
+ }
+
+ const result = await MusicModel.putRelease(releasePayload)
+
+ // Update initial values to prevent showing "unsaved changes"
+ setInitialValues(currentReleaseData)
+
+ app.message.success("Release saved successfully")
+
+ if (releaseId === "new") {
+ app.location.push(result._id)
+ }
+
+ // update items
+ fetchData()
+
+ return result
+ } catch (error) {
+ console.error("Failed to submit release:", error)
+ app.message.error(error.message || "Failed to save release")
+ setSubmitError(error)
+ throw error
+ } finally {
+ setSubmitting(false)
+ }
+ }, [])
+
+ const deleteRelease = React.useCallback(async () => {
+ const currentReleaseData = releaseDataRef.current
+
+ if (!currentReleaseData._id) {
+ console.warn("Cannot delete release without ID")
+ return
+ }
+
+ try {
+ await MusicModel.deleteRelease(currentReleaseData._id)
+ app.message.success("Release deleted successfully")
+ app.location.push("/studio/music")
+ } catch (error) {
+ console.error("Failed to delete release:", error)
+ app.message.error(error.message || "Failed to delete release")
+ }
+ }, [])
+
+ React.useEffect(() => {
+ fetchData()
+ }, [fetchData])
+
+ const isNewRelease = releaseId === "new"
+ const canSubmit = hasChanges && !submitting && !loading
+
+ return {
+ // State
+ loading,
+ submitting,
+ loadError,
+ submitError,
+
+ hasChanges,
+ isNewRelease,
+ canSubmit,
+
+ data: data,
+ changeData: changeData,
+
+ // Actions
+ submitRelease: submitRelease,
+ deleteRelease: deleteRelease,
+ reload: fetchData,
+ }
+}
+
+export default useReleaseEditor
diff --git a/packages/app/src/pages/studio/music/hooks/useTracksManager.js b/packages/app/src/pages/studio/music/hooks/useTracksManager.js
new file mode 100644
index 00000000..31c9056b
--- /dev/null
+++ b/packages/app/src/pages/studio/music/hooks/useTracksManager.js
@@ -0,0 +1,222 @@
+import React from "react"
+import queuedUploadFile from "@utils/queuedUploadFile"
+import FilesModel from "@models/files"
+import TrackManifest from "@cores/player/classes/TrackManifest"
+
+const useTracksManager = (initialTracks = [], updater) => {
+ const [tracks, setTracks] = React.useState(initialTracks)
+ const [pendingUploads, setPendingUploads] = React.useState([])
+
+ const findTrackByUid = React.useCallback(
+ (uid) => {
+ return tracks.find((track) => track.uid === uid)
+ },
+ [tracks],
+ )
+
+ const addTrack = React.useCallback((track) => {
+ if (!track) {
+ return false
+ }
+
+ setTracks((prev) => [...prev, track])
+ }, [])
+
+ const removeTrack = React.useCallback((uid) => {
+ if (!uid) {
+ return false
+ }
+
+ setTracks((prev) => {
+ const filtered = prev.filter((track) => track.uid !== uid)
+ return filtered.length !== prev.length ? filtered : prev
+ })
+ setPendingUploads((prev) => prev.filter((upload) => upload.uid !== uid))
+ }, [])
+
+ const updateTrack = React.useCallback((uid, updates) => {
+ if (!uid || !updates) {
+ return false
+ }
+
+ setTracks((prev) => {
+ const updated = prev.map((track) =>
+ track.uid === uid ? { ...track, ...updates } : track,
+ )
+ return JSON.stringify(updated) !== JSON.stringify(prev)
+ ? updated
+ : prev
+ })
+ }, [])
+
+ const reorderTracks = React.useCallback((newTracksArray) => {
+ if (!Array.isArray(newTracksArray)) {
+ console.warn("reorderTracks: Invalid tracks array provided")
+ return
+ }
+
+ setTracks((prev) => {
+ if (JSON.stringify(prev) === JSON.stringify(newTracksArray)) {
+ return prev
+ }
+ return newTracksArray
+ })
+ }, [])
+
+ const addPendingUpload = React.useCallback((uid) => {
+ if (!uid) {
+ return false
+ }
+
+ setPendingUploads((prev) => {
+ if (prev.find((upload) => upload.uid === uid)) return prev
+ return [...prev, { uid, progress: 0 }]
+ })
+ }, [])
+
+ const removePendingUpload = React.useCallback((uid) => {
+ if (!uid) {
+ return false
+ }
+
+ setPendingUploads((prev) => prev.filter((upload) => upload.uid !== uid))
+ }, [])
+
+ const updateUploadProgress = React.useCallback((uid, progress) => {
+ setPendingUploads((prev) =>
+ prev.map((upload) =>
+ upload.uid === uid ? { ...upload, progress } : upload,
+ ),
+ )
+ }, [])
+
+ const getUploadProgress = React.useCallback(
+ (uid) => {
+ const upload = pendingUploads.find((upload) => upload.uid === uid)
+ return upload?.progress || null
+ },
+ [pendingUploads],
+ )
+
+ const uploadToStorage = React.useCallback(
+ async (req) => {
+ await queuedUploadFile(req.file, {
+ onFinish: (file, response) => {
+ req.onSuccess(response)
+ },
+ onError: req.onError,
+ onProgress: (file, progress) => {
+ updateUploadProgress(file.uid, progress)
+ },
+ headers: {
+ transformations: "a-dash",
+ },
+ })
+ },
+ [updateUploadProgress],
+ )
+
+ const handleUploadStateChange = async (change) => {
+ const uid = change.file.uid
+
+ switch (change.file.status) {
+ case "uploading": {
+ addPendingUpload(uid)
+
+ const trackManifest = new TrackManifest({
+ uid,
+ file: change.file.originFileObj,
+ })
+
+ addTrack(trackManifest)
+ break
+ }
+ case "done": {
+ let trackManifest = findTrackByUid(uid)
+
+ if (!trackManifest) {
+ console.error(`Track with uid [${uid}] not found!`)
+ app.message.error(`Track with uid [${uid}] not found!`)
+ break
+ }
+
+ trackManifest.source = change.file.response.url
+ trackManifest = await trackManifest.initialize()
+
+ try {
+ if (trackManifest._coverBlob) {
+ const coverFile = new File(
+ [trackManifest._coverBlob],
+ "cover",
+ { type: trackManifest._coverBlob.type },
+ )
+
+ const coverUpload = await FilesModel.upload(coverFile, {
+ headers: {
+ "prefer-no-job": true,
+ },
+ })
+
+ trackManifest.cover = coverUpload.url
+ }
+ } catch (e) {
+ console.error(e)
+ }
+
+ updateTrack(uid, trackManifest)
+ removePendingUpload(uid)
+ break
+ }
+ case "error":
+ case "removed": {
+ removePendingUpload(uid)
+ removeTrack(uid)
+ break
+ }
+ default:
+ break
+ }
+ }
+
+ // Sync with initial tracks from props (only when length changes or first mount)
+ const prevInitialTracksLength = React.useRef(initialTracks.length)
+
+ React.useEffect(() => {
+ if (
+ initialTracks.length !== prevInitialTracksLength.current ||
+ tracks.length === 0
+ ) {
+ setTracks(initialTracks)
+ prevInitialTracksLength.current = initialTracks.length
+ }
+ }, [initialTracks.length])
+
+ // Notify parent when tracks change (but not on initial mount)
+ const isInitialMount = React.useRef(true)
+ const onTracksChangeRef = React.useRef(updater)
+
+ onTracksChangeRef.current = updater
+
+ React.useEffect(() => {
+ if (isInitialMount.current) {
+ isInitialMount.current = false
+ return
+ }
+
+ onTracksChangeRef.current?.(tracks)
+ }, [tracks])
+
+ return {
+ tracks,
+ pendingUploads,
+ addTrack,
+ removeTrack,
+ updateTrack,
+ reorderTracks,
+ getUploadProgress,
+ uploadToStorage,
+ handleUploadStateChange,
+ }
+}
+
+export default useTracksManager
diff --git a/packages/app/src/pages/studio/music/index.jsx b/packages/app/src/pages/studio/music/index.jsx
index 79a3f2d0..190b6f79 100644
--- a/packages/app/src/pages/studio/music/index.jsx
+++ b/packages/app/src/pages/studio/music/index.jsx
@@ -3,38 +3,30 @@ import * as antd from "antd"
import { Icons } from "@components/Icons"
-import MyReleasesList from "@components/MusicStudio/MyReleasesList"
+import MyReleasesList from "./components/MyReleasesList"
import "./index.less"
-const ReleasesAnalytics = () => {
- return
-
Analytics
-
+const MusicStudioPage = () => {
+ return (
+
+
+
Music Studio
+
+
}
+ onClick={() => {
+ app.location.push("/studio/music/release/new")
+ }}
+ >
+ New Release
+
+
+
+
+
+ )
}
-const MusicStudioPage = (props) => {
- return
-
-
Music Studio
-
-
}
- onClick={() => {
- app.location.push("/studio/music/new")
- }}
- >
- New Release
-
-
-
-
-
-
-
-}
-
-export default MusicStudioPage
\ No newline at end of file
+export default MusicStudioPage
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/index.jsx
new file mode 100644
index 00000000..a99f244b
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/index.jsx
@@ -0,0 +1,143 @@
+import React from "react"
+import * as antd from "antd"
+import { Icons, createIconRender } from "@components/Icons"
+
+import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey"
+import useReleaseEditor from "../../hooks/useReleaseEditor"
+
+import Tabs from "./tabs"
+
+import "./index.less"
+
+const ReleaseEditor = (props) => {
+ const { release_id } = props.params
+
+ const {
+ loading,
+ loadError,
+
+ submitting,
+ submitError,
+
+ data,
+ changeData,
+
+ submitRelease,
+ deleteRelease,
+
+ canSubmit,
+ isNewRelease,
+ } = useReleaseEditor(release_id)
+
+ const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
+ defaultKey: "info",
+ queryKey: "tab",
+ })
+
+ const handleDelete = React.useCallback(() => {
+ app.layout.modal.confirm({
+ headerText: "Are you sure you want to delete this release?",
+ descriptionText: "This action cannot be undone.",
+ onConfirm: deleteRelease,
+ })
+ }, [deleteRelease])
+
+ const renderContent = () => {
+ if (loadError) {
+ return (
+
+ )
+ }
+
+ if (loading) {
+ return
+ }
+
+ const Tab = Tabs.find(({ key }) => key === selectedTab)
+
+ if (!Tab) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {submitError && (
+
+ )}
+ {React.createElement(Tab.render, {
+ data: data,
+ changeData: changeData,
+ })}
+
+ )
+ }
+
+ return (
+
+
+
setSelectedTab(e.key)}
+ selectedKeys={[selectedTab]}
+ items={Tabs}
+ mode="vertical"
+ />
+
+
+
:
+ }
+ disabled={!canSubmit}
+ loading={submitting}
+ >
+ {isNewRelease ? "Release" : "Save"}
+
+
+ {!isNewRelease && (
+
}
+ disabled={loading}
+ onClick={handleDelete}
+ >
+ Delete
+
+ )}
+
+ {!isNewRelease && (
+
}
+ onClick={() =>
+ app.location.push(`/music/list/${data._id}`)
+ }
+ >
+ Go to release
+
+ )}
+
+
+
+ {renderContent()}
+
+ )
+}
+
+ReleaseEditor.options = {
+ layout: {
+ type: "default",
+ centeredContent: true,
+ },
+}
+
+export default ReleaseEditor
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/index.less b/packages/app/src/pages/studio/music/release/[release_id]/index.less
new file mode 100644
index 00000000..6d35dbb7
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/index.less
@@ -0,0 +1,136 @@
+.music-studio-release-editor {
+ display: flex;
+ flex-direction: row;
+
+ width: 100%;
+
+ min-width: 700px;
+
+ 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;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Advanced/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Advanced/index.jsx
similarity index 100%
rename from packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Advanced/index.jsx
rename to packages/app/src/pages/studio/music/release/[release_id]/tabs/Advanced/index.jsx
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/BasicInformation/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/BasicInformation/index.jsx
new file mode 100644
index 00000000..35b07f2d
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/BasicInformation/index.jsx
@@ -0,0 +1,136 @@
+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: ,
+ },
+ {
+ value: "ep",
+ label: "Episode",
+ icon: ,
+ },
+ {
+ value: "album",
+ label: "Album",
+ icon: ,
+ },
+ {
+ value: "compilation",
+ label: "Compilation",
+ icon: ,
+ },
+]
+
+const BasicInformation = ({ data, changeData }) => {
+ const handleFormChange = React.useCallback(
+ (changes) => {
+ changeData((prev) => ({ ...prev, ...changes }))
+ },
+ [data],
+ )
+
+ return (
+
+
Release Information
+
+
+
+
+
+
+ {data?._id && (
+
+ ID
+ >
+ }
+ name="_id"
+ initialValue={data._id}
+ >
+
+
+ )}
+
+
+ Title
+ >
+ }
+ name="title"
+ rules={[
+ {
+ required: true,
+ message: "Input a title for the release",
+ },
+ ]}
+ initialValue={data?.title}
+ >
+
+
+
+
+ Type
+ >
+ }
+ name="type"
+ rules={[
+ {
+ required: true,
+ message: "Select a type for the release",
+ },
+ ]}
+ initialValue={data?.type}
+ >
+
+
+
+
+ Public
+ >
+ }
+ name="public"
+ initialValue={data?.public}
+ >
+
+
+
+
+ )
+}
+
+export default BasicInformation
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/ListItem/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/ListItem/index.jsx
new file mode 100644
index 00000000..5ac3f3b9
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/ListItem/index.jsx
@@ -0,0 +1,145 @@
+import React from "react"
+import * as antd from "antd"
+import { useSortable } from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+import classnames from "classnames"
+
+import { Icons } from "@components/Icons"
+import TrackEditor from "../TrackEditor"
+
+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 SortableTrackItem = ({
+ id,
+ release,
+ track,
+ index,
+ progress,
+ disabled,
+ onUpdate,
+ onDelete,
+}) => {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id,
+ disabled,
+ })
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.8 : 1,
+ zIndex: isDragging ? 1000 : 1,
+ }
+
+ const handleEditTrack = React.useCallback(() => {
+ app.layout.drawer.open("track-editor", TrackEditor, {
+ props: {
+ release: release,
+ track: track,
+ onUpdate: (updatedTrack) => onUpdate(track.uid, updatedTrack),
+ },
+ })
+ }, [track, onUpdate])
+
+ const handleRemoveTrack = React.useCallback(() => {
+ onDelete?.(track.uid)
+ }, [onDelete, track.uid])
+
+ return (
+
+
+
+ {/*
+ {index + 1}
+
*/}
+
+ {progress !== null &&
}
+
+

+
+
+ {getTitleString({ track, progress })}
+ {!progress && (
+ <>
+ {track.artist}
+ {track.album}
+ >
+ )}
+
+
+
+
+ }
+ disabled={disabled}
+ />
+
+
+
}
+ onClick={handleEditTrack}
+ disabled={disabled}
+ />
+
+
+
+
+
+
+ )
+}
+
+export default SortableTrackItem
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/ListItem/index.less b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/ListItem/index.less
new file mode 100644
index 00000000..b066c515
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/ListItem/index.less
@@ -0,0 +1,86 @@
+.music-studio-release-editor-tracks-list-item {
+ position: relative;
+
+ display: flex;
+ flex-direction: row;
+
+ align-items: center;
+
+ gap: 10px;
+
+ width: 100%;
+ height: 80px;
+
+ transition: all 200ms ease-in-out;
+ cursor: default;
+
+ &:not(:last-child) {
+ border-bottom: 2px solid var(--border-color);
+ }
+
+ &.dragging {
+ opacity: 0.8;
+ z-index: 1000;
+ border-bottom: 0;
+ }
+
+ .music-studio-release-editor-tracks-list-item-progress {
+ position: absolute;
+
+ width: var(--upload-progress);
+ height: 3px;
+
+ bottom: 0;
+ left: 0;
+
+ background-color: var(--colorPrimary);
+
+ transition: width 150ms ease-in-out;
+ }
+
+ .music-studio-release-editor-tracks-list-item-cover {
+ height: 40px;
+ width: 40px;
+ min-height: 40px;
+ min-width: 40px;
+
+ border-radius: 12px;
+
+ object-fit: cover;
+
+ background-color: var(--background-color-accent);
+ border: 0 !important;
+ }
+
+ .music-studio-release-editor-tracks-list-item-info {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ #artist,
+ #album {
+ font-size: 0.6rem;
+ }
+ }
+
+ .music-studio-release-editor-tracks-list-item-actions {
+ display: flex;
+ flex-direction: row;
+ }
+
+ .music-studio-release-editor-tracks-list-item-dragger {
+ display: flex;
+ flex-direction: column;
+
+ justify-content: center;
+ align-items: center;
+
+ cursor: grab;
+ user-select: none;
+
+ &:active {
+ transform: scale(1.1);
+ }
+ }
+}
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/SortableTrackList/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/SortableTrackList/index.jsx
new file mode 100644
index 00000000..824804f0
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/SortableTrackList/index.jsx
@@ -0,0 +1,109 @@
+import React from "react"
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core"
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable"
+import {
+ restrictToVerticalAxis,
+ restrictToParentElement,
+} from "@dnd-kit/modifiers"
+
+import SortableTrackItem from "../ListItem"
+
+const SortableTrackList = ({
+ release,
+ tracks = [],
+ onReorder,
+ getUploadProgress,
+ onUpdate,
+ onDelete,
+ disabled = false,
+}) => {
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ )
+
+ const handleDragEnd = React.useCallback(
+ (event) => {
+ const { active, over } = event
+
+ if (active.id !== over?.id) {
+ const oldIndex = tracks.findIndex(
+ (track) => (track._id || track.uid) === active.id,
+ )
+ const newIndex = tracks.findIndex(
+ (track) => (track._id || track.uid) === over.id,
+ )
+
+ if (oldIndex !== -1 && newIndex !== -1) {
+ const newTracks = arrayMove(tracks, oldIndex, newIndex)
+ onReorder?.(newTracks)
+ }
+ }
+ },
+ [tracks, onReorder],
+ )
+
+ const trackIds = React.useMemo(
+ () => tracks.map((track) => track._id || track.uid),
+ [tracks],
+ )
+
+ if (tracks.length === 0) {
+ return null
+ }
+
+ return (
+
+
+
+ {tracks.map((track, index) => {
+ const progress = getUploadProgress?.(track.uid)
+ const isDisabled = disabled || !!progress
+
+ return (
+
+ )
+ })}
+
+
+
+ )
+}
+
+export default SortableTrackList
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/TrackEditor/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/TrackEditor/index.jsx
new file mode 100644
index 00000000..3e0d93d2
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/TrackEditor/index.jsx
@@ -0,0 +1,152 @@
+import React from "react"
+import * as antd from "antd"
+
+import CoverEditor from "@components/CoverEditor"
+import { Icons } from "@components/Icons"
+
+import "./index.less"
+
+const TrackField = ({ icon, label, children }) => (
+
+
+ {icon}
+ {label}
+
+ {children}
+
+)
+
+const TrackEditor = ({
+ release,
+ track: initialTrack = {},
+ onUpdate,
+ close,
+ setHeader,
+}) => {
+ const [track, setTrack] = React.useState(initialTrack)
+
+ const handleSave = React.useCallback(async () => {
+ onUpdate?.(track)
+ close?.()
+ }, [track, onUpdate, close])
+
+ const handleChange = React.useCallback((key, value) => {
+ setTrack((prev) => ({ ...prev, [key]: value }))
+ }, [])
+
+ const handleClickEditLyrics = React.useCallback(() => {
+ app.layout.modal.confirm({
+ headerText: "Save your changes",
+ descriptionText:
+ "All unsaved changes will be lost, make sure you have saved & submitted your changes before proceeding.",
+ onConfirm: async () => {
+ close()
+ app.location.push(`/studio/music/track_lyrics/${track._id}`)
+ },
+ })
+ }, [])
+
+ const setParentCover = React.useCallback(() => {
+ handleChange("cover", release.cover || "")
+ }, [handleChange, release])
+
+ const hasChanges = React.useMemo(() => {
+ return JSON.stringify(initialTrack) !== JSON.stringify(track)
+ }, [initialTrack, track])
+
+ React.useEffect(() => {
+ setHeader?.({
+ title: "Track Editor",
+ actions: [
+ }
+ >
+ Save
+ ,
+ ],
+ })
+ }, [setHeader, handleSave, hasChanges])
+
+ console.log(track, release)
+
+ return (
+
+
} label="Cover">
+
handleChange("cover", url)}
+ extraActions={[
+
+ Use Parent
+ ,
+ ]}
+ />
+
+
+ } label="Title">
+ handleChange("title", e.target.value)}
+ />
+
+
+ } label="Artist">
+ handleChange("artist", e.target.value)}
+ />
+
+
+ } label="Album">
+ handleChange("album", e.target.value)}
+ />
+
+
+ } label="Explicit">
+ handleChange("explicit", value)}
+ />
+
+
+ } label="Public">
+ handleChange("public", checked)}
+ />
+
+
+ } label="Enhanced Lyrics">
+
+
+ Edit
+
+
+ {!track.params?._id && (
+
+ You cannot edit Video and Lyrics without releasing
+ first
+
+ )}
+
+
+
+ )
+}
+
+export default TrackEditor
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/TrackEditor/index.less b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/TrackEditor/index.less
new file mode 100644
index 00000000..4db45b38
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/TrackEditor/index.less
@@ -0,0 +1,60 @@
+.track-editor {
+ display: flex;
+ flex-direction: column;
+
+ align-items: center;
+
+ min-width: 600px;
+ 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%;
+ }
+ }
+}
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/UploadHint/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/UploadHint/index.jsx
similarity index 100%
rename from packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/UploadHint/index.jsx
rename to packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/UploadHint/index.jsx
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/UploadHint/index.less b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/UploadHint/index.less
similarity index 100%
rename from packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/UploadHint/index.less
rename to packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/components/UploadHint/index.less
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/index.jsx
new file mode 100644
index 00000000..d0965e84
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/index.jsx
@@ -0,0 +1,88 @@
+import React from "react"
+import * as antd from "antd"
+
+import { Icons } from "@components/Icons"
+import useTracksManager from "../../../../hooks/useTracksManager"
+
+import UploadHint from "./components/UploadHint"
+import SortableTrackList from "./components/SortableTrackList"
+
+import "./index.less"
+
+const ReleaseTracks = ({ data, changeData }) => {
+ const {
+ tracks,
+ getUploadProgress,
+ uploadToStorage,
+ handleUploadStateChange,
+ removeTrack,
+ updateTrack,
+ reorderTracks,
+ } = useTracksManager(data.items, (tracks) =>
+ changeData({
+ items: tracks,
+ }),
+ )
+
+ // Handle reorder with new tracks array
+ const handleReorder = React.useCallback(
+ (newTracksArray) => {
+ reorderTracks(newTracksArray)
+ },
+ [reorderTracks],
+ )
+
+ const renderUploadButton = () => {
+ if (tracks.length === 0) {
+ return
+ }
+
+ return (
+ }>
+ Add another
+
+ )
+ }
+
+ const renderTracksList = () => {
+ if (tracks.length === 0) {
+ return
+ }
+
+ return (
+
+ )
+ }
+
+ return (
+
+
Tracks
+
+
+
+ {renderUploadButton()}
+
+
+
+ {renderTracksList()}
+
+
+
+ )
+}
+
+export default ReleaseTracks
diff --git a/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/index.less b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/index.less
new file mode 100644
index 00000000..c7b4bb81
--- /dev/null
+++ b/packages/app/src/pages/studio/music/release/[release_id]/tabs/Tracks/index.less
@@ -0,0 +1,52 @@
+.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-container {
+ width: 100%;
+ min-height: 100px;
+}
+
+.music-studio-release-editor-tracks-list {
+ display: flex;
+ flex-direction: column;
+ /* gap: 10px; */
+ width: 100%;
+
+ /* Empty state */
+ &:empty::after {
+ content: "No tracks uploaded yet";
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 60px;
+ color: var(--text-color-secondary);
+ font-style: italic;
+ border: 2px dashed var(--border-color);
+ border-radius: 8px;
+ background-color: var(--background-color-accent);
+ }
+}
diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/index.jsx b/packages/app/src/pages/studio/music/release/[release_id]/tabs/index.jsx
similarity index 100%
rename from packages/app/src/components/MusicStudio/ReleaseEditor/tabs/index.jsx
rename to packages/app/src/pages/studio/music/release/[release_id]/tabs/index.jsx
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/InlinePlayer/index.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/InlinePlayer/index.jsx
new file mode 100644
index 00000000..59cb2ce2
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/InlinePlayer/index.jsx
@@ -0,0 +1,124 @@
+import React from "react"
+import PropTypes from "prop-types"
+import { Button, Slider, Flex } from "antd"
+import {
+ PlayCircleOutlined,
+ PauseCircleOutlined,
+ SoundOutlined,
+ LoadingOutlined,
+} from "@ant-design/icons"
+
+import { useAudioPlayer } from "../../hooks/useAudioPlayer"
+
+import TimeIndicators from "../TimeIndicators"
+import SeekBar from "../SeekBar"
+
+const speedOptions = [
+ { label: "0.5x", value: 0.5 },
+ { label: "0.75x", value: 0.75 },
+ { label: "1x", value: 1 },
+ { label: "1.25x", value: 1.25 },
+ { label: "1.5x", value: 1.5 },
+ { label: "2x", value: 2 },
+]
+
+const InlinePlayer = React.forwardRef(({ src }, ref) => {
+ const {
+ audio,
+ toggle,
+ seek,
+ setSpeed,
+ setVolume,
+ playbackSpeed,
+ volume,
+ isPlaying,
+ isLoading,
+ } = useAudioPlayer(src)
+
+ React.useImperativeHandle(ref, () => {
+ return {
+ audio: audio,
+ toggle: toggle,
+ seek: seek,
+ isPlaying: isPlaying,
+ }
+ })
+
+ return (
+
+
+
+
+ ) : isPlaying ? (
+
+ ) : (
+
+ )
+ }
+ onClick={toggle}
+ disabled={isLoading}
+ className="control-button play-button"
+ />
+
+
+
+
+ `${Math.round(value * 100)}%`,
+ }}
+ icon={}
+ style={{ width: "100px" }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {speedOptions.map((option) => (
+
+ ))}
+
+
+
+ )
+})
+
+InlinePlayer.displayName = "InlinePlayer"
+
+InlinePlayer.propTypes = {
+ src: PropTypes.string.isRequired,
+}
+
+export default InlinePlayer
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/InlinePlayer/index.less b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/InlinePlayer/index.less
new file mode 100644
index 00000000..cda95d8e
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/InlinePlayer/index.less
@@ -0,0 +1,243 @@
+.inline-player {
+ display: flex;
+ flex-direction: column;
+
+ // Fixed dimensions to prevent layout shift
+ .time-display {
+ min-width: 120px;
+ text-align: right;
+ font-size: 14px;
+ line-height: 1.4;
+ }
+
+ // Stable button sizes
+ .control-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 40px;
+ height: 40px;
+
+ &.play-button {
+ min-width: 50px;
+ height: 50px;
+ }
+ }
+
+ // Progress slider container
+ .progress-container {
+ margin: 16px 0;
+
+ .ant-slider {
+ margin: 0;
+
+ .ant-slider-rail {
+ background: #f5f5f5;
+ height: 6px;
+ }
+
+ .ant-slider-track {
+ background: #1890ff;
+ height: 6px;
+ }
+
+ .ant-slider-handle {
+ width: 16px;
+ height: 16px;
+ margin-top: -5px;
+ border: 2px solid #1890ff;
+
+ &:hover,
+ &:focus {
+ border-color: #40a9ff;
+ box-shadow: 0 0 0 5px rgba(24, 144, 255, 0.12);
+ }
+ }
+
+ &:hover .ant-slider-track {
+ background: #40a9ff;
+ }
+ }
+ }
+
+ // Speed controls
+ .speed-controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ .speed-label {
+ font-size: 12px;
+ color: #666;
+ margin-right: 8px;
+ min-width: 35px;
+ }
+
+ .speed-button {
+ min-width: 50px;
+ height: 28px;
+ font-size: 12px;
+ border-radius: 4px;
+
+ &.ant-btn-primary {
+ background: #1890ff;
+ border-color: #1890ff;
+ }
+ }
+ }
+
+ // Volume controls
+ .volume-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 140px;
+
+ .volume-icon {
+ color: #666;
+ font-size: 16px;
+ min-width: 16px;
+ }
+
+ .volume-slider {
+ width: 80px;
+ margin: 0;
+
+ .ant-slider-rail {
+ background: #f0f0f0;
+ height: 4px;
+ }
+
+ .ant-slider-track {
+ background: #52c41a;
+ height: 4px;
+ }
+
+ .ant-slider-handle {
+ width: 12px;
+ height: 12px;
+ margin-top: -4px;
+ border: 2px solid #52c41a;
+ }
+ }
+
+ .volume-text {
+ font-size: 11px;
+ color: #999;
+ min-width: 30px;
+ text-align: center;
+ }
+ }
+
+ // Main controls layout
+ .main-controls {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+ min-height: 50px;
+ }
+
+ .secondary-controls {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ min-height: 32px;
+ }
+
+ // Responsive adjustments
+ @media (max-width: 768px) {
+ .ant-card-body {
+ padding: 16px;
+ }
+
+ .main-controls {
+ flex-direction: column;
+ gap: 12px;
+
+ .time-display {
+ min-width: 100px;
+ font-size: 12px;
+ }
+ }
+
+ .secondary-controls {
+ flex-direction: column;
+ gap: 16px;
+
+ .speed-controls,
+ .volume-controls {
+ width: 100%;
+ justify-content: center;
+ }
+ }
+
+ .volume-controls {
+ min-width: 120px;
+
+ .volume-slider {
+ width: 60px;
+ }
+ }
+ }
+
+ @media (max-width: 480px) {
+ .speed-controls {
+ flex-wrap: wrap;
+ justify-content: center;
+
+ .speed-button {
+ min-width: 45px;
+ margin: 2px;
+ }
+ }
+ }
+
+ // Smooth transitions
+ .ant-btn {
+ transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
+ border-radius: 6px;
+
+ &:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+ }
+
+ // Loading state
+ .ant-btn-loading {
+ .anticon {
+ animation: ant-spin 1s infinite linear;
+ }
+ }
+
+ // Error state styling
+ &.has-error {
+ .ant-slider {
+ .ant-slider-rail {
+ background: #ffccc7;
+ }
+
+ .ant-slider-track {
+ background: #ff4d4f;
+ }
+ }
+ }
+
+ // Focus styles for accessibility
+ .ant-btn:focus-visible {
+ outline: 2px solid #1890ff;
+ outline-offset: 2px;
+ }
+
+ .ant-slider:focus-within {
+ .ant-slider-handle {
+ border-color: #1890ff;
+ box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.2);
+ }
+ }
+}
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/SeekBar/index.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/SeekBar/index.jsx
new file mode 100644
index 00000000..1c4ddd6c
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/SeekBar/index.jsx
@@ -0,0 +1,73 @@
+import React from "react"
+import { Slider } from "antd"
+import PropTypes from "prop-types"
+
+import formatTime from "../../utils/formatTime"
+
+const SeekBar = ({ audio, onSeek }) => {
+ const [currentTime, setCurrentTime] = React.useState(0)
+ const [isDragging, setIsDragging] = React.useState(false)
+ const [tempProgress, setTempProgress] = React.useState(0)
+
+ const intervalRef = React.useRef(null)
+
+ const duration = audio.current.duration ?? 0
+ const progress = duration > 0 ? (currentTime / duration) * 100 : 0
+
+ const handleProgressStart = React.useCallback(() => {
+ setIsDragging(true)
+ }, [])
+
+ const handleProgressChange = React.useCallback((value) => {
+ const duration = audio.current.duration ?? 0
+
+ setTempProgress(value)
+ onSeek((value / 100) * duration)
+ }, [])
+
+ const handleProgressEnd = React.useCallback((value) => {
+ const duration = audio.current.duration ?? 0
+
+ setIsDragging(false)
+ onSeek((value / 100) * duration)
+ }, [])
+
+ const updateCurrentTime = React.useCallback(() => {
+ setCurrentTime(audio.current.currentTime)
+ }, [])
+
+ React.useEffect(() => {
+ intervalRef.current = setInterval(updateCurrentTime, 100)
+
+ return () => {
+ clearInterval(intervalRef.current)
+ }
+ }, [!audio.current.paused])
+
+ return (
+
+ {
+ const time = (value / 100) * duration
+ return formatTime(time)
+ },
+ }}
+ />
+
+ )
+}
+
+SeekBar.propTypes = {
+ audio: PropTypes.object.isRequired,
+ onSeek: PropTypes.func.isRequired,
+}
+
+export default SeekBar
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/TimeIndicators/index.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/TimeIndicators/index.jsx
new file mode 100644
index 00000000..1cd6b343
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/components/TimeIndicators/index.jsx
@@ -0,0 +1,37 @@
+import React from "react"
+import PropTypes from "prop-types"
+import formatTime from "../../utils/formatTime"
+
+const TimeIndicators = ({ audio }) => {
+ const [currentTime, setCurrentTime] = React.useState(0)
+ const frameId = React.useRef(null)
+
+ const timeTick = React.useCallback(() => {
+ setCurrentTime(audio.current.currentTime)
+ frameId.current = requestAnimationFrame(timeTick)
+ }, [])
+
+ React.useEffect(() => {
+ console.log("starting frame")
+ timeTick()
+
+ return () => {
+ if (frameId.current) {
+ console.log("canceling frame")
+ cancelAnimationFrame(frameId.current)
+ }
+ }
+ }, [])
+
+ return (
+ <>
+ {formatTime(currentTime)} / {formatTime(audio.current.duration)}
+ >
+ )
+}
+
+TimeIndicators.propTypes = {
+ audio: PropTypes.object.isRequired,
+}
+
+export default TimeIndicators
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/context/LyricsEditorContext.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/context/LyricsEditorContext.jsx
new file mode 100644
index 00000000..8a155e48
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/context/LyricsEditorContext.jsx
@@ -0,0 +1,204 @@
+import React from "react"
+import PropTypes from "prop-types"
+
+const initialState = {
+ error: null,
+
+ // Track data
+ track: null,
+
+ // Audio state
+ currentTime: 0,
+ duration: 0,
+ isPlaying: false,
+ isLoading: false,
+ playbackSpeed: 1,
+ volume: 1,
+
+ // Lyrics state
+ lyrics: {},
+ selectedLanguage: "original",
+
+ // Video state
+ videoSource: null,
+ videoSyncTime: null,
+
+ // UI state
+ loading: false,
+ saving: false,
+ editMode: false,
+}
+
+const LyricsEditorContext = React.createContext()
+
+function lyricsReducer(state, action) {
+ switch (action.type) {
+ case "SET_TRACK": {
+ return { ...state, track: action.payload }
+ }
+
+ case "SET_AUDIO_PLAYING": {
+ return { ...state, isPlaying: action.payload }
+ }
+
+ case "SET_AUDIO_SPEED": {
+ return { ...state, playbackSpeed: action.payload }
+ }
+
+ case "SET_AUDIO_VOLUME": {
+ return { ...state, volume: action.payload }
+ }
+
+ case "SET_AUDIO_LOADING": {
+ return { ...state, isLoading: action.payload }
+ }
+
+ case "SET_LYRICS": {
+ return {
+ ...state,
+ lyrics: {
+ original: [],
+ ...action.payload,
+ },
+ }
+ }
+
+ case "OVERRIDE_LINES": {
+ return {
+ ...state,
+ lyrics: {
+ ...state.lyrics,
+ [state.selectedLanguage]: action.payload,
+ },
+ }
+ }
+
+ case "ADD_LINE": {
+ let lines = state.lyrics[state.selectedLanguage] ?? []
+
+ if (lines.find((line) => line.time === action.payload.time)) {
+ return state
+ }
+
+ lines.push(action.payload)
+
+ lines = lines.sort((a, b) => {
+ if (a.time === null) return -1
+ if (b.time === null) return 1
+ return a.time - b.time
+ })
+
+ return {
+ ...state,
+ lyrics: {
+ ...state.lyrics,
+ [state.selectedLanguage]: lines,
+ },
+ }
+ }
+
+ case "UPDATE_LINE": {
+ let lines = state.lyrics[state.selectedLanguage] ?? []
+
+ lines = lines.map((line) => {
+ if (line.time === action.payload.time) {
+ return action.payload
+ }
+
+ return line
+ })
+
+ return {
+ ...state,
+ lyrics: {
+ ...state.lyrics,
+ [state.selectedLanguage]: lines,
+ },
+ }
+ }
+
+ case "REMOVE_LINE": {
+ let lines = state.lyrics[state.selectedLanguage] ?? []
+
+ lines = lines.filter((line) => line.time !== action.payload.time)
+
+ return {
+ ...state,
+ lyrics: {
+ ...state.lyrics,
+ [state.selectedLanguage]: lines,
+ },
+ }
+ }
+
+ case "SET_SELECTED_LANGUAGE": {
+ return { ...state, selectedLanguage: action.payload }
+ }
+
+ case "SET_VIDEO_SOURCE": {
+ return {
+ ...state,
+ videoSource: action.payload,
+ }
+ }
+
+ case "SET_VIDEO_SYNC": {
+ return {
+ ...state,
+ videoSyncTime: action.payload,
+ }
+ }
+
+ case "SET_LOADING": {
+ return { ...state, loading: action.payload }
+ }
+
+ case "SET_SAVING": {
+ return { ...state, saving: action.payload }
+ }
+
+ case "RESET_STATE": {
+ return { ...initialState }
+ }
+
+ default: {
+ return state
+ }
+ }
+}
+
+export function LyricsEditorProvider({ children }) {
+ const [state, dispatch] = React.useReducer(lyricsReducer, initialState)
+
+ const value = React.useMemo(
+ () => ({
+ state,
+ dispatch,
+ }),
+ [state],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+LyricsEditorProvider.propTypes = {
+ children: PropTypes.node.isRequired,
+}
+
+export function useLyricsEditor() {
+ const context = React.useContext(LyricsEditorContext)
+
+ if (!context) {
+ throw new Error(
+ "useLyricsEditor must be used within a LyricsEditorProvider",
+ )
+ }
+
+ return context
+}
+
+export default LyricsEditorContext
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/hooks/useAudioPlayer.js b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/hooks/useAudioPlayer.js
new file mode 100644
index 00000000..9b24545f
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/hooks/useAudioPlayer.js
@@ -0,0 +1,251 @@
+import React, { useEffect, useCallback } from "react"
+import shaka from "shaka-player/dist/shaka-player.compiled.js"
+import { useLyricsEditor } from "../context/LyricsEditorContext"
+
+export const useAudioPlayer = (src) => {
+ const { state, dispatch } = useLyricsEditor()
+
+ const audioRef = React.useRef(new Audio())
+ const playerRef = React.useRef(null)
+ const waitTimeoutRef = React.useRef(null)
+ const lastSeekTimeRef = React.useRef(0)
+ const scrubTimeoutRef = React.useRef(null)
+
+ const initializePlayer = useCallback(async () => {
+ if (!src) {
+ return null
+ }
+
+ try {
+ dispatch({ type: "SET_AUDIO_LOADING", payload: true })
+ dispatch({ type: "SET_AUDIO_ERROR", payload: null })
+
+ audioRef.current.loop = true
+
+ // Cleanup existing player
+ if (playerRef.current) {
+ await playerRef.current.destroy()
+ playerRef.current = null
+ }
+
+ // Check browser support
+ if (!shaka.Player.isBrowserSupported()) {
+ throw new Error("Browser does not support DASH playback")
+ }
+
+ // Create new player
+ playerRef.current = new shaka.Player()
+
+ await playerRef.current.attach(audioRef.current)
+
+ // Setup DASH error handling
+ playerRef.current.addEventListener("error", (event) => {
+ const error = event.detail
+
+ dispatch({
+ type: "SET_AUDIO_ERROR",
+ payload: `DASH Error: ${error.message || "Playback failed"}`,
+ })
+ })
+
+ // Load the source
+ await playerRef.current.load(src)
+ dispatch({ type: "SET_AUDIO_LOADING", payload: false })
+ } catch (error) {
+ console.error("Player initialization error:", error)
+ dispatch({ type: "SET_AUDIO_ERROR", payload: error.message })
+ dispatch({ type: "SET_AUDIO_LOADING", payload: false })
+ }
+ }, [src, dispatch])
+
+ // Audio controls
+ const play = useCallback(async () => {
+ if (!audioRef.current) return
+
+ try {
+ await audioRef.current.play()
+ } catch (error) {
+ dispatch({
+ type: "SET_AUDIO_ERROR",
+ payload:
+ error.name === "NotAllowedError"
+ ? "Playback blocked. Please interact with the page first."
+ : "Failed to play audio",
+ })
+ }
+ }, [dispatch])
+
+ const pause = useCallback(() => {
+ if (audioRef.current) {
+ audioRef.current.pause()
+ }
+ }, [])
+
+ const toggle = useCallback(() => {
+ if (audioRef.current.paused) {
+ play()
+ } else {
+ pause()
+ }
+ }, [audioRef.current])
+
+ const seek = useCallback((time, scrub = false) => {
+ if (audioRef.current && audioRef.current.duration > 0) {
+ const clampedTime = Math.max(
+ 0,
+ Math.min(time, audioRef.current.duration),
+ )
+
+ // Update currentTime immediately for responsive UI
+ audioRef.current.currentTime = clampedTime
+
+ if (audioRef.current.paused) {
+ if (scrub === true) {
+ // Clear any pending scrub preview
+ if (scrubTimeoutRef.current) {
+ clearTimeout(scrubTimeoutRef.current)
+ }
+
+ const scrubDuration = 100
+
+ audioRef.current.play().then(() => {
+ scrubTimeoutRef.current = setTimeout(() => {
+ audioRef.current.pause()
+ audioRef.current.currentTime = clampedTime
+ }, scrubDuration)
+ })
+ } else {
+ audioRef.current.play()
+ }
+ }
+ }
+ }, [])
+
+ const setSpeed = useCallback(
+ (speed) => {
+ if (audioRef.current) {
+ const clampedSpeed = Math.max(0.25, Math.min(4, speed))
+ audioRef.current.playbackRate = clampedSpeed
+ dispatch({ type: "SET_AUDIO_SPEED", payload: clampedSpeed })
+ }
+ },
+ [dispatch],
+ )
+
+ const setVolume = useCallback(
+ (volume) => {
+ if (audioRef.current) {
+ const clampedVolume = Math.max(0, Math.min(1, volume))
+ audioRef.current.volume = clampedVolume
+ dispatch({ type: "SET_AUDIO_VOLUME", payload: clampedVolume })
+ }
+ },
+ [dispatch],
+ )
+
+ // Initialize player when src changes
+ useEffect(() => {
+ initializePlayer()
+
+ return () => {
+ if (playerRef.current) {
+ playerRef.current.destroy().catch(console.error)
+ }
+ }
+ }, [initializePlayer])
+
+ // Setup audio event listeners
+ useEffect(() => {
+ const audio = audioRef.current
+
+ if (!audio) {
+ return null
+ }
+
+ const handlePlay = () => {
+ dispatch({ type: "SET_AUDIO_PLAYING", payload: true })
+ }
+
+ const handlePause = () => {
+ dispatch({ type: "SET_AUDIO_PLAYING", payload: false })
+ }
+
+ const handleWaiting = () => {
+ if (waitTimeoutRef.current) {
+ clearTimeout(waitTimeoutRef.current)
+ waitTimeoutRef.current = null
+ }
+
+ waitTimeoutRef.current = setTimeout(() => {
+ dispatch({ type: "SET_AUDIO_LOADING", payload: true })
+ }, 1000)
+ }
+
+ const handlePlaying = () => {
+ if (waitTimeoutRef.current) {
+ clearTimeout(waitTimeoutRef.current)
+ waitTimeoutRef.current = null
+ }
+
+ waitTimeoutRef.current = setTimeout(() => {
+ dispatch({ type: "SET_AUDIO_LOADING", payload: false })
+ }, 300)
+ }
+
+ const handleError = () => {
+ const error = audio.error
+ let errorMessage = "Audio playback error"
+
+ if (error) {
+ switch (error.code) {
+ case error.MEDIA_ERR_NETWORK:
+ errorMessage = "Network error loading audio"
+ break
+ case error.MEDIA_ERR_DECODE:
+ errorMessage = "Audio decoding error"
+ break
+ case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
+ errorMessage = "Audio format not supported"
+ break
+ default:
+ errorMessage = "Unknown audio error"
+ }
+ }
+
+ dispatch({ type: "SET_AUDIO_ERROR", payload: errorMessage })
+ }
+
+ // Add event listeners
+ audio.addEventListener("play", handlePlay)
+ audio.addEventListener("pause", handlePause)
+ audio.addEventListener("waiting", handleWaiting)
+ audio.addEventListener("playing", handlePlaying)
+ audio.addEventListener("error", handleError)
+
+ return () => {
+ // Remove event listeners
+ audio.removeEventListener("play", handlePlay)
+ audio.removeEventListener("pause", handlePause)
+ audio.removeEventListener("waiting", handleWaiting)
+ audio.removeEventListener("playing", handlePlaying)
+ audio.removeEventListener("error", handleError)
+ }
+ }, [dispatch])
+
+ return {
+ audio: audioRef,
+ play,
+ pause,
+ toggle,
+ seek,
+ setSpeed,
+ setVolume,
+ isPlaying: state.isPlaying,
+ playbackSpeed: state.playbackSpeed,
+ volume: state.volume,
+ isLoading: state.isLoading,
+ error: state.error,
+ }
+}
+
+export default useAudioPlayer
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/index.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/index.jsx
new file mode 100644
index 00000000..ca604b2d
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/index.jsx
@@ -0,0 +1,240 @@
+import React, { useEffect } from "react"
+import PropTypes from "prop-types"
+import { Button, Segmented, Alert, Flex } from "antd"
+import { SaveOutlined } from "@ant-design/icons"
+import { parseLRC, formatToLRC } from "./utils/lrcParser"
+
+import {
+ LyricsEditorProvider,
+ useLyricsEditor,
+} from "./context/LyricsEditorContext"
+
+import Skeleton from "@components/Skeleton"
+
+import VideoEditor from "./tabs/videos"
+import LyricsEditor from "./tabs/lyrics"
+import InlinePlayer from "./components/InlinePlayer"
+import MusicModel from "@models/music"
+
+import "./index.less"
+
+const EnhancedLyricsEditorContent = ({ trackId }) => {
+ const { state, dispatch } = useLyricsEditor()
+
+ const [activeTab, setActiveTab] = React.useState("lyrics")
+ const playerRef = React.useRef(null)
+
+ const loadTrackData = async () => {
+ dispatch({ type: "SET_LOADING", payload: true })
+
+ try {
+ const track = await MusicModel.getTrackData(trackId)
+
+ if (!track) {
+ throw new Error("Track not found")
+ }
+
+ dispatch({ type: "SET_TRACK", payload: track })
+
+ let lyrics = await MusicModel.getTrackLyrics(trackId, {
+ fetchAll: true,
+ }).catch(() => {
+ return {
+ lrc: {
+ original: [],
+ },
+ }
+ })
+
+ for await (const [lang, lrc] of Object.entries(lyrics.lrc)) {
+ if (typeof lrc === "string" && lrc.startsWith("https://")) {
+ lyrics.lrc[lang] = await fetch(lrc).then((res) =>
+ res.text(),
+ )
+
+ lyrics.lrc[lang] = parseLRC(lyrics.lrc[lang])
+ }
+ }
+
+ dispatch({ type: "SET_LYRICS", payload: lyrics.lrc })
+
+ if (lyrics.video_source) {
+ dispatch({
+ type: "SET_VIDEO_SOURCE",
+ payload: lyrics.video_source,
+ })
+ dispatch({
+ type: "SET_VIDEO_SYNC",
+ payload: lyrics.video_starts_at ?? lyrics.sync_audio_at,
+ })
+ }
+ } catch (error) {
+ console.error("Failed to load track:", error)
+ } finally {
+ dispatch({ type: "SET_LOADING", payload: false })
+ }
+ }
+
+ const handleSave = async () => {
+ dispatch({ type: "SET_SAVING", payload: true })
+
+ try {
+ const saveData = {
+ video_source: state.videoSource || null,
+ video_starts_at: state.videoSyncTime || null,
+ lrc: state.lyrics,
+ }
+
+ await MusicModel.putTrackLyrics(trackId, saveData)
+
+ app.message.success("Changes saved successfully")
+ } catch (error) {
+ console.error("Save failed:", error)
+ app.message.error("Failed to save changes")
+ } finally {
+ dispatch({ type: "SET_SAVING", payload: false })
+ }
+ }
+
+ const keyboardEvents = {
+ Space: () => {
+ const { toggle } = playerRef.current
+
+ toggle()
+ },
+ ArrowLeft: (event) => {
+ const { seek, audio } = playerRef.current
+
+ if (event.ctrlKey) {
+ if (event.ctrlKey && event.shiftKey) {
+ seek(audio.current.currentTime - 0.001, true)
+ } else {
+ seek(audio.current.currentTime - 0.1, true)
+ }
+ } else {
+ seek(audio.current.currentTime - 1, true)
+ }
+ },
+ ArrowRight: (event) => {
+ const { seek, audio } = playerRef.current
+
+ if (event.ctrlKey) {
+ if (event.ctrlKey && event.shiftKey) {
+ seek(audio.current.currentTime + 0.001, true)
+ } else {
+ seek(audio.current.currentTime + 0.1, true)
+ }
+ } else {
+ seek(audio.current.currentTime + 1, true)
+ }
+ },
+ }
+
+ const handleKeyDown = React.useCallback((event) => {
+ // check the target is not a input element
+ if (
+ event.target.nodeName === "INPUT" ||
+ event.target.nodeName === "TEXTAREA" ||
+ event.target.nodeName === "SELECT" ||
+ event.target.nodeName === "OPTION" ||
+ event.target.nodeName === "BUTTON"
+ ) {
+ return false
+ }
+
+ if (keyboardEvents[event.code]) {
+ keyboardEvents[event.code](event)
+ }
+ }, [])
+
+ React.useEffect(() => {
+ document.addEventListener("keydown", handleKeyDown)
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown)
+ }
+ }, [])
+
+ // Loader effect
+ useEffect(() => {
+ if (trackId) {
+ loadTrackData()
+ }
+ }, [])
+
+ if (state.loading || !state.track) {
+ return
+ }
+
+ return (
+
+
+ {state.track.title}
+
+ }
+ onClick={handleSave}
+ loading={state.saving}
+ //disabled={!state.isDirty}
+ >
+ Save Changes
+
+
+
+
+
+
+
+ {activeTab === "lyrics" && }
+ {activeTab === "video" && }
+
+ )
+}
+
+EnhancedLyricsEditorContent.propTypes = {
+ trackId: PropTypes.string.isRequired,
+}
+
+const EnhancedLyricsEditor = ({ params }) => {
+ const trackId = params?.track_id
+
+ if (!trackId) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+EnhancedLyricsEditor.options = {
+ layout: {
+ type: "default",
+ centeredContent: true,
+ },
+}
+
+EnhancedLyricsEditor.propTypes = {
+ params: PropTypes.shape({
+ track_id: PropTypes.string.isRequired,
+ }).isRequired,
+}
+
+export default EnhancedLyricsEditor
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/index.less b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/index.less
new file mode 100644
index 00000000..e5652204
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/index.less
@@ -0,0 +1,9 @@
+.avlyrics-editor {
+ display: flex;
+ flex-direction: column;
+
+ width: 100%;
+ max-width: 800px;
+
+ gap: 20px;
+}
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/lyrics/index.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/lyrics/index.jsx
new file mode 100644
index 00000000..717da91d
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/lyrics/index.jsx
@@ -0,0 +1,520 @@
+import React from "react"
+import PropTypes from "prop-types"
+import classnames from "classnames"
+
+import { parseLRC, formatToLRC } from "../../utils/lrcParser"
+
+import {
+ Input,
+ Button,
+ List,
+ Space,
+ Typography,
+ Select,
+ Row,
+ Col,
+ Popconfirm,
+ InputNumber,
+ Empty,
+ Flex,
+ Switch,
+} from "antd"
+
+import {
+ PlusOutlined,
+ DeleteOutlined,
+ EditOutlined,
+ SaveOutlined,
+ CloseOutlined,
+ PlayCircleOutlined,
+} from "@ant-design/icons"
+
+import { MdSpaceBar } from "react-icons/md"
+
+import "./index.less"
+
+import { useLyricsEditor } from "../../context/LyricsEditorContext"
+import { formatSecondsToLRC } from "../../utils/lrcParser"
+
+import Languages from "@config/languages"
+
+const { Text } = Typography
+const { TextArea } = Input
+
+const languageOptions = [
+ ...Object.entries(Languages).map(([key, value]) => ({
+ label: value,
+ value: key,
+ })),
+ { label: "Original", value: "original" },
+]
+
+const Line = ({
+ line,
+ editData,
+ setEditData,
+
+ active,
+
+ handleSeek,
+ handleDeleteLine,
+ handleEditLineSave,
+ handleEditLineCancel,
+ handleEditLineStart,
+ handleClickDuplicate,
+ handleEditLineSetAsBreak,
+}) => {
+ const editMode = editData && editData.time === line.time
+
+ if (editMode) {
+ return (
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ }
+ onClick={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ handleSeek(line.time)
+ }}
+ style={{
+ padding: 0,
+ height: "auto",
+ fontSize: "12px",
+ }}
+ >
+ {formatSecondsToLRC(line.time)}
+
+
+
+
+
+ {line.break && ""}
+ {!line.break && line.text}
+
+
+
+
+
+ }
+ onClick={() => handleEditLineStart(line)}
+ style={{ padding: "4px" }}
+ />
+ handleDeleteLine(line)}
+ okText="Delete"
+ cancelText="Cancel"
+ placement="topRight"
+ >
+ }
+ danger
+ style={{
+ padding: "4px",
+ }}
+ />
+
+
+
+
+
+ )
+}
+
+const LyricsEditor = ({ player }) => {
+ const { state, dispatch } = useLyricsEditor()
+
+ const newLineTextRef = React.useRef(null)
+ const linesListRef = React.useRef(null)
+
+ // ticker
+ const tickerRef = React.useRef(null)
+ const [followTime, setFollowTime] = React.useState(true)
+ const [lineIndex, setLineIndex] = React.useState(null)
+
+ const [newLineText, setNewLineText] = React.useState("")
+
+ const [editData, setEditData] = React.useState(null)
+
+ const lines = state.lyrics[state.selectedLanguage] ?? []
+
+ const scrollToTime = React.useCallback((time) => {
+ const lineSelector = `#t${parseInt(time * 1000)}`
+
+ const lineElement = linesListRef.current.querySelector(lineSelector)
+
+ if (lineElement) {
+ lineElement.scrollIntoView({ behavior: "smooth" })
+ }
+ }, [])
+
+ const handleAddLine = () => {
+ if (!newLineText.trim()) {
+ return null
+ }
+
+ const time = player.current.audio.current.currentTime
+
+ dispatch({
+ type: "ADD_LINE",
+ payload: {
+ text: newLineText.trim(),
+ time: time,
+ },
+ })
+
+ setNewLineText("")
+ scrollToTime(time)
+ }
+
+ const handleEditLineStart = (line) => {
+ setEditData({
+ text: line.text,
+ time: line.time || 0,
+ })
+ }
+
+ const handleEditLineSave = () => {
+ dispatch({
+ type: "UPDATE_LINE",
+ payload: editData,
+ })
+
+ setEditData(null)
+ }
+
+ const handleEditLineCancel = () => {
+ setEditData(null)
+ }
+
+ const handleDeleteLine = (line) => {
+ dispatch({
+ type: "REMOVE_LINE",
+ payload: line,
+ })
+ }
+
+ const handleAddLineBreak = () => {
+ const time = player.current.audio.current.currentTime
+
+ dispatch({
+ type: "ADD_LINE",
+ payload: {
+ break: true,
+ time: time,
+ },
+ })
+
+ scrollToTime(time)
+ }
+
+ const handleClickDuplicate = (line) => {
+ const nextTime = line.time + 0.4
+
+ dispatch({
+ type: "ADD_LINE",
+ payload: {
+ text: line.text,
+ time: nextTime,
+ },
+ })
+ }
+
+ const handleSeek = (time) => {
+ // TODO: call to player seek function
+ player.current.seek(time)
+ }
+
+ const handleLanguageUpload = async () => {
+ const input = document.createElement("input")
+
+ input.type = "file"
+ input.accept = "text/*"
+
+ input.onchange = async (e) => {
+ const file = e.target.files[0]
+ const text = await file.text()
+
+ dispatch({
+ type: "OVERRIDE_LINES",
+ payload: parseLRC(text),
+ })
+
+ app.message.success("Language file loaded")
+ }
+
+ input.click()
+ }
+
+ const handleLanguageDownload = () => {
+ const data = formatToLRC(lines)
+ const blob = new Blob([data], { type: "text/plain" })
+ const url = URL.createObjectURL(blob)
+
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `${state.track.title} - ${state.selectedLanguage}.txt`
+ link.click()
+ }
+
+ const followLineTick = () => {
+ const currentTime = player.current.audio.current.currentTime
+
+ const lineIndex = lines.findLastIndex((line) => {
+ return currentTime >= line.time
+ })
+
+ if (lineIndex <= -1) {
+ return false
+ }
+
+ setLineIndex(lineIndex)
+ }
+
+ const handleSelectLanguageChange = (language) => {
+ dispatch({
+ type: "SET_SELECTED_LANGUAGE",
+ payload: language,
+ })
+ }
+
+ React.useEffect(() => {
+ if (state.isPlaying) {
+ if (tickerRef.current) {
+ clearInterval(tickerRef.current)
+ }
+
+ tickerRef.current = setInterval(followLineTick, 200)
+ }
+
+ return () => {
+ clearInterval(tickerRef.current)
+ }
+ }, [followTime, state.isPlaying])
+
+ React.useEffect(() => {
+ if (followTime === true && lineIndex !== -1) {
+ const line = lines[lineIndex]
+
+ if (line) {
+ scrollToTime(line.time)
+ }
+ }
+ }, [lineIndex])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {state.lyrics.length === 0 && (
+
+
+ Add lyrics manually or upload an LRC file
+
+
+ )}
+
+
+
+ {lines.length} lines
+
+
+
+
+ {lines.map((line, index) => {
+ return (
+
+ )
+ })}
+
+
+ )
+}
+
+LyricsEditor.propTypes = {
+ lyrics: PropTypes.arrayOf(PropTypes.string).isRequired,
+}
+
+export default LyricsEditor
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/lyrics/index.less b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/lyrics/index.less
new file mode 100644
index 00000000..c1ff7898
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/lyrics/index.less
@@ -0,0 +1,61 @@
+.avlyrics-editor-list {
+ display: flex;
+ flex-direction: column;
+
+ height: 500px;
+
+ overflow: overlay;
+
+ background-color: var(--background-color-accent);
+
+ border-radius: 12px;
+}
+
+.avlyrics-editor-list-item {
+ position: relative;
+
+ display: flex;
+ flex-direction: row;
+
+ align-items: center;
+
+ width: 100%;
+
+ padding: 15px 10px;
+
+ &.active {
+ &:before {
+ position: absolute;
+
+ content: "";
+
+ top: 0;
+ bottom: 0;
+ left: 0;
+
+ margin: auto;
+ margin-left: 3px;
+
+ width: 5px;
+ height: 70%;
+
+ border-radius: 12px;
+ background-color: var(--colorPrimary);
+
+ //animation: active-line-indicator-enter 150ms ease-in-out linear;
+ }
+ }
+
+ &:not(:last-child) {
+ border-bottom: 1px solid var(--border-color);
+ }
+}
+
+@keyframes active-line-indicator-enter {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/videos/index.jsx b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/videos/index.jsx
new file mode 100644
index 00000000..36b90592
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/videos/index.jsx
@@ -0,0 +1,189 @@
+import React, { useState, useCallback } from "react"
+import {
+ Card,
+ Input,
+ TimePicker,
+ Space,
+ Button,
+ Empty,
+ Switch,
+ Typography,
+ message,
+} from "antd"
+import {
+ VideoCameraOutlined,
+ ClockCircleOutlined,
+ UploadOutlined,
+} from "@ant-design/icons"
+import dayjs from "dayjs"
+import customParseFormat from "dayjs/plugin/customParseFormat"
+
+import { useLyricsEditor } from "../../context/LyricsEditorContext"
+import UploadButton from "@components/UploadButton"
+import VideoPlayer from "@components/VideoPlayer"
+
+import "./index.less"
+
+dayjs.extend(customParseFormat)
+
+const { Title, Text } = Typography
+
+const VideoEditor = () => {
+ const { state, dispatch } = useLyricsEditor()
+ const [inputUrl, setInputUrl] = useState(state.videoSource || "")
+
+ const handleVideoUpload = useCallback(
+ (response) => {
+ const url = response.url
+ dispatch({ type: "SET_VIDEO_SOURCE", payload: url })
+ setInputUrl(url)
+ message.success("Video uploaded successfully")
+ },
+ [dispatch],
+ )
+
+ const handleUrlChange = useCallback((e) => {
+ const url = e.target.value
+ setInputUrl(url)
+ }, [])
+
+ const handleUrlSet = useCallback(() => {
+ if (inputUrl !== state.videoSource) {
+ dispatch({ type: "SET_VIDEO_SOURCE", payload: inputUrl })
+ message.success("Video URL updated")
+ }
+ }, [inputUrl, state.videoSource, dispatch])
+
+ const handleSyncTimeChange = useCallback(
+ (time, timeString) => {
+ console.log("changed:", time, timeString)
+ dispatch({ type: "SET_VIDEO_SYNC", payload: timeString })
+ },
+ [dispatch],
+ )
+
+ const handleLoopingChange = useCallback((checked) => {
+ // Note: looping is not in simplified context, could be local state if needed
+ console.log("Looping changed:", checked)
+ }, [])
+
+ const videoControls = [
+ "play",
+ "current-time",
+ "seek-time",
+ "duration",
+ "progress",
+ "settings",
+ ]
+
+ const syncTime = state.videoSyncTime
+ ? dayjs(state.videoSyncTime, "mm:ss:SSS")
+ : null
+
+ return (
+
+
+ Video Editor
+
+ }
+ >
+ {state.videoSource ? (
+
+
+
+ ) : (
+
+ }
+ description="No video loaded"
+ />
+ )}
+
+
+
+
+
+ Video sync time:
+ {state.videoSyncTime || "not set"}
+
+
+
+ Set sync point:
+
+
+
+
+
+
+
+
+
+ handleVideoUpload(data.url)
+ }
+ accept={["video/*"]}
+ headers={{ transformations: "mq-hls" }}
+ disabled={state.saving}
+ icon={}
+ >
+ Upload Video
+
+ or
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default VideoEditor
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/videos/index.less b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/videos/index.less
new file mode 100644
index 00000000..d7517828
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/tabs/videos/index.less
@@ -0,0 +1,203 @@
+.video-editor {
+ .ant-card-head {
+ border-bottom: 1px solid var(--border-color-light);
+
+ .ant-card-head-title {
+ padding: 16px 0;
+
+ .ant-typography {
+ .anticon {
+ color: var(--primary-color);
+ }
+ }
+ }
+ }
+
+ .ant-card-body {
+ padding: 24px;
+ }
+
+ .video-preview {
+ width: 100%;
+ height: 350px;
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--background-color-dark);
+ margin-bottom: 24px;
+ border: 1px solid var(--border-color-light);
+
+ @media (max-width: 768px) {
+ height: 250px;
+ }
+ }
+
+ .ant-empty {
+ padding: 60px 20px;
+ background: var(--background-color-light);
+ border-radius: 8px;
+ border: 2px dashed var(--border-color-light);
+ margin-bottom: 24px;
+
+ .ant-empty-description {
+ color: var(--text-color-secondary);
+ font-size: 14px;
+ }
+ }
+
+ .sync-controls {
+ padding: 16px;
+ background: var(--background-color-light);
+ border-radius: 8px;
+ border: 1px solid var(--border-color-light);
+
+ .ant-space {
+ width: 100%;
+ justify-content: space-between;
+
+ @media (max-width: 768px) {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 12px;
+ }
+ }
+
+ .ant-picker {
+ border-radius: 6px;
+ font-family: 'Courier New', monospace;
+
+ &:focus {
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
+ }
+ }
+
+ .ant-switch {
+ &.ant-switch-checked {
+ background-color: var(--success-color);
+ }
+ }
+
+ .ant-typography {
+ &.ant-typography code {
+ background: var(--background-color);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 2px 6px;
+ font-family: 'Courier New', monospace;
+ font-size: 12px;
+ }
+ }
+ }
+
+ .upload-controls {
+ padding: 16px;
+ background: var(--background-color);
+ border-radius: 8px;
+ border: 1px solid var(--border-color-light);
+
+ .ant-btn {
+ border-radius: 6px;
+ transition: all 0.2s ease;
+
+ &:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ }
+
+ &.ant-btn-primary {
+ background: var(--primary-color);
+ border-color: var(--primary-color);
+
+ &:hover:not(:disabled) {
+ background: var(--primary-color-hover);
+ border-color: var(--primary-color-hover);
+ }
+ }
+ }
+
+ .ant-input {
+ border-radius: 6px 0 0 6px;
+
+ &:focus {
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
+ }
+ }
+
+ .ant-input-group-compact {
+ .ant-btn {
+ border-radius: 0 6px 6px 0;
+ border-left: none;
+ }
+ }
+
+ .ant-typography {
+ font-size: 12px;
+ color: var(--text-color-secondary);
+ }
+ }
+
+ // Dark theme support
+ .dark & {
+ .video-preview {
+ background: var(--background-color-darker);
+ border-color: var(--border-color-dark);
+ }
+
+ .sync-controls,
+ .upload-controls {
+ background: var(--background-color-darker);
+ border-color: var(--border-color-dark);
+ }
+
+ .ant-empty {
+ background: var(--background-color-darker);
+ border-color: var(--border-color-dark);
+ }
+ }
+
+ // Mobile responsiveness
+ @media (max-width: 576px) {
+ .ant-card-body {
+ padding: 16px;
+ }
+
+ .sync-controls,
+ .upload-controls {
+ padding: 12px;
+ }
+
+ .upload-controls {
+ .ant-space-compact {
+ flex-direction: column;
+
+ .ant-input,
+ .ant-btn {
+ border-radius: 6px;
+ border: 1px solid var(--border-color);
+ }
+
+ .ant-btn {
+ margin-top: 8px;
+ width: 100%;
+ }
+ }
+ }
+ }
+
+ // High contrast mode
+ @media (prefers-contrast: high) {
+ .video-preview,
+ .sync-controls,
+ .upload-controls {
+ border-width: 2px;
+ }
+ }
+
+ // Reduced motion
+ @media (prefers-reduced-motion: reduce) {
+ .ant-btn:hover {
+ transform: none;
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/utils/formatTime.js b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/utils/formatTime.js
new file mode 100644
index 00000000..ce00cf9d
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/utils/formatTime.js
@@ -0,0 +1,11 @@
+export default (seconds) => {
+ if (!seconds || isNaN(seconds)) {
+ return "00:00.000"
+ }
+
+ const minutes = Math.floor(seconds / 60)
+ const secs = Math.floor(seconds % 60)
+ const ms = Math.floor((seconds % 1) * 1000)
+
+ return `${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${ms.toString().padStart(3, "0")}`
+}
diff --git a/packages/app/src/pages/studio/music/track_lyrics/[track_id]/utils/lrcParser.js b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/utils/lrcParser.js
new file mode 100644
index 00000000..751fd9ee
--- /dev/null
+++ b/packages/app/src/pages/studio/music/track_lyrics/[track_id]/utils/lrcParser.js
@@ -0,0 +1,274 @@
+/**
+ * LRC Parser Utility
+ * Handles parsing and formatting of LRC (Lyric) files
+ */
+
+/**
+ * Parse time string in format MM:SS.SSS or MM:SS to seconds
+ * @param {string} timeStr - Time string like "01:23.45"
+ * @returns {number} Time in seconds
+ */
+export const parseTimeToSeconds = (timeStr) => {
+ const match = timeStr.match(/^(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?$/)
+ if (!match) return 0
+
+ const minutes = parseInt(match[1], 10)
+ const seconds = parseInt(match[2], 10)
+ const milliseconds = match[3] ? parseInt(match[3].padEnd(3, "0"), 10) : 0
+
+ return minutes * 60 + seconds + milliseconds / 1000
+}
+
+/**
+ * Convert seconds to LRC time format MM:SS.SSS
+ * @param {number} seconds - Time in seconds
+ * @returns {string} Formatted time string
+ */
+export const formatSecondsToLRC = (seconds) => {
+ const minutes = Math.floor(seconds / 60)
+ const secs = Math.floor(seconds % 60)
+ const ms = Math.floor((seconds % 1) * 1000)
+
+ return `${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${ms.toString().padStart(3, "0")}`
+}
+
+/**
+ * Parse LRC content into structured data
+ * @param {string} lrcContent - Raw LRC file content
+ * @returns {Object} Parsed LRC data
+ */
+export const parseLRC = (lrcContent) => {
+ if (!lrcContent || typeof lrcContent !== "string") {
+ return []
+ }
+
+ const lines = lrcContent
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+
+ const lyrics = []
+
+ for (const line of lines) {
+ // Check for timestamped lyrics [MM:SS.SSS]text
+ const timestampMatch = line.match(
+ /^\[(\d{1,2}:\d{2}(?:\.\d{1,3})?)\](.*)$/,
+ )
+
+ if (timestampMatch) {
+ const [, timeStr, text] = timestampMatch
+ const time = parseTimeToSeconds(timeStr)
+
+ if (text.trim() === "") {
+ lyrics.push({
+ time: time,
+ break: true,
+ })
+
+ continue
+ }
+
+ lyrics.push({
+ time: time,
+ text: text.trim(),
+ })
+
+ continue
+ }
+ }
+
+ // Sort lyrics by timestamp
+ lyrics.sort((a, b) => {
+ if (a.time === null) return -1
+ if (b.time === null) return 1
+ return a.time - b.time
+ })
+
+ return lyrics
+}
+
+/**
+ * Convert structured lyrics data back to LRC format
+ * @param {Object} lrcData - Structured LRC data
+ * @returns {string} LRC formatted string
+ */
+export const formatToLRC = (lines) => {
+ const data = []
+
+ lines.forEach((line) => {
+ if (line.time !== null) {
+ const timeStr = line.timeStr || formatSecondsToLRC(line.time)
+
+ if (line.break) {
+ data.push(`[${timeStr}]`)
+ } else {
+ data.push(`[${timeStr}] ${line.text}`)
+ }
+ }
+ })
+
+ return data.join("\n")
+}
+
+/**
+ * Find the current lyric line based on current time
+ * @param {Array} lyrics - Array of lyric objects
+ * @param {number} currentTime - Current playback time in seconds
+ * @returns {Object|null} Current lyric object
+ */
+export const getCurrentLyric = (lyrics, currentTime) => {
+ if (!lyrics || lyrics.length === 0) return null
+
+ // Filter out lyrics without timestamps
+ const timedLyrics = lyrics.filter((lyric) => lyric.time !== null)
+
+ if (timedLyrics.length === 0) return null
+
+ // Find the last lyric that has passed
+ let currentLyric = null
+ for (let i = 0; i < timedLyrics.length; i++) {
+ if (timedLyrics[i].time <= currentTime) {
+ currentLyric = timedLyrics[i]
+ } else {
+ break
+ }
+ }
+
+ return currentLyric
+}
+
+/**
+ * Get next lyric line
+ * @param {Array} lyrics - Array of lyric objects
+ * @param {number} currentTime - Current playback time in seconds
+ * @returns {Object|null} Next lyric object
+ */
+export const getNextLyric = (lyrics, currentTime) => {
+ if (!lyrics || lyrics.length === 0) return null
+
+ const timedLyrics = lyrics.filter((lyric) => lyric.time !== null)
+
+ for (const lyric of timedLyrics) {
+ if (lyric.time > currentTime) {
+ return lyric
+ }
+ }
+
+ return null
+}
+
+/**
+ * Insert a new lyric at specific time
+ * @param {Array} lyrics - Current lyrics array
+ * @param {number} time - Time in seconds
+ * @param {string} text - Lyric text
+ * @returns {Array} Updated lyrics array
+ */
+export const insertLyric = (lyrics, time, text) => {
+ const newLyric = {
+ time,
+ timeStr: formatSecondsToLRC(time),
+ text,
+ id: `${time}-${Math.random().toString(36).substr(2, 9)}`,
+ }
+
+ const updatedLyrics = [...lyrics, newLyric]
+
+ // Sort by time
+ return updatedLyrics.sort((a, b) => {
+ if (a.time === null) return -1
+ if (b.time === null) return 1
+ return a.time - b.time
+ })
+}
+
+/**
+ * Update existing lyric
+ * @param {Array} lyrics - Current lyrics array
+ * @param {string} id - Lyric ID to update
+ * @param {Object} updates - Updates to apply
+ * @returns {Array} Updated lyrics array
+ */
+export const updateLyric = (lyrics, id, updates) => {
+ return lyrics.map((lyric) => {
+ if (lyric.id === id) {
+ const updated = { ...lyric, ...updates }
+ // Update timeStr if time was changed
+ if (updates.time !== undefined && updates.time !== null) {
+ updated.timeStr = formatSecondsToLRC(updates.time)
+ }
+ return updated
+ }
+ return lyric
+ })
+}
+
+/**
+ * Remove lyric by ID
+ * @param {Array} lyrics - Current lyrics array
+ * @param {string} id - Lyric ID to remove
+ * @returns {Array} Updated lyrics array
+ */
+export const removeLyric = (lyrics, id) => {
+ return lyrics.filter((lyric) => lyric.id !== id)
+}
+
+/**
+ * Validate LRC format
+ * @param {string} lrcContent - LRC content to validate
+ * @returns {Object} Validation result
+ */
+export const validateLRC = (lrcContent) => {
+ const errors = []
+ const warnings = []
+
+ if (!lrcContent || typeof lrcContent !== "string") {
+ errors.push("Invalid LRC content")
+ return { isValid: false, errors, warnings }
+ }
+
+ const lines = lrcContent.split("\n")
+ let hasTimestamps = false
+
+ lines.forEach((line, index) => {
+ const trimmed = line.trim()
+ if (!trimmed) return
+
+ // Check metadata format
+ const metadataMatch = trimmed.match(/^\[([a-z]+):(.+)\]$/i)
+ if (metadataMatch) return
+
+ // Check timestamp format
+ const timestampMatch = trimmed.match(
+ /^\[(\d{1,2}:\d{2}(?:\.\d{1,3})?)\](.*)$/,
+ )
+ if (timestampMatch) {
+ hasTimestamps = true
+ const [, timeStr] = timestampMatch
+ const time = parseTimeToSeconds(timeStr)
+ if (time < 0) {
+ errors.push(
+ `Invalid timestamp at line ${index + 1}: ${timeStr}`,
+ )
+ }
+ return
+ }
+
+ // Check for malformed brackets
+ if (trimmed.includes("[") || trimmed.includes("]")) {
+ warnings.push(
+ `Possible malformed tag at line ${index + 1}: ${trimmed}`,
+ )
+ }
+ })
+
+ if (!hasTimestamps) {
+ warnings.push("No timestamps found in LRC content")
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings,
+ }
+}
diff --git a/packages/app/src/pages/timeline/index.jsx b/packages/app/src/pages/timeline/index.jsx
index f215a2e5..9839cc4c 100755
--- a/packages/app/src/pages/timeline/index.jsx
+++ b/packages/app/src/pages/timeline/index.jsx
@@ -1,7 +1,7 @@
-import React from "react"
import { Translation } from "react-i18next"
import { PagePanelWithNavMenu } from "@components/PagePanels"
+
import TrendingsCard from "@components/TrendingsCard"
import FeaturedEvents from "@components/FeaturedEventsAnnouncements"
diff --git a/packages/app/src/pages/tv/live/[id]/decoders/index.js b/packages/app/src/pages/tv/live/[id]/decoders/index.js
index fd2669a3..ab7b4888 100644
--- a/packages/app/src/pages/tv/live/[id]/decoders/index.js
+++ b/packages/app/src/pages/tv/live/[id]/decoders/index.js
@@ -1,2 +1 @@
export { default as hls } from "./hls"
-export { default as shaka } from "./shaka"
diff --git a/packages/app/src/pages/tv/live/[id]/decoders/shaka.js b/packages/app/src/pages/tv/live/[id]/decoders/shaka.js
deleted file mode 100644
index 9d5e79dd..00000000
--- a/packages/app/src/pages/tv/live/[id]/decoders/shaka.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import shaka from "shaka-player"
-
-export default async (player, sources = {}, options = {}) => {
- if (!player) {
- console.error("[Shaka] player is not defined")
- return false
- }
-
- if (!sources.hls) {
- console.error("[Shaka] an hls source is not provided")
- return false
- }
-
- let source = sources.hls
-
- // Initialize shaka player
- const shakaInstance = new shaka.Player(player)
-
- // Helper function to sync to live edge
- const syncToLive = () => {
- if (shakaInstance.isLive()) {
- const end = shakaInstance.seekRange().end
- player.currentTime = end
- }
- }
-
- // Configure for low-latency HLS
- shakaInstance.configure({
- streaming: {
- lowLatencyMode: true,
- inaccurateManifestTolerance: 0,
- rebufferingGoal: 0.01,
- bufferingGoal: 0.1,
- bufferBehind: 30,
- startAtSegmentBoundary: false,
- durationBackoff: 0.2,
- },
- })
-
- // Add request filter for authentication if token is provided
- if (options.authToken) {
- shakaInstance
- .getNetworkingEngine()
- .registerRequestFilter((type, request) => {
- request.headers = {
- ...request.headers,
- Authorization: `Bearer ${options.authToken}`,
- }
- })
- source += `?token=${options.authToken}`
- }
-
- console.log("[Shaka] Instance options >", options)
- console.log(`[Shaka] Loading source [${source}]`)
-
- // Error handling
- shakaInstance.addEventListener("error", (error) => {
- console.error("[Shaka] Error", error)
- })
-
- // Buffer state monitoring
- player.addEventListener("waiting", () => {
- console.log("[Shaka] Buffer underrun")
- })
-
- // Handle stream end
- player.addEventListener("ended", () => {
- console.log("[Shaka] Stream ended")
- if (typeof options.onSourceEnd === "function") {
- options.onSourceEnd()
- }
- })
-
- try {
- await shakaInstance.load(source)
- console.log("[Shaka] Stream loaded successfully")
-
- const tracks = shakaInstance.getVariantTracks()
- console.log("[Shaka] Available qualities >", tracks)
- } catch (error) {
- console.error("[Shaka] Error loading stream:", error)
- }
-
- player.addEventListener("play", () => {
- console.log("[SHAKA] Syncing to last position")
- syncToLive()
- })
-
- // Add destroy method for cleanup
- shakaInstance._destroy = () => {
- try {
- shakaInstance.unload()
- shakaInstance.destroy()
- } catch (error) {
- console.error("[Shaka] Error during cleanup:", error)
- }
- }
-
- return shakaInstance
-}
diff --git a/packages/app/src/router.jsx b/packages/app/src/router.jsx
deleted file mode 100755
index b97d6ab1..00000000
--- a/packages/app/src/router.jsx
+++ /dev/null
@@ -1,244 +0,0 @@
-import React from "react"
-import { BrowserRouter, Route, Routes, useNavigate, useParams } from "react-router-dom"
-import { Skeleton } from "antd"
-import config from "@config"
-import loadable from "@loadable/component"
-
-import routesDeclaration from "@config/routes"
-
-const DefaultNotFoundRender = () => {
- return Not found
-}
-
-const DefaultLoadingRender = () => {
- return
-}
-
-const getPagePaths = () => {
- let paths = {
- ...import.meta.glob("/src/pages/**/[a-z[]*.jsx"),
- ...import.meta.glob("/src/pages/**/[a-z[]*.tsx"),
- }
-
- if (app.isMobile) {
- paths = {
- ...paths,
- ...import.meta.glob("/src/pages/**/[a-z[]*.mobile.jsx"),
- ...import.meta.glob("/src/pages/**/[a-z[]*.mobile.tsx"),
- }
-
- // find & replace matching non mobile routes with mobile routes
- Object.keys(paths).forEach((path) => {
- const mobilePath = path.replace(/\.jsx$/, ".mobile.jsx").replace(/\.tsx$/, ".mobile.tsx")
-
- if (paths[mobilePath]) {
- delete paths[path]
- }
- })
- }
-
- return paths
-}
-
-const generateRoutes = () => {
- let paths = getPagePaths()
-
- return Object.keys(paths).map((route) => {
- let path = route
- .replace(/\/src\/pages|index|\.jsx$/g, "")
- .replace(/\/src\/pages|index|\.tsx$/g, "")
- .replace(/\/src\/pages|index|\.mobile|\.jsx$/g, "")
- .replace(/\/src\/pages|index|\.mobile|\.tsx$/g, "")
-
- path = path.replace(/\[([a-z]+)\]/g, ":$1")
- path = path.replace(/\[\.{3}.+\]/, "*").replace(/\[(.+)\]/, ":$1")
-
- return {
- path,
- element: paths[route],
- }
- })
-}
-
-function findRouteDeclaration(route) {
- return routesDeclaration.find((layout) => {
- const routePath = layout.path.replace(/\*/g, ".*").replace(/!/g, "^")
-
- return new RegExp(routePath).test(route)
- }) ?? {
- path: route,
- useLayout: "default",
- }
-}
-
-function isAuthenticated() {
- return !!app.userData
-}
-
-function handleRouteDeclaration(declaration) {
- React.useEffect(() => {
- if (declaration) {
- // if not authenticated and is not in public route, redirect
- if (!isAuthenticated() && !declaration.public && (window.location.pathname !== config.app?.authPath)) {
- if (typeof window.app.location.push === "function") {
- window.app.location.push(config.app?.authPath ?? "/login")
-
- app.cores.notifications.new({
- title: "Please login to use this feature.",
- duration: 15,
- })
- } else {
- window.location.href = config.app?.authPath ?? "/login"
- }
- } else {
- if (declaration.useLayout) {
- app.layout.set(declaration.useLayout)
- }
-
- if (typeof declaration.centeredContent !== "undefined") {
- let finalBool = null
-
- if (typeof declaration.centeredContent === "boolean") {
- finalBool = declaration.centeredContent
- } else {
- if (app.isMobile) {
- finalBool = declaration.centeredContent?.mobile ?? null
- } else {
- finalBool = declaration.centeredContent?.desktop ?? null
- }
- }
-
- app.layout.toggleCenteredContent(finalBool)
- }
-
- if (typeof declaration.useTitle !== "undefined") {
- if (typeof declaration.useTitle === "function") {
- declaration.useTitle = declaration.useTitle(path, params)
- }
-
- document.title = `${declaration.useTitle} - ${config.app.siteName}`
- } else {
- document.title = config.app.siteName
- }
- }
- }
- }, [])
-}
-
-function generatePageElementWrapper(path, element, props, declaration) {
- return React.createElement((props) => {
- const params = useParams()
- const url = new URL(window.location)
- const query = new Proxy(url, {
- get: (target, prop) => target.searchParams.get(prop),
- })
-
- handleRouteDeclaration(declaration)
-
- return React.createElement(
- loadable(element, {
- fallback: React.createElement(props.staticRenders?.PageLoad || DefaultLoadingRender),
- }),
- {
- ...props,
- ...props,
- url: url,
- params: params,
- query: query,
- })
- })
-}
-
-const NavigationController = (props) => {
- if (!app.location) {
- app.location = Object()
- }
-
- const navigate = useNavigate()
-
- async function setLocation(to, state = {}) {
- // clean double slashes
- to = to.replace(/\/{2,}/g, "/")
-
- // if state is a number, it's a delay
- if (typeof state !== "object") {
- state = {}
- }
-
- app.location.last = window.location
-
- await navigate(to, {
- state
- })
-
- app.eventBus.emit("router.navigate", to, {
- state,
- })
-
- return {
- to,
- state,
- }
- }
-
- async function backLocation() {
- return window.history.back()
- }
-
- async function onHistoryChange() {
- setTimeout(() => {
- app.eventBus.emit("router.navigate", window.location.pathname, {
- state: window.location.state,
- })
- }, 0)
- }
-
- React.useEffect(() => {
- app.location = {
- last: window.location,
- push: setLocation,
- back: backLocation,
- }
-
- window.addEventListener("popstate", onHistoryChange)
-
- return () => {
- window.removeEventListener("popstate", onHistoryChange)
- }
- }, [])
-
- return props.children
-}
-
-export const InternalRouter = (props) => {
- return
-
- {
- props.children
- }
-
-
-}
-
-export const PageRender = React.memo((props) => {
- let routes = generateRoutes()
-
- return
- {
- routes.map((route, index) => {
- const declaration = findRouteDeclaration(route.path)
-
- return
- })
- }
-
-
-})
\ No newline at end of file
diff --git a/packages/app/src/settings/player/index.jsx b/packages/app/src/settings/player/index.jsx
index f6bdb4fe..c52d7e52 100755
--- a/packages/app/src/settings/player/index.jsx
+++ b/packages/app/src/settings/player/index.jsx
@@ -271,5 +271,15 @@ export default {
},
storaged: false,
},
+ {
+ id: "lyrics:prefer_translation",
+ title: "Translate Lyrics",
+ icon: "MdTranslate",
+ group: "general",
+ description:
+ "Prefer translation over original lyrics, if available.",
+ component: "Switch",
+ storaged: true,
+ },
],
}
diff --git a/packages/app/src/settings/profile/index.jsx b/packages/app/src/settings/profile/index.jsx
index 5cb5ee39..ac24dd25 100755
--- a/packages/app/src/settings/profile/index.jsx
+++ b/packages/app/src/settings/profile/index.jsx
@@ -3,196 +3,194 @@ import UserModel from "@models/user"
import UploadButton from "@components/UploadButton"
export default {
- id: "profile",
- icon: "FiUser",
- label: "Profile",
- group: "basic",
- ctxData: async () => {
- const userData = await UserModel.data()
+ id: "profile",
+ icon: "FiUser",
+ label: "Profile",
+ group: "basic",
+ ctxData: async () => {
+ const userData = await UserModel.data()
- return {
- userData
- }
- },
- settings: [
- {
- id: "username",
- group: "account.basicInfo",
- component: "Button",
- icon: "FiAtSign",
- title: "Username",
- description: "Your username is the name you use to log in to your account.",
- props: {
- disabled: true,
- children: "Change username",
- },
- },
- {
- id: "public_name",
- group: "account.basicInfo",
- component: "Input",
- icon: "FiEdit3",
- title: "Name",
- description: "Change your public name",
- props: {
- maxLength: 120,
- showCount: true,
- allowClear: true,
- placeholder: "Enter your name. e.g. John Doe",
- },
- defaultValue: (ctx) => {
- return ctx.userData.public_name
- },
- onUpdate: async (value) => {
- const result = await UserModel.updateData({
- public_name: value
- })
+ return {
+ userData,
+ }
+ },
+ settings: [
+ {
+ id: "username",
+ group: "account.basicInfo",
+ component: "Button",
+ icon: "FiAtSign",
+ title: "Username",
+ description:
+ "Your username is the name you use to log in to your account.",
+ props: {
+ disabled: true,
+ children: "Change username",
+ },
+ },
+ {
+ id: "public_name",
+ group: "account.basicInfo",
+ component: "Input",
+ icon: "FiEdit3",
+ title: "Name",
+ description: "Change your public name",
+ props: {
+ maxLength: 120,
+ showCount: true,
+ allowClear: true,
+ placeholder: "Enter your name. e.g. John Doe",
+ },
+ defaultValue: (ctx) => {
+ return ctx.userData.public_name
+ },
+ onUpdate: async (value) => {
+ const result = await UserModel.updateData({
+ public_name: value,
+ })
- if (result) {
- app.message.success("Public name updated")
- return value
- }
- },
- extraActions: [
- {
- "id": "unset",
- "icon": "Delete",
- "title": "Unset",
- "onClick": async () => {
- await UserModel.unsetFullName()
- }
- }
- ],
- debounced: true,
- storaged: false,
- },
- {
- id: "email",
- group: "account.basicInfo",
- component: "Input",
- icon: "FiMail",
- title: "Email",
- description: "Change your email address",
- props: {
- placeholder: "Enter your email address",
- allowClear: true,
- showCount: true,
- maxLength: 320,
- },
- defaultValue: (ctx) => {
- return ctx.userData.email
- },
- onUpdate: async (value) => {
- const result = await UserModel.updateData({
- email: value
- })
+ if (result) {
+ app.message.success("Public name updated")
+ return value
+ }
+ },
+ extraActions: [
+ {
+ id: "unset",
+ icon: "Delete",
+ title: "Unset",
+ onClick: async () => {
+ await UserModel.unsetPublicName()
+ },
+ },
+ ],
+ debounced: true,
+ storaged: false,
+ },
+ {
+ id: "email",
+ group: "account.basicInfo",
+ component: "Input",
+ icon: "FiMail",
+ title: "Email",
+ description: "Change your email address",
+ props: {
+ placeholder: "Enter your email address",
+ allowClear: true,
+ showCount: true,
+ maxLength: 320,
+ },
+ defaultValue: (ctx) => {
+ return ctx.userData.email
+ },
+ onUpdate: async (value) => {
+ const result = await UserModel.updateData({
+ email: value,
+ })
- if (result) {
- return value
- }
- },
- debounced: true,
- },
- {
- id: "avatar",
- group: "account.profile",
- icon: "FiImage",
- title: "Avatar",
- description: "Change your avatar (Upload an image or use an URL)",
- component: loadable(() => import("../components/urlInput")),
- extraActions: [
- UploadButton
- ],
- defaultValue: (ctx) => {
- return ctx.userData.avatar
- },
- onUpdate: async (value) => {
- const result = await UserModel.updateData({
- avatar: value
- })
+ if (result) {
+ return value
+ }
+ },
+ debounced: true,
+ },
+ {
+ id: "avatar",
+ group: "account.profile",
+ icon: "FiImage",
+ title: "Avatar",
+ description: "Change your avatar (Upload an image or use an URL)",
+ component: loadable(() => import("../components/urlInput")),
+ extraActions: [UploadButton],
+ defaultValue: (ctx) => {
+ return ctx.userData.avatar
+ },
+ onUpdate: async (value) => {
+ const result = await UserModel.updateData({
+ avatar: value,
+ })
- if (result) {
- app.message.success("Avatar updated")
- return value
- }
- },
- },
- {
- id: "cover",
- group: "account.profile",
- icon: "FiImage",
- title: "Cover",
- description: "Change your profile cover (Upload an image or use an URL)",
- component: loadable(() => import("../components/urlInput")),
- extraActions: [
- UploadButton
- ],
- defaultValue: (ctx) => {
- return ctx.userData.cover
- },
- onUpdate: async (value) => {
- const result = await UserModel.updateData({
- cover: value
- })
+ if (result) {
+ app.message.success("Avatar updated")
+ return value
+ }
+ },
+ },
+ {
+ id: "cover",
+ group: "account.profile",
+ icon: "FiImage",
+ title: "Cover",
+ description:
+ "Change your profile cover (Upload an image or use an URL)",
+ component: loadable(() => import("../components/urlInput")),
+ extraActions: [UploadButton],
+ defaultValue: (ctx) => {
+ return ctx.userData.cover
+ },
+ onUpdate: async (value) => {
+ const result = await UserModel.updateData({
+ cover: value,
+ })
- if (result) {
- app.message.success("Cover updated")
- return value
- }
- },
- },
- {
- id: "description",
- group: "account.profile",
- component: "TextArea",
- icon: "FiEdit3",
- title: "Description",
- description: "Change your description for your profile",
- props: {
- placeholder: "Enter here a description for your profile",
- maxLength: 320,
- showCount: true,
- allowClear: true
- },
- defaultValue: (ctx) => {
- return ctx.userData.description
- },
- onUpdate: async (value) => {
- const result = await UserModel.updateData({
- description: value
- })
+ if (result) {
+ app.message.success("Cover updated")
+ return value
+ }
+ },
+ },
+ {
+ id: "description",
+ group: "account.profile",
+ component: "TextArea",
+ icon: "FiEdit3",
+ title: "Description",
+ description: "Change your description for your profile",
+ props: {
+ placeholder: "Enter here a description for your profile",
+ maxLength: 320,
+ showCount: true,
+ allowClear: true,
+ },
+ defaultValue: (ctx) => {
+ return ctx.userData.description
+ },
+ onUpdate: async (value) => {
+ const result = await UserModel.updateData({
+ description: value,
+ })
- if (result) {
- return value
- }
- },
- debounced: true,
- },
- {
- id: "Links",
- group: "account.profile",
- component: loadable(() => import("../components/profileLinks")),
- icon: "MdLink",
- title: "Links",
- description: "Add links to your profile",
- onUpdate: async (value) => {
- // filter invalid links
- value = value.filter((link) => {
- return link.key && link.value
- })
+ if (result) {
+ return value
+ }
+ },
+ debounced: true,
+ },
+ {
+ id: "Links",
+ group: "account.profile",
+ component: loadable(() => import("../components/profileLinks")),
+ icon: "MdLink",
+ title: "Links",
+ description: "Add links to your profile",
+ onUpdate: async (value) => {
+ // filter invalid links
+ value = value.filter((link) => {
+ return link.key && link.value
+ })
- const result = await UserModel.updateData({
- links: value
- })
+ const result = await UserModel.updateData({
+ links: value,
+ })
- if (result) {
- return result.links
- }
- },
- defaultValue: (ctx) => {
- return ctx.userData.links ?? []
- },
- debounced: true,
- }
- ]
-}
\ No newline at end of file
+ if (result) {
+ return result.links
+ }
+ },
+ defaultValue: (ctx) => {
+ return ctx.userData.links ?? []
+ },
+ debounced: true,
+ },
+ ],
+}
diff --git a/packages/app/src/statics/renders.jsx b/packages/app/src/statics/renders.jsx
index 91390d8b..8910f4a6 100644
--- a/packages/app/src/statics/renders.jsx
+++ b/packages/app/src/statics/renders.jsx
@@ -1,14 +1,8 @@
-import { NotFound, RenderError, Crash } from "@components"
-
+import { NotFound, Crash, Skeleton } from "@components"
+import ErrorCatcher from "@components/ErrorCatcher"
export default {
- PageLoad: () => {
- return
- },
- NotFound: (props) => {
- return
- },
- RenderError: (props) => {
- return
- },
+ Loading: Skeleton,
+ NotFound: NotFound,
+ RenderError: ErrorCatcher,
Crash: Crash.CrashWrapper,
}
diff --git a/packages/server/classes/SegmentedAudioMPDJob/index.js b/packages/server/classes/SegmentedAudioMPDJob/index.js
index bd295747..51b4fee0 100644
--- a/packages/server/classes/SegmentedAudioMPDJob/index.js
+++ b/packages/server/classes/SegmentedAudioMPDJob/index.js
@@ -5,6 +5,7 @@ import { FFMPEGLib, Utils } from "../FFMPEGLib"
const codecOverrides = {
wav: "flac",
+ mp3: "libopus",
}
export default class SegmentedAudioMPDJob extends FFMPEGLib {
diff --git a/packages/server/classes/Transformation/handlers/a-dash.js b/packages/server/classes/Transformation/handlers/a-dash.js
index bd5074d0..2823f85f 100644
--- a/packages/server/classes/Transformation/handlers/a-dash.js
+++ b/packages/server/classes/Transformation/handlers/a-dash.js
@@ -2,7 +2,7 @@ import path from "node:path"
import SegmentedAudioMPDJob from "@shared-classes/SegmentedAudioMPDJob"
export default async ({ filePath, workPath, onProgress }) => {
- return new Promise((resolve) => {
+ return new Promise((resolve, reject) => {
const outputDir = path.resolve(workPath, "a-dash")
const job = new SegmentedAudioMPDJob({
@@ -28,6 +28,10 @@ export default async ({ filePath, workPath, onProgress }) => {
}
})
+ job.on("error", (error) => {
+ reject(error)
+ })
+
job.run()
})
}
diff --git a/packages/server/classes/Transformation/handlers/mq-hls.js b/packages/server/classes/Transformation/handlers/mq-hls.js
index e10ea1d0..93bf924d 100644
--- a/packages/server/classes/Transformation/handlers/mq-hls.js
+++ b/packages/server/classes/Transformation/handlers/mq-hls.js
@@ -2,7 +2,7 @@ import path from "node:path"
import MultiqualityHLSJob from "@shared-classes/MultiqualityHLSJob"
export default async ({ filePath, workPath, onProgress }) => {
- return new Promise(async (resolve, reject) => {
+ return new Promise((resolve, reject) => {
const outputDir = path.resolve(workPath, "mqhls")
const job = new MultiqualityHLSJob({
@@ -40,6 +40,10 @@ export default async ({ filePath, workPath, onProgress }) => {
}
})
+ job.on("error", (error) => {
+ reject(error)
+ })
+
job.run()
})
}
diff --git a/packages/server/classes/Upload/index.ts b/packages/server/classes/Upload/index.ts
index b2d42eab..673a57ff 100644
--- a/packages/server/classes/Upload/index.ts
+++ b/packages/server/classes/Upload/index.ts
@@ -132,6 +132,7 @@ export default class Upload {
metadata: metadata,
targetFilename: isDirectory ? path.basename(targetPath) : null,
provider: s3Provider,
+ onProgress: onProgress,
})
return result
diff --git a/packages/server/classes/Upload/putObject.js b/packages/server/classes/Upload/putObject.js
index 91c81f74..aa0cf7f2 100644
--- a/packages/server/classes/Upload/putObject.js
+++ b/packages/server/classes/Upload/putObject.js
@@ -8,6 +8,7 @@ export default async function putObject({
metadata = {},
targetFilename,
onFinish,
+ onProgress,
provider = "standard",
}) {
const providerClass = global.storages[provider]
@@ -22,6 +23,18 @@ export default async function putObject({
if (isDirectory) {
let files = await fs.promises.readdir(filePath)
+ let count = 0
+
+ const handleProgress = () => {
+ if (typeof onProgress === "function") {
+ count = count + 1
+
+ onProgress({
+ percent: Math.round((count / files.length) * 100),
+ state: "uploading_s3",
+ })
+ }
+ }
files = files.map((file) => {
const newPath = path.join(filePath, file)
@@ -29,6 +42,8 @@ export default async function putObject({
return {
filePath: newPath,
uploadPath: path.join(uploadPath, file),
+ provider: provider,
+ onFinish: handleProgress,
}
})
diff --git a/packages/server/db_models/playlist/index.js b/packages/server/db_models/playlist/index.js
index a771ddf4..8ab1889b 100755
--- a/packages/server/db_models/playlist/index.js
+++ b/packages/server/db_models/playlist/index.js
@@ -13,17 +13,12 @@ export default {
description: {
type: String,
},
- list: {
+ items: {
type: Object,
default: [],
required: true,
},
- cover: {
- type: String,
- default:
- "https://storage.ragestudio.net/comty-static-assets/default_song.png",
- },
- thumbnail: {
+ image: {
type: String,
default:
"https://storage.ragestudio.net/comty-static-assets/default_song.png",
@@ -32,9 +27,6 @@ export default {
type: Date,
required: true,
},
- publisher: {
- type: Object,
- },
public: {
type: Boolean,
default: true,
diff --git a/packages/server/db_models/track/index.js b/packages/server/db_models/track/index.js
index 0c7789a5..52c48bf9 100755
--- a/packages/server/db_models/track/index.js
+++ b/packages/server/db_models/track/index.js
@@ -29,7 +29,6 @@ export default {
},
created_at: {
type: Date,
- required: true,
},
cover: {
type: String,
diff --git a/packages/server/db_models/trackLyrics/index.js b/packages/server/db_models/trackLyrics/index.js
index 4bc97a5c..3bef8935 100644
--- a/packages/server/db_models/trackLyrics/index.js
+++ b/packages/server/db_models/trackLyrics/index.js
@@ -1,20 +1,20 @@
export default {
- name: "TrackLyric",
- collection: "tracks_lyrics",
- schema: {
- track_id: {
- type: String,
- required: true
- },
- lrc: {
- type: Object,
- default: {}
- },
- video_source: {
- type: String,
- },
- sync_audio_at: {
- type: String,
- }
- }
-}
\ No newline at end of file
+ name: "TrackLyric",
+ collection: "tracks_lyrics",
+ schema: {
+ track_id: {
+ type: String,
+ required: true,
+ },
+ lrc: {
+ type: Object,
+ default: {},
+ },
+ video_source: {
+ type: String,
+ },
+ video_starts_at: {
+ type: String,
+ },
+ },
+}
diff --git a/packages/server/lb-plugins/openapi/index.js b/packages/server/lb-plugins/openapi/index.js
new file mode 100644
index 00000000..465074ba
--- /dev/null
+++ b/packages/server/lb-plugins/openapi/index.js
@@ -0,0 +1,277 @@
+import Endpoint from "../../../../linebridge/server/src/classes/Endpoint"
+import fs from "node:fs"
+
+// OpenAPI Extension for Linebridge
+// Registers and generates OpenAPI specification from registered routes/specifications
+
+const typeMap = {
+ string: { type: "string" },
+ number: { type: "number" },
+ boolean: { type: "boolean" },
+ date: { type: "string", format: "date-time" },
+ String: { type: "string" },
+ Number: { type: "number" },
+ Boolean: { type: "boolean" },
+ Date: { type: "string", format: "date-time" },
+}
+
+function extractProperties(properties) {
+ return Object.entries(properties).reduce((acc, [key, value]) => {
+ let type = value.type?.name ? value.type.name.toLowerCase() : value.type
+ if (type === "array" || type === Array) {
+ acc[key] = { type: "array", items: { type: "string" } }
+ } else {
+ acc[key] = typeMap[type] || { type }
+ }
+ return acc
+ }, {})
+}
+
+function extractRequiredKeys(properties) {
+ return Object.keys(properties).filter((key) => properties[key].required)
+}
+
+function addSchemaRef(refs, ref) {
+ // Basic validation and check if already added
+ // Removed !ref.constructor check as it prevents Mongoose models (which are constructors)
+ if (!ref || !ref.name || refs.has(ref.name)) {
+ return // Return nothing as original didn't use return value
+ }
+
+ let schemaName
+ let schemaType
+ let rawProperties
+
+ // Check if it's a Mongoose model (simple check for .schema property and .name)
+ // Mongoose models typically have a 'schema' property and a 'name' property
+ if (ref.schema && ref.name) {
+ schemaName = ref.name
+ schemaType = "object" // Mongoose models represent objects
+ rawProperties = ref.schema.obj // Get the raw schema object structure
+ } else {
+ // Assume it's a standard specification ref object
+ // It must have type and properties
+ if (!ref.type || !ref.properties) {
+ console.warn(
+ `[OpenAPIPlugin] Invalid schema ref definition for ${ref.name}. Missing 'type' or 'properties'.`,
+ )
+ return
+ }
+
+ schemaName = ref.name
+ schemaType = ref.type
+ rawProperties = ref.properties
+ }
+
+ // Extract required keys and process properties for OpenAPI format
+ const required = extractRequiredKeys(rawProperties)
+ const properties = extractProperties(rawProperties)
+
+ // Store the processed schema definition in the refs map
+ refs.set(schemaName, { type: schemaType, required, properties })
+}
+
+function buildParameters(spec) {
+ const params = []
+
+ if (spec.parameters) {
+ for (const [key, value] of Object.entries(spec.parameters)) {
+ params.push({
+ name: key,
+ in: "path",
+ required: true,
+ description: value.description,
+ schema: { type: value.type },
+ })
+ }
+ }
+
+ if (spec.query) {
+ for (const [key, value] of Object.entries(spec.query)) {
+ params.push({
+ name: key,
+ in: "query",
+ required: value.required,
+ description: value.description,
+ schema: { type: value.type },
+ })
+ }
+ }
+
+ return params
+}
+
+function buildRequestBody(spec, refs) {
+ if (!spec.body) {
+ return undefined
+ }
+
+ let refName = null
+
+ if (typeof spec.body.ref === "object") {
+ addSchemaRef(refs, spec.body.ref)
+ refName = spec.body.ref.name
+ } else {
+ refName = spec.body.ref.toString()
+ }
+
+ return {
+ description: spec.body.description,
+ content: {
+ "application/json": {
+ schema: {
+ type: spec.body.type,
+ $ref: `#/components/schemas/${refName}`,
+ },
+ },
+ },
+ }
+}
+
+function buildResponses(spec, refs) {
+ const responses = {
+ default: { description: "Default response" },
+ }
+
+ if (spec.returns) {
+ let refName = null
+
+ if (typeof spec.returns.ref === "object") {
+ addSchemaRef(refs, spec.returns.ref)
+ refName = spec.returns.ref.name
+ } else {
+ refName = spec.returns.ref.toString()
+ }
+
+ responses[200] = {
+ description: spec.returns.description,
+ content: {
+ "application/json": {
+ schema: {
+ type: spec.returns.type,
+ $ref: `#/components/schemas/${refName}`,
+ },
+ },
+ },
+ }
+ }
+
+ if (spec.errors) {
+ for (const [code, err] of Object.entries(spec.errors)) {
+ responses[code] = { description: err.description }
+ }
+ }
+
+ return responses
+}
+
+async function generateOpenAPIJson(specifications) {
+ const paths = {}
+ const components = { schemas: {} }
+ const refs = new Map()
+
+ for (const spec of specifications) {
+ const path = spec.path.replace(/:([^/]+)/g, `{$1}`)
+
+ if (!paths[path]) {
+ paths[path] = {}
+ }
+
+ const parameters = buildParameters(spec)
+ const requestBody = buildRequestBody(spec, refs)
+ const responses = buildResponses(spec, refs)
+
+ paths[path][spec.method] = {
+ description: spec.description,
+ parameters,
+ ...(requestBody && { requestBody }),
+ responses,
+ }
+ }
+
+ for (const [name, ref] of refs) {
+ components.schemas[name] = {
+ type: ref.type,
+ properties: ref.properties,
+ ...(ref.required && ref.required.length > 0
+ ? { required: ref.required }
+ : {}),
+ }
+ }
+
+ return {
+ openapi: "3.0.0",
+ info: {
+ title: "api",
+ version: "1.0.0",
+ },
+ paths,
+ components,
+ }
+}
+
+export default class OpenAPIPlugin {
+ constructor(server) {
+ this.server = server
+ }
+
+ specifications = new Set()
+
+ async initialize() {
+ for (const endpoint of this.server.engine.registers) {
+ if (!endpoint.filePath) {
+ continue
+ }
+
+ // Searc for spec file with same name as route file but with '.spec.js' extension
+ const specFilePath = endpoint.filePath.replace(/\.js$/, ".spec.js")
+
+ // Check if the spec file exists
+ if (fs.existsSync(specFilePath)) {
+ try {
+ // Dynamically import the spec file
+ const specModule = await import(specFilePath)
+
+ // Get the specification export (assuming default or named 'specification')
+ const specification =
+ specModule.specification || specModule.default
+
+ if (specification) {
+ // Determine the actual spec details based on method
+ const methodSpec =
+ specification[endpoint.method.toLowerCase()] ??
+ specification // Use lowercase method for key lookup
+
+ // Construct the final spec object for the set
+ const spec = {
+ path: endpoint.route, // Assuming endpoint has route property
+ method: endpoint.method, // Assuming endpoint has method property
+ ...methodSpec, // Include all details from the spec file (parameters, body, returns, errors, etc.)
+ }
+
+ this.specifications.add(spec)
+ } else {
+ console.warn(
+ `[OpenAPIPlugin] Spec file found for ${endpoint.filePath} but no 'specification' or default export found.`,
+ )
+ }
+ } catch (error) {
+ console.error(
+ `[OpenAPIPlugin] Failed to load spec file ${specFilePath}:`,
+ error,
+ )
+ }
+ }
+ }
+
+ const getOpenApiEndpoint = new Endpoint(async () => {
+ return await generateOpenAPIJson(this.specifications)
+ })
+
+ this.server.register.http({
+ method: "GET",
+ route: "/openapi",
+ fn: getOpenApiEndpoint.handler,
+ })
+ }
+}
diff --git a/packages/server/package.json b/packages/server/package.json
index e55264bb..2cc9dd04 100755
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,52 +1,52 @@
{
- "name": "@comty/server",
- "version": "1.43.0@alpha",
- "license": "ComtyLicense",
- "private": true,
- "workspaces": [
- "services/*"
- ],
- "scripts": {
- "start:prod": "cross-env NODE_ENV=production hermes-node ./start.js",
- "dev": "cross-env NODE_ENV=development hermes-node ./start.js",
- "setup": "./boot scripts/setup/index.js"
- },
- "dependencies": {
- "@grpc/grpc-js": "^1.13.2",
- "@grpc/proto-loader": "^0.7.13",
- "@infisical/sdk": "^2.1.8",
- "@opentelemetry/api": "^1.9.0",
- "@opentelemetry/auto-instrumentations-node": "^0.56.1",
- "@ragestudio/hermes": "^1.0.0",
- "@sentry/node": "^9.4.0",
- "axios": "^1.7.4",
- "bcrypt": "^5.1.1",
- "bullmq": "^5.41.5",
- "chalk": "^5.4.1",
- "chokidar": "^4.0.3",
- "cross-env": "^7.0.3",
- "http-proxy": "^1.18.1",
- "jsonwebtoken": "^9.0.2",
- "linebridge": "^0.25.2",
- "minimatch": "^10.0.1",
- "minio": "^8.0.1",
- "module-alias": "^2.2.3",
- "mongoose": "^8.5.3",
- "pino": "^9.6.0",
- "pino-opentelemetry-transport": "^1.0.1",
- "pino-pretty": "^13.0.0",
- "signal-exit": "^4.1.0",
- "spinnies": "^0.5.1",
- "tree-kill": "^1.2.2"
- },
- "devDependencies": {
- "@eslint/js": "^9.26.0",
- "chai": "^5.1.0",
- "eslint": "^9.26.0",
- "globals": "^16.1.0",
- "mocha": "^10.3.0"
- },
- "resolutions": {
- "string-width": "4.2.3"
- }
+ "name": "@comty/server",
+ "version": "1.44.0@alpha",
+ "license": "ComtyLicense",
+ "private": true,
+ "workspaces": [
+ "services/*"
+ ],
+ "scripts": {
+ "start:prod": "cross-env NODE_ENV=production hermes-node ./start.js",
+ "dev": "cross-env NODE_ENV=development hermes-node ./start.js",
+ "setup": "./boot scripts/setup/index.js"
+ },
+ "dependencies": {
+ "@grpc/grpc-js": "^1.13.2",
+ "@grpc/proto-loader": "^0.7.13",
+ "@infisical/sdk": "^2.1.8",
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/auto-instrumentations-node": "^0.56.1",
+ "@ragestudio/hermes": "^1.0.0",
+ "@sentry/node": "^9.4.0",
+ "axios": "^1.7.4",
+ "bcrypt": "^5.1.1",
+ "bullmq": "^5.41.5",
+ "chalk": "^5.4.1",
+ "chokidar": "^4.0.3",
+ "cross-env": "^7.0.3",
+ "http-proxy": "^1.18.1",
+ "jsonwebtoken": "^9.0.2",
+ "linebridge": "^0.25.2",
+ "minimatch": "^10.0.1",
+ "minio": "^8.0.1",
+ "module-alias": "^2.2.3",
+ "mongoose": "^8.5.3",
+ "pino": "^9.6.0",
+ "pino-opentelemetry-transport": "^1.0.1",
+ "pino-pretty": "^13.0.0",
+ "signal-exit": "^4.1.0",
+ "spinnies": "^0.5.1",
+ "tree-kill": "^1.2.2"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.26.0",
+ "chai": "^5.1.0",
+ "eslint": "^9.26.0",
+ "globals": "^16.1.0",
+ "mocha": "^10.3.0"
+ },
+ "resolutions": {
+ "string-width": "4.2.3"
+ }
}
diff --git a/packages/server/scripts/installLatestFfmpeg.sh b/packages/server/scripts/installLatestFfmpeg.sh
index 81f5abc7..c819bb55 100755
--- a/packages/server/scripts/installLatestFfmpeg.sh
+++ b/packages/server/scripts/installLatestFfmpeg.sh
@@ -19,8 +19,6 @@ detect_arch() {
echo "amd64"
elif [[ "$ARCH" == "aarch64" ]] || [[ "$ARCH" == "arm64" ]]; then
echo "arm64"
- elif [[ "$ARCH" == "armv7l" ]]; then
- echo "armhf"
else
echo "unsupported"
fi
@@ -52,19 +50,17 @@ download_ffmpeg() {
echo -e "${YELLOW}Downloading the latest stable version of FFmpeg...${NC}"
# Base URL for downloads from John van Sickle's FFmpeg builds
- BASE_URL="https://johnvansickle.com/ffmpeg/releases"
+ BASE_URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest"
# Map architecture to the expected format in the URL
if [[ "$ARCH" == "amd64" ]]; then
- URL_ARCH="amd64"
+ URL_ARCH="linux64"
elif [[ "$ARCH" == "arm64" ]]; then
- URL_ARCH="arm64"
- elif [[ "$ARCH" == "armhf" ]]; then
- URL_ARCH="armhf"
+ URL_ARCH="linuxarm64"
fi
# Create the download URL for the latest release
- FFMPEG_URL="$BASE_URL/ffmpeg-release-$URL_ARCH-static.tar.xz"
+ FFMPEG_URL="$BASE_URL-$URL_ARCH-gpl-7.1.tar.xz"
if [[ -z "$FFMPEG_URL" ]]; then
echo -e "${RED}Could not determine the download URL for your system.${NC}"
@@ -118,9 +114,11 @@ install_binaries() {
exit 1
fi
+ echo -e "${GREEN}Extracted directory: $EXTRACTED_DIR${NC}"
+
# Find the binaries
- FFMPEG_BIN="$EXTRACTED_DIR/ffmpeg"
- FFPROBE_BIN="$EXTRACTED_DIR/ffprobe"
+ FFMPEG_BIN="$EXTRACTED_DIR/bin/ffmpeg"
+ FFPROBE_BIN="$EXTRACTED_DIR/bin/ffprobe"
# Verify binaries exist
if [[ ! -f "$FFMPEG_BIN" ]] || [[ ! -f "$FFPROBE_BIN" ]]; then
diff --git a/packages/server/scripts/installNginxStatic.sh b/packages/server/scripts/installNginxStatic.sh
index 9b694461..225c5bb4 100755
--- a/packages/server/scripts/installNginxStatic.sh
+++ b/packages/server/scripts/installNginxStatic.sh
@@ -8,20 +8,12 @@ for arg in "$@"; do
done
NGINX_VERSION="1.26.3"
-ARCH="$(dpkg --print-architecture)"
+ARCH="$(uname -m)"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUTPUTBIN="${SCRIPT_DIR}/../nginx-bin"
-if [ "$ARCH" = "arm64" ]; then
- NGINX_ARCH="aarch64";
-elif [ "$ARCH" = "amd64" ]; then
- NGINX_ARCH="x86_64";
-else
- echo "Unsupported architecture: $ARCH";
- exit 1;
-fi
-NGINX_BINARY_URL="https://jirutka.github.io/nginx-binaries/nginx-${NGINX_VERSION}-${NGINX_ARCH}-linux"
+NGINX_BINARY_URL="https://jirutka.github.io/nginx-binaries/nginx-${NGINX_VERSION}-${ARCH}-linux"
if [ -f "${OUTPUTBIN}" ]; then
if [ "$FORCE_OVERWRITE" = true ]; then
diff --git a/packages/server/scripts/migrations/removeDuplicateTracks.js b/packages/server/scripts/migrations/removeDuplicateTracks.js
index f3687406..845e884e 100644
--- a/packages/server/scripts/migrations/removeDuplicateTracks.js
+++ b/packages/server/scripts/migrations/removeDuplicateTracks.js
@@ -8,7 +8,25 @@ async function main() {
const db = new DbManager()
await db.initialize()
- const tracks = await Track.find()
+ // try to parse some queries from argv
+ let fromTime = process.argv.find((arg) => arg.startsWith("--fromTime="))
+
+ if (fromTime) {
+ fromTime = fromTime.replace("--fromTime=", "")
+ console.log(`Searching from time: ${fromTime}`, new Date(fromTime))
+ }
+
+ let query = {}
+
+ if (fromTime) {
+ query = {
+ created_at: {
+ $gte: new Date(fromTime),
+ },
+ }
+ }
+
+ const tracks = await Track.find(query)
console.log(`Total tracks in database: ${tracks.length}`)
diff --git a/packages/server/scripts/setup/tasks/01.default-limits.js b/packages/server/scripts/setup/tasks/01.default-limits.js
new file mode 100644
index 00000000..4677c8f3
--- /dev/null
+++ b/packages/server/scripts/setup/tasks/01.default-limits.js
@@ -0,0 +1,35 @@
+import { Config } from "@db_models"
+
+const defaultLimits = {
+ maxFileSizeInMB: 100,
+ maxChunkSizeInMB: 10,
+ maxPostCharacters: 512,
+ maxAccountsPerIp: 3,
+ maxTranscodeTasks: 10,
+}
+
+export default {
+ description: "Set default server limits",
+ fn: async () => {
+ // check if limits already exists
+ const limits = await Config.findOne({ key: "limits" }).catch(() => {
+ return false
+ })
+
+ if (limits) {
+ console.log("Limits already exists, skipping...")
+ return true
+ }
+
+ // create limits
+ await Config.create({
+ key: "limits",
+ value: defaultLimits,
+ })
+
+ console.log("Default limits created successfully! :", {
+ defaultLimits: defaultLimits,
+ })
+ },
+ crashOnFail: true,
+}
diff --git a/packages/server/services/files/queues/fileProcess/index.js b/packages/server/services/files/queues/fileProcess/index.js
index 2baf0e19..25e2a857 100644
--- a/packages/server/services/files/queues/fileProcess/index.js
+++ b/packages/server/services/files/queues/fileProcess/index.js
@@ -6,7 +6,7 @@ export default {
id: "file-process",
maxJobs: 2,
process: async (job) => {
- console.log("[JOB][file-process] starting... >", job.data)
+ console.log("[JOB][file-process] running... >", job.data)
try {
const result = await Upload.fileHandle({
@@ -22,7 +22,11 @@ export default {
.rm(job.workPath, { recursive: true, force: true })
.catch(() => null)
- throw error
+ console.error(error)
+
+ throw new Error(
+ `Failed to process file > ${error.message ?? error}`,
+ )
}
},
}
diff --git a/packages/server/services/files/routes/upload/chunk/post.js b/packages/server/services/files/routes/upload/chunk/post.js
index 27615fc9..877ad20b 100644
--- a/packages/server/services/files/routes/upload/chunk/post.js
+++ b/packages/server/services/files/routes/upload/chunk/post.js
@@ -92,7 +92,7 @@ export default {
// if has transformations, use background job
if (
(transformations && transformations.length > 0) ||
- config.useCompression
+ (config.useCompression && !req.headers["prefer-no-job"])
) {
const job = await global.queues.createJob(
"file-process",
diff --git a/packages/server/services/music/classes/library/index.js b/packages/server/services/music/classes/library/index.js
index 187322f1..d8a1896f 100644
--- a/packages/server/services/music/classes/library/index.js
+++ b/packages/server/services/music/classes/library/index.js
@@ -1,5 +1,4 @@
import { Track, Playlist, MusicRelease } from "@db_models"
-import { MusicLibraryItem } from "@db_models"
import toggleFavorite from "./methods/toggleFavorite"
import getUserLibrary from "./methods/getUserLibrary"
diff --git a/packages/server/services/music/classes/library/methods/getUserLibrary.js b/packages/server/services/music/classes/library/methods/getUserLibrary.js
index d2121755..c1c33b42 100644
--- a/packages/server/services/music/classes/library/methods/getUserLibrary.js
+++ b/packages/server/services/music/classes/library/methods/getUserLibrary.js
@@ -121,6 +121,7 @@ async function fetchAllKindsData(userId, limit, offsetStr) {
const actualItems = await Model.find({
_id: { $in: itemIds },
}).lean()
+
const actualItemsMap = new Map(
actualItems.map((item) => [item._id.toString(), item]),
)
diff --git a/packages/server/services/music/classes/playlist/index.js b/packages/server/services/music/classes/playlist/index.js
new file mode 100644
index 00000000..d4978664
--- /dev/null
+++ b/packages/server/services/music/classes/playlist/index.js
@@ -0,0 +1,17 @@
+import getData from "./methods/getData"
+import create from "./methods/create"
+import modify from "./methods/modify"
+import deletePlaylist from "./methods/deletePlaylist"
+
+import appendItem from "./methods/appendItem"
+import removeItem from "./methods/removeItem"
+
+export default class Playlist {
+ static get = getData
+ static create = create
+ static modify = modify
+ static delete = deletePlaylist
+
+ static appendItem = appendItem
+ static removeItem = removeItem
+}
diff --git a/packages/server/services/music/classes/playlist/methods/appendItem.js b/packages/server/services/music/classes/playlist/methods/appendItem.js
new file mode 100644
index 00000000..92d67730
--- /dev/null
+++ b/packages/server/services/music/classes/playlist/methods/appendItem.js
@@ -0,0 +1,17 @@
+import { Playlist } from "@db_models"
+
+export default async (id, item) => {
+ let playlist = await Playlist.findById(id).lean()
+
+ if (!playlist) {
+ throw new OperationError(404, "Playlist not found")
+ }
+
+ if (typeof item === "string" && !Array.isArray(item)) {
+ item = [item]
+ }
+
+ playlist.items = [...playlist.items, ...item]
+
+ return await Playlist.findByIdAndUpdate(id, playlist)
+}
diff --git a/packages/server/services/music/classes/playlist/methods/create.js b/packages/server/services/music/classes/playlist/methods/create.js
new file mode 100644
index 00000000..1a3dcb33
--- /dev/null
+++ b/packages/server/services/music/classes/playlist/methods/create.js
@@ -0,0 +1,7 @@
+import { Playlist } from "@db_models"
+
+export default async (payload) => {
+ let playlist = await Playlist.create(playlist)
+
+ return playlist
+}
diff --git a/packages/server/services/music/classes/playlist/methods/deletePlaylist.js b/packages/server/services/music/classes/playlist/methods/deletePlaylist.js
new file mode 100644
index 00000000..8c87580f
--- /dev/null
+++ b/packages/server/services/music/classes/playlist/methods/deletePlaylist.js
@@ -0,0 +1,11 @@
+import { Playlist } from "@db_models"
+
+export default async (id) => {
+ let playlist = await Playlist.findById(id)
+
+ if (!playlist) {
+ throw new OperationError(404, "Playlist not found")
+ }
+
+ return await Playlist.findByIdAndDelete(id)
+}
diff --git a/packages/server/services/music/classes/playlist/methods/getData.js b/packages/server/services/music/classes/playlist/methods/getData.js
new file mode 100644
index 00000000..407ce410
--- /dev/null
+++ b/packages/server/services/music/classes/playlist/methods/getData.js
@@ -0,0 +1,11 @@
+import { Playlist } from "@db_models"
+
+export default async (id) => {
+ let playlist = await Playlist.findById(id)
+
+ if (!playlist) {
+ throw new OperationError(404, "Playlist not found")
+ }
+
+ return playlist
+}
diff --git a/packages/server/services/music/classes/playlist/methods/modify.js b/packages/server/services/music/classes/playlist/methods/modify.js
new file mode 100644
index 00000000..7e7f430a
--- /dev/null
+++ b/packages/server/services/music/classes/playlist/methods/modify.js
@@ -0,0 +1,16 @@
+import { Playlist } from "@db_models"
+
+export default async (id, update) => {
+ let playlist = await Playlist.findById(id).lean()
+
+ if (!playlist) {
+ throw new OperationError(404, "Playlist not found")
+ }
+
+ playlist = {
+ ...playlist,
+ ...update,
+ }
+
+ return await Playlist.findByIdAndUpdate(id, playlist)
+}
diff --git a/packages/server/services/music/classes/playlist/methods/removeItem.js b/packages/server/services/music/classes/playlist/methods/removeItem.js
new file mode 100644
index 00000000..66e9d9f3
--- /dev/null
+++ b/packages/server/services/music/classes/playlist/methods/removeItem.js
@@ -0,0 +1,17 @@
+import { Playlist } from "@db_models"
+
+export default async (id, item) => {
+ let playlist = await Playlist.findById(id).lean()
+
+ if (!playlist) {
+ throw new OperationError(404, "Playlist not found")
+ }
+
+ if (typeof item === "string" && !Array.isArray(item)) {
+ item = [item]
+ }
+
+ playlist.items = playlist.items.filter((entry) => !item.includes(entry))
+
+ return await Playlist.findByIdAndUpdate(id, playlist)
+}
diff --git a/packages/server/services/music/classes/release/index.js b/packages/server/services/music/classes/release/index.js
index 027337a9..5e5f8a56 100644
--- a/packages/server/services/music/classes/release/index.js
+++ b/packages/server/services/music/classes/release/index.js
@@ -35,6 +35,8 @@ export default class Release {
onlyList: true,
})
+ release.items = tracks
+ release.total_items = totalTracks
release.total_duration = tracks.reduce((acc, track) => {
if (track.metadata?.duration) {
return acc + parseFloat(track.metadata.duration)
@@ -42,8 +44,6 @@ export default class Release {
return acc
}, 0)
- release.total_items = totalTracks
- release.items = tracks
return release
}
diff --git a/packages/server/services/music/classes/track/methods/create.js b/packages/server/services/music/classes/track/methods/create.js
index 1df4f788..ffa32fa0 100644
--- a/packages/server/services/music/classes/track/methods/create.js
+++ b/packages/server/services/music/classes/track/methods/create.js
@@ -71,19 +71,17 @@ export default async (payload = {}) => {
source: payload.source,
metadata: metadata,
public: payload.public ?? true,
+ publisher: {
+ user_id: payload.user_id,
+ },
+ created_at: new Date(),
}
if (Array.isArray(payload.artists)) {
obj.artist = payload.artists.join(", ")
}
- let track = new Track({
- ...obj,
- publisher: {
- user_id: payload.user_id,
- },
- created_at: new Date(),
- })
+ let track = new Track(obj)
await track.save()
diff --git a/packages/server/services/music/classes/track/methods/get.js b/packages/server/services/music/classes/track/methods/get.js
index 26111c50..7d86f558 100644
--- a/packages/server/services/music/classes/track/methods/get.js
+++ b/packages/server/services/music/classes/track/methods/get.js
@@ -51,42 +51,53 @@ export default async (track_id, { user_id = null, onlyList = false } = {}) => {
const isMultiple = Array.isArray(track_id) || track_id.includes(",")
+ let totalItems = 1
+ let data = null
+
if (isMultiple) {
const track_ids = Array.isArray(track_id)
? track_id
: track_id.split(",")
- let tracks = await Track.find({
+ data = await Track.find({
_id: { $in: track_ids },
}).lean()
- tracks = await fullfillData(tracks, {
- user_id,
+ // order tracks by ids
+ data = data.sort((a, b) => {
+ return (
+ track_ids.indexOf(a._id.toString()) -
+ track_ids.indexOf(b._id.toString())
+ )
})
- if (onlyList) {
- return tracks
- }
+ totalItems = await Track.countDocuments({
+ _id: { $in: track_ids },
+ })
+ } else {
+ data = await Track.findOne({
+ _id: track_id,
+ }).lean()
- return {
- total_count: await Track.countDocuments({
- _id: { $in: track_ids },
- }),
- list: tracks,
+ if (!data) {
+ throw new OperationError(404, "Track not found")
}
}
- let track = await Track.findOne({
- _id: track_id,
- }).lean()
-
- if (!track) {
- throw new OperationError(404, "Track not found")
- }
-
- track = await fullfillData(track, {
+ data = await fullfillData(data, {
user_id,
})
- return track[0]
+ if (isMultiple) {
+ if (onlyList) {
+ return data
+ }
+
+ return {
+ total_count: totalItems,
+ list: data,
+ }
+ }
+
+ return data[0]
}
diff --git a/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/get.js b/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/get.js
index 39f35f1a..3b891b73 100644
--- a/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/get.js
+++ b/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/get.js
@@ -1,102 +1,114 @@
import { TrackLyric } from "@db_models"
import axios from "axios"
-function parseTimeToMs(timeStr) {
- const [minutes, seconds, milliseconds] = timeStr.split(":")
-
- return (
- Number(minutes) * 60 * 1000 +
- Number(seconds) * 1000 +
- Number(milliseconds)
- )
+function secondsToMs(number) {
+ return number * 1000
}
-async function remoteLcrToSyncedLyrics(lrcUrl) {
- const { data } = await axios.get(lrcUrl)
+class LRCV1 {
+ static timeStrToMs(timeStr) {
+ const [minutes, seconds, milliseconds] = timeStr.split(":")
- let syncedLyrics = data
+ return (
+ Number(minutes) * 60 * 1000 +
+ Number(seconds) * 1000 +
+ Number(milliseconds)
+ )
+ }
- syncedLyrics = syncedLyrics.split("\n")
+ static timeStrToSeconds(timeStr) {
+ const [minutes, seconds, milliseconds] = timeStr.split(":")
- syncedLyrics = syncedLyrics.map((line) => {
- const syncedLine = {}
+ return (
+ Number(minutes) * 60 + Number(seconds) + Number(milliseconds) / 1000
+ )
+ }
- //syncedLine.time = line.match(/\[.*\]/)[0]
- syncedLine.time = line.split(" ")[0]
- syncedLine.text = line.replace(syncedLine.time, "").trim()
+ static parseString(str) {
+ str = str.split("\n")
- if (syncedLine.text === "") {
- delete syncedLine.text
- syncedLine.break = true
- }
+ str = str.map((str) => {
+ let line = {}
- syncedLine.time = syncedLine.time.replace(/\[|\]/g, "")
- syncedLine.time = syncedLine.time.replace(".", ":")
+ line.time = str.split(" ")[0]
+ line.text = str.replace(line.time, "").trim()
- return syncedLine
- })
+ // detect empty lines as breaks
+ if (line.text === "" || line.text === "") {
+ delete line.text
+ line.break = true
+ }
- syncedLyrics = syncedLyrics.map((syncedLine, index) => {
- const nextLine = syncedLyrics[index + 1]
+ // parse time
+ line.time = line.time.replace(/\[|\]/g, "")
+ line.time = line.time.replace(".", ":")
+ line.time = this.timeStrToSeconds(line.time)
- syncedLine.startTimeMs = parseTimeToMs(syncedLine.time)
- syncedLine.endTimeMs = nextLine
- ? parseTimeToMs(nextLine.time)
- : parseTimeToMs(syncedLyrics[syncedLyrics.length - 1].time)
+ return line
+ })
- return syncedLine
- })
+ return str
+ }
- return syncedLyrics
+ static setTimmings(lyricsArray) {
+ lyricsArray = lyricsArray.map((line, index) => {
+ const nextLine = lyricsArray[index + 1]
+
+ line.start_ms = secondsToMs(line.time)
+ line.end_ms = secondsToMs(nextLine ? nextLine.time : line.time + 1)
+
+ return line
+ })
+
+ return lyricsArray
+ }
}
export default async (req) => {
const { track_id } = req.params
- let { translate_lang = "original" } = req.query
+ let { language = "original", fetchAll = false } = req.query
let result = await TrackLyric.findOne({
track_id,
- })
+ }).lean()
if (!result) {
throw new OperationError(404, "Track lyric not found")
}
- result = result.toObject()
-
- result.translated_lang = translate_lang
+ result.translated_lang = language
result.available_langs = []
- const lrc = result.lrc_v2 ?? result.lrc
+ if (typeof result.lrc === "object") {
+ result.available_langs = Object.keys(result.lrc)
- result.isLyricsV2 = !!result.lrc_v2
-
- if (typeof lrc === "object") {
- result.available_langs = Object.keys(lrc)
-
- if (!lrc[translate_lang]) {
- translate_lang = "original"
+ if (!result.lrc[language]) {
+ language = "original"
}
- if (lrc[translate_lang]) {
- if (result.isLyricsV2 === true) {
- result.synced_lyrics = await axios.get(lrc[translate_lang])
+ if (result.lrc[language]) {
+ if (typeof result.lrc[language] === "string") {
+ let { data } = await axios.get(result.lrc[language])
- result.synced_lyrics = result.synced_lyrics.data
+ result.synced_lyrics = LRCV1.parseString(data)
+ result.synced_lyrics = LRCV1.setTimmings(result.synced_lyrics)
} else {
- result.synced_lyrics = await remoteLcrToSyncedLyrics(
- result.lrc[translate_lang],
- )
+ result.synced_lyrics = result.lrc[language]
+ result.synced_lyrics = LRCV1.setTimmings(result.synced_lyrics)
}
}
}
- if (result.sync_audio_at) {
- result.sync_audio_at_ms = parseTimeToMs(result.sync_audio_at)
+ if (result.video_starts_at || result.sync_audio_at) {
+ result.video_starts_at_ms = LRCV1.timeStrToMs(
+ result.video_starts_at ?? result.sync_audio_at,
+ )
+ }
+
+ if (!fetchAll) {
+ delete result.lrc
}
- result.lrc
- delete result.lrc_v2
delete result.__v
return result
diff --git a/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/put.js b/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/put.js
index 2e917b84..18c913e7 100644
--- a/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/put.js
+++ b/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/put.js
@@ -4,7 +4,7 @@ export default {
useMiddlewares: ["withAuthentication"],
fn: async (req) => {
const { track_id } = req.params
- const { video_source, lrc, sync_audio_at } = req.body
+ const { video_source, lrc, video_starts_at } = req.body
// check if track exists
let track = await Track.findById(track_id).catch(() => null)
@@ -17,12 +17,6 @@ export default {
throw new OperationError(403, "Unauthorized")
}
- console.log(`Setting lyrics for track ${track_id} >`, {
- track_id: track_id,
- video_source: video_source,
- lrc: lrc,
- })
-
// check if trackLyric exists
let trackLyric = await TrackLyric.findOne({
track_id: track_id,
@@ -33,8 +27,8 @@ export default {
trackLyric = new TrackLyric({
track_id: track_id,
video_source: video_source,
+ video_starts_at: video_starts_at,
lrc: lrc,
- sync_audio_at: sync_audio_at,
})
await trackLyric.save()
@@ -49,8 +43,8 @@ export default {
update.lrc = lrc
}
- if (typeof sync_audio_at !== "undefined") {
- update.sync_audio_at = sync_audio_at
+ if (typeof video_starts_at !== "undefined") {
+ update.video_starts_at = video_starts_at
}
trackLyric = await TrackLyric.findOneAndUpdate(
diff --git a/packages/server/services/posts/classes/posts/methods/data.js b/packages/server/services/posts/classes/posts/methods/data.js
index 97c6e590..3515d868 100644
--- a/packages/server/services/posts/classes/posts/methods/data.js
+++ b/packages/server/services/posts/classes/posts/methods/data.js
@@ -19,6 +19,7 @@ export default async (payload = {}) => {
}
let posts = []
+ let total_posts = 0
if (post_id) {
try {
@@ -33,6 +34,8 @@ export default async (payload = {}) => {
.sort(sort)
.limit(limit)
.skip(limit * page)
+
+ total_posts = await Post.countDocuments({ ...query })
}
// fullfill data
@@ -50,5 +53,9 @@ export default async (payload = {}) => {
return posts[0]
}
- return posts
+ return {
+ items: posts,
+ total_items: total_posts,
+ has_more: total_posts > limit * page + 1,
+ }
}
diff --git a/packages/server/services/posts/classes/posts/methods/delete.js b/packages/server/services/posts/classes/posts/methods/delete.js
index 246b166c..fdb6cdb6 100644
--- a/packages/server/services/posts/classes/posts/methods/delete.js
+++ b/packages/server/services/posts/classes/posts/methods/delete.js
@@ -42,11 +42,9 @@ export default async (payload = {}) => {
// broadcast post to all users
if (post.visibility === "public") {
- global.websockets.senders.toTopic(
- "realtime:feed",
- "post:delete",
- post_id,
- )
+ global.websockets.senders.toTopic("realtime:feed", "post:delete", {
+ _id: post_id,
+ })
}
if (post.visibility === "private") {
@@ -55,7 +53,9 @@ export default async (payload = {}) => {
)
for (const userSocket of userSockets) {
- userSocket.emit(`post:delete`, post_id)
+ userSocket.emit(`post:delete`, {
+ _id: post_id,
+ })
}
}
diff --git a/packages/server/services/posts/routes/posts/[post_id]/data/get.spec.js b/packages/server/services/posts/routes/posts/[post_id]/data/get.spec.js
new file mode 100644
index 00000000..001ace7d
--- /dev/null
+++ b/packages/server/services/posts/routes/posts/[post_id]/data/get.spec.js
@@ -0,0 +1,41 @@
+import Post from "@db_models/post"
+
+const StagedPostRef = {
+ name: "StagedPostRef",
+ description: "A reference to a staged post",
+ type: "object",
+ properties: {
+ ...Post.schema.obj,
+ countLikes: {
+ type: "number",
+ description: "The number of likes the post has",
+ },
+ hasReplies: {
+ type: "number",
+ description: "The number of replies the post has",
+ },
+ share_url: {
+ type: "string",
+ description: "The share url of the post",
+ },
+ user: {
+ type: "object",
+ description: "The user who created the post",
+ },
+ },
+}
+
+export default {
+ description: "Get data of a post by its id",
+ parameters: {
+ post_id: {
+ type: "string",
+ description: "The id of the post",
+ },
+ },
+ returns: {
+ type: "object",
+ description: "The requested post data",
+ ref: StagedPostRef,
+ },
+}
diff --git a/packages/server/services/posts/routes/posts/[post_id]/replies/get.spec.js b/packages/server/services/posts/routes/posts/[post_id]/replies/get.spec.js
new file mode 100644
index 00000000..a4559d2c
--- /dev/null
+++ b/packages/server/services/posts/routes/posts/[post_id]/replies/get.spec.js
@@ -0,0 +1,40 @@
+import Post from "@db_models/post"
+
+const StagedPostRef = {
+ name: "StagedPostRef",
+ description: "A reference to a staged post",
+ type: "object",
+ properties: {
+ ...Post.schema.obj,
+ countLikes: {
+ type: "number",
+ description: "The number of likes the post has",
+ },
+ hasReplies: {
+ type: "number",
+ description: "The number of replies the post has",
+ },
+ share_url: {
+ type: "string",
+ description: "The share url of the post",
+ },
+ user: {
+ type: "object",
+ description: "The user who created the post",
+ },
+ },
+}
+
+export default {
+ description: "Get all replies of a post",
+ parameters: {
+ post_id: {
+ type: "string",
+ description: "The id of the post",
+ },
+ },
+ returns: {
+ type: "array",
+ description: "Replies post data",
+ },
+}
diff --git a/packages/server/services/users/classes/users/method/getFollowers.js b/packages/server/services/users/classes/users/method/getFollowers.js
index 2310a154..0cecdad9 100644
--- a/packages/server/services/users/classes/users/method/getFollowers.js
+++ b/packages/server/services/users/classes/users/method/getFollowers.js
@@ -1,35 +1,44 @@
import { User, UserFollow } from "@db_models"
export default async (payload = {}) => {
- const { user_id, data = false, limit = 50, offset = 0 } = payload
+ const { user_id, data = false, limit = 50, page = 0 } = payload
if (!user_id) {
throw new OperationError(400, "Missing user_id")
}
- if (data) {
+ const total_followers = await UserFollow.countDocuments({
+ to: user_id,
+ })
+
+ if (data === true) {
let followers = await UserFollow.find({
to: user_id,
})
.limit(limit)
- .skip(offset)
+ .skip(limit * page)
+ .lean()
+
+ followers = followers.map((follow) => {
+ return follow.user_id
+ })
const followersData = await User.find({
_id: {
- $in: followers.map((follow) => {
- return follow.user_id
- }),
+ $in: followers,
},
})
- return followersData
- } else {
- const count = await UserFollow.countDocuments({
- to: user_id,
- })
+ const nextPage = page + 1
return {
- count,
+ items: followersData,
+ total_items: total_followers,
+ has_more: total_followers > limit * nextPage,
+ }
+ } else {
+ return {
+ count: total_followers,
}
}
}
diff --git a/packages/server/services/users/classes/users/method/update.js b/packages/server/services/users/classes/users/method/update.js
index 17a2da4c..2ebd952a 100644
--- a/packages/server/services/users/classes/users/method/update.js
+++ b/packages/server/services/users/classes/users/method/update.js
@@ -25,5 +25,13 @@ export default async (user_id, update) => {
user = user.toObject()
+ const userSockets = await global.websockets.find.clientsByUserId(
+ user._id.toString(),
+ )
+
+ for (const userSocket of userSockets) {
+ userSocket.emit(`self:user:update`, user)
+ }
+
return user
}
diff --git a/packages/server/services/users/package.json b/packages/server/services/users/package.json
index c6a050d5..0fc4260e 100644
--- a/packages/server/services/users/package.json
+++ b/packages/server/services/users/package.json
@@ -1,3 +1,6 @@
{
- "name": "users"
+ "name": "users",
+ "dependencies": {
+ "linebridge": "^1.0.0-alpha.4"
+ }
}
diff --git a/packages/server/services/users/routes/users/[user_id]/badges/get.js b/packages/server/services/users/routes/users/[user_id]/badges/get.js
index c12b9e4d..16fa58e8 100644
--- a/packages/server/services/users/routes/users/[user_id]/badges/get.js
+++ b/packages/server/services/users/routes/users/[user_id]/badges/get.js
@@ -1,21 +1,21 @@
import { User, Badge } from "@db_models"
export default {
- fn: async (req) => {
- const { user_id } = req.params
+ fn: async (req) => {
+ const { user_id } = req.params
- const user = await User.findOne({
- _id: user_id
- }).catch((err) => {
- return false
- })
+ const user = await User.findOne({
+ _id: user_id,
+ }).catch((err) => {
+ return false
+ })
- const badges = await Badge.find({
- name: {
- $in: user.badges
- }
- })
+ const badges = await Badge.find({
+ name: {
+ $in: user.badges,
+ },
+ })
- return badges
- }
-}
\ No newline at end of file
+ return badges
+ },
+}
diff --git a/packages/server/services/users/routes/users/[user_id]/data/get.js b/packages/server/services/users/routes/users/[user_id]/data/get.js
index ad9ae002..d2506728 100644
--- a/packages/server/services/users/routes/users/[user_id]/data/get.js
+++ b/packages/server/services/users/routes/users/[user_id]/data/get.js
@@ -1,7 +1,7 @@
import Users from "@classes/users"
export default {
- middlewares: ["withOptionalAuthentication"],
+ useMiddlewares: ["withOptionalAuthentication"],
fn: async (req) => {
const { user_id } = req.params
@@ -10,7 +10,7 @@ export default {
return await Users.data({
user_id: ids.length > 1 ? ids : user_id,
from_user_id: req.auth?.session.user_id,
- basic: req.query?.basic,
+ basic: ToBoolean(req.query?.basic),
})
},
}
diff --git a/packages/server/services/users/routes/users/[user_id]/follow/post.js b/packages/server/services/users/routes/users/[user_id]/follow/post.js
index 1c8cda09..41c35777 100644
--- a/packages/server/services/users/routes/users/[user_id]/follow/post.js
+++ b/packages/server/services/users/routes/users/[user_id]/follow/post.js
@@ -1,12 +1,12 @@
import User from "@classes/users"
export default {
- middlewares: ["withAuthentication"],
- fn: async (req) => {
- return await User.toggleFollow({
- user_id: req.params.user_id,
- from_user_id: req.auth.session.user_id,
- to: req.body?.to,
- })
- }
-}
\ No newline at end of file
+ useMiddlewares: ["withAuthentication"],
+ fn: async (req) => {
+ return await User.toggleFollow({
+ user_id: req.params.user_id,
+ from_user_id: req.auth.session.user_id,
+ to: req.body?.to,
+ })
+ },
+}
diff --git a/packages/server/services/users/routes/users/[user_id]/followers/get.js b/packages/server/services/users/routes/users/[user_id]/followers/get.js
index 53768f0e..04f325c4 100644
--- a/packages/server/services/users/routes/users/[user_id]/followers/get.js
+++ b/packages/server/services/users/routes/users/[user_id]/followers/get.js
@@ -1,12 +1,12 @@
import User from "@classes/users"
export default {
- fn: async (req) => {
- return await User.getFollowers({
- user_id: req.params.user_id,
- data: ToBoolean(req.query.fetchData),
- limit: req.query.limit,
- offset: req.query.offset,
- })
- }
-}
\ No newline at end of file
+ fn: async (req) => {
+ return await User.getFollowers({
+ user_id: req.params.user_id,
+ data: ToBoolean(req.query.fetchData),
+ limit: parseInt(req.query.limit),
+ page: parseInt(req.query.page),
+ })
+ },
+}
diff --git a/packages/server/services/users/routes/users/[user_id]/public-key/get.js b/packages/server/services/users/routes/users/[user_id]/public-key/get.js
index 15d50b99..eb01da40 100644
--- a/packages/server/services/users/routes/users/[user_id]/public-key/get.js
+++ b/packages/server/services/users/routes/users/[user_id]/public-key/get.js
@@ -1,7 +1,7 @@
import { UserPublicKey } from "@db_models"
export default {
- middlewares: ["withAuthentication"],
+ useMiddlewares: ["withAuthentication"],
fn: async (req) => {
const targetUserId = req.params.user_id
diff --git a/packages/server/services/users/routes/users/[user_id]/resolve-user_id/get.js b/packages/server/services/users/routes/users/[user_id]/resolve-user_id/get.js
index 68fe7803..bd449a66 100644
--- a/packages/server/services/users/routes/users/[user_id]/resolve-user_id/get.js
+++ b/packages/server/services/users/routes/users/[user_id]/resolve-user_id/get.js
@@ -2,9 +2,9 @@ import Users from "@classes/users"
// resolve user id from a username (passed from params)
export default {
- fn: async (req) => {
- return await Users.resolveUserId({
- username: req.params.user_id,
- })
- },
-}
\ No newline at end of file
+ fn: async (req) => {
+ return await Users.resolveUserId({
+ username: req.params.user_id,
+ })
+ },
+}
diff --git a/packages/server/services/users/routes/users/[user_id]/roles/get.js b/packages/server/services/users/routes/users/[user_id]/roles/get.js
index 908d7de2..b01b4181 100644
--- a/packages/server/services/users/routes/users/[user_id]/roles/get.js
+++ b/packages/server/services/users/routes/users/[user_id]/roles/get.js
@@ -1,12 +1,12 @@
import Users from "@classes/users"
export default {
- middlewares: ["withOptionalAuthentication"],
- fn: async (req) => {
- const data = await Users.data({
- user_id: req.auth.session.user_id,
- })
+ useMiddlewares: ["withOptionalAuthentication"],
+ fn: async (req) => {
+ const data = await Users.data({
+ user_id: req.auth.session.user_id,
+ })
- return data.roles
- }
-}
\ No newline at end of file
+ return data.roles
+ },
+}
diff --git a/packages/server/services/users/routes/users/self/config/get.js b/packages/server/services/users/routes/users/self/config/get.js
index 539da041..0b97547c 100644
--- a/packages/server/services/users/routes/users/self/config/get.js
+++ b/packages/server/services/users/routes/users/self/config/get.js
@@ -1,25 +1,25 @@
import { UserConfig } from "@db_models"
export default {
- middlewares: ["withAuthentication"],
- fn: async (req) => {
- const key = req.query.key
+ useMiddlewares: ["withAuthentication"],
+ fn: async (req) => {
+ const key = req.query.key
- let config = await UserConfig.findOne({
- user_id: req.auth.session.user_id
- })
+ let config = await UserConfig.findOne({
+ user_id: req.auth.session.user_id,
+ })
- if (!config) {
- config = await UserConfig.create({
- user_id: req.auth.session.user_id,
- values: {}
- })
- }
+ if (!config) {
+ config = await UserConfig.create({
+ user_id: req.auth.session.user_id,
+ values: {},
+ })
+ }
- if (key) {
- return config.values?.[key]
- }
+ if (key) {
+ return config.values?.[key]
+ }
- return config.values
- }
-}
\ No newline at end of file
+ return config.values
+ },
+}
diff --git a/packages/server/services/users/routes/users/self/config/put.js b/packages/server/services/users/routes/users/self/config/put.js
index 88d264ca..c6641a12 100644
--- a/packages/server/services/users/routes/users/self/config/put.js
+++ b/packages/server/services/users/routes/users/self/config/put.js
@@ -2,60 +2,65 @@ import { UserConfig } from "@db_models"
import lodash from "lodash"
const baseConfig = [
- {
- key: "app:language",
- type: "string",
- value: "en-us"
- },
- {
- key: "auth:mfa",
- type: "boolean",
- value: false
- },
+ {
+ key: "app:language",
+ type: "string",
+ value: "en-us",
+ },
+ {
+ key: "auth:mfa",
+ type: "boolean",
+ value: false,
+ },
]
export default {
- middlewares: ["withAuthentication"],
- fn: async (req) => {
- let config = await UserConfig.findOne({
- user_id: req.auth.session.user_id
- })
+ useMiddlewares: ["withAuthentication"],
+ fn: async (req) => {
+ let config = await UserConfig.findOne({
+ user_id: req.auth.session.user_id,
+ })
- const values = {}
+ const values = {}
- baseConfig.forEach((config) => {
- const fromBody = req.body[config.key]
- if (typeof fromBody !== "undefined") {
- if (typeof fromBody === config.type) {
- values[config.key] = req.body[config.key]
- } else {
- throw new OperationError(400, `Invalid type for ${config.key}`)
- }
- } else {
- values[config.key] = config.value
- }
- })
+ baseConfig.forEach((config) => {
+ const fromBody = req.body[config.key]
+ if (typeof fromBody !== "undefined") {
+ if (typeof fromBody === config.type) {
+ values[config.key] = req.body[config.key]
+ } else {
+ throw new OperationError(
+ 400,
+ `Invalid type for ${config.key}`,
+ )
+ }
+ } else {
+ values[config.key] = config.value
+ }
+ })
+ if (!config) {
+ config = await UserConfig.create({
+ user_id: req.auth.session.user_id,
+ values,
+ })
+ } else {
+ const newValues = lodash.merge(config.values, values)
- if (!config) {
- config = await UserConfig.create({
- user_id: req.auth.session.user_id,
- values
- })
- } else {
- const newValues = lodash.merge(config.values, values)
+ config = await UserConfig.updateOne(
+ {
+ user_id: req.auth.session.user_id,
+ },
+ {
+ values: newValues,
+ },
+ )
- config = await UserConfig.updateOne({
- user_id: req.auth.session.user_id
- }, {
- values: newValues
- })
+ config = await UserConfig.findOne({
+ user_id: req.auth.session.user_id,
+ })
+ }
- config = await UserConfig.findOne({
- user_id: req.auth.session.user_id
- })
- }
-
- return config.values
- }
-}
\ No newline at end of file
+ return config.values
+ },
+}
diff --git a/packages/server/services/users/routes/users/self/get.js b/packages/server/services/users/routes/users/self/get.js
index 5cb1f441..16456a95 100644
--- a/packages/server/services/users/routes/users/self/get.js
+++ b/packages/server/services/users/routes/users/self/get.js
@@ -1,7 +1,7 @@
import Users from "@classes/users"
export default {
- middlewares: ["withAuthentication"],
+ useMiddlewares: ["withAuthentication"],
fn: async (req) => {
return await Users.data({
user_id: req.auth.session.user_id,
diff --git a/packages/server/services/users/routes/users/self/keypair/get.js b/packages/server/services/users/routes/users/self/keypair/get.js
index 35416987..6553ec12 100644
--- a/packages/server/services/users/routes/users/self/keypair/get.js
+++ b/packages/server/services/users/routes/users/self/keypair/get.js
@@ -1,7 +1,7 @@
import { UserDHKeyPair } from "@db_models"
export default {
- middlewares: ["withAuthentication"],
+ useMiddlewares: ["withAuthentication"],
fn: async (req) => {
const userId = req.auth.session.user_id
diff --git a/packages/server/services/users/routes/users/self/keypair/post.js b/packages/server/services/users/routes/users/self/keypair/post.js
index 97e528d6..a1576e08 100644
--- a/packages/server/services/users/routes/users/self/keypair/post.js
+++ b/packages/server/services/users/routes/users/self/keypair/post.js
@@ -1,7 +1,7 @@
import { UserDHKeyPair } from "@db_models"
export default {
- middlewares: ["withAuthentication"],
+ useMiddlewares: ["withAuthentication"],
fn: async (req) => {
const userId = req.auth.session.user_id
const { str } = req.body
diff --git a/packages/server/services/users/routes/users/self/public-key/post.js b/packages/server/services/users/routes/users/self/public-key/post.js
index 542a58fc..851c1c25 100644
--- a/packages/server/services/users/routes/users/self/public-key/post.js
+++ b/packages/server/services/users/routes/users/self/public-key/post.js
@@ -1,7 +1,7 @@
import { UserPublicKey } from "@db_models"
export default {
- middlewares: ["withAuthentication"],
+ useMiddlewares: ["withAuthentication"],
fn: async (req) => {
const userId = req.auth.session.user_id
const { public_key } = req.body
diff --git a/packages/server/services/users/routes/users/self/roles/get.js b/packages/server/services/users/routes/users/self/roles/get.js
index 68dab220..2e129ee0 100644
--- a/packages/server/services/users/routes/users/self/roles/get.js
+++ b/packages/server/services/users/routes/users/self/roles/get.js
@@ -1,16 +1,16 @@
import Users from "@classes/users"
export default {
- middlewares: ["withAuthentication"],
- fn: async (req) => {
- const data = await Users.data({
- user_id: user_id,
- })
+ useMiddlewares: ["withAuthentication"],
+ fn: async (req) => {
+ const data = await Users.data({
+ user_id: user_id,
+ })
- if (!data) {
- throw new OperationError(404, "User not found")
- }
+ if (!data) {
+ throw new OperationError(404, "User not found")
+ }
- return data.roles
- }
-}
\ No newline at end of file
+ return data.roles
+ },
+}
diff --git a/packages/server/services/users/routes/users/self/update/post.js b/packages/server/services/users/routes/users/self/update/post.js
index 4188455a..a58f0d65 100644
--- a/packages/server/services/users/routes/users/self/update/post.js
+++ b/packages/server/services/users/routes/users/self/update/post.js
@@ -2,56 +2,62 @@ import UserClass from "@classes/users"
import { User } from "@db_models"
const AllowedPublicUpdateFields = [
- "public_name",
- "avatar",
- "email",
- "cover",
- "description",
- "location",
- "links",
- "birthday",
+ "public_name",
+ "avatar",
+ "email",
+ "cover",
+ "description",
+ "location",
+ "links",
+ "birthday",
]
const MaxStringsLengths = {
- public_name: 120,
- email: 320,
- description: 320,
+ public_name: 120,
+ email: 320,
+ description: 320,
}
export default {
- middlewares: ["withAuthentication"],
- fn: async (req) => {
- const update = {}
+ useMiddlewares: ["withAuthentication"],
+ fn: async (req) => {
+ const update = {}
- // sanitize update
- AllowedPublicUpdateFields.forEach((key) => {
- if (typeof req.body[key] !== "undefined") {
- // check maximung strings length
- if (typeof req.body[key] === "string" && MaxStringsLengths[key]) {
- if (req.body[key].length > MaxStringsLengths[key]) {
- // create a substring
- update[key] = req.body[key].substring(0, MaxStringsLengths[key])
- } else {
- update[key] = req.body[key]
- }
- } else {
- update[key] = req.body[key]
- }
- }
- })
+ // sanitize update
+ AllowedPublicUpdateFields.forEach((key) => {
+ if (typeof req.body[key] !== "undefined") {
+ // check maximung strings length
+ if (
+ typeof req.body[key] === "string" &&
+ MaxStringsLengths[key]
+ ) {
+ if (req.body[key].length > MaxStringsLengths[key]) {
+ // create a substring
+ update[key] = req.body[key].substring(
+ 0,
+ MaxStringsLengths[key],
+ )
+ } else {
+ update[key] = req.body[key]
+ }
+ } else {
+ update[key] = req.body[key]
+ }
+ }
+ })
- if (typeof update.email !== "undefined") {
- const user = await User.findOne({
- email: update.email,
- }).catch((err) => {
- return false
- })
+ if (typeof update.email !== "undefined") {
+ const user = await User.findOne({
+ email: update.email,
+ }).catch((err) => {
+ return false
+ })
- if (user) {
- throw new OperationError(400, "Email is already in use")
- }
- }
+ if (user) {
+ throw new OperationError(400, "Email is already in use")
+ }
+ }
- return await UserClass.update(req.auth.session.user_id, update)
- }
-}
\ No newline at end of file
+ return await UserClass.update(req.auth.session.user_id, update)
+ },
+}
diff --git a/packages/server/services/users/users.service.js b/packages/server/services/users/users.service.js
index 3c58eee8..92471766 100644
--- a/packages/server/services/users/users.service.js
+++ b/packages/server/services/users/users.service.js
@@ -4,12 +4,19 @@ import DbManager from "@shared-classes/DbManager"
import RedisClient from "@shared-classes/RedisClient"
import SharedMiddlewares from "@shared-middlewares"
+import InjectedAuth from "@shared-lib/injectedAuth"
export default class API extends Server {
static refName = "users"
- static useEngine = "hyper-express"
static routesPath = `${__dirname}/routes`
- static listen_port = process.env.HTTP_LISTEN_PORT ?? 3008
+ static listenPort = process.env.HTTP_LISTEN_PORT ?? 3008
+
+ static useMiddlewares = ["logs"]
+
+ static websockets = {
+ enabled: true,
+ path: "/users",
+ }
middlewares = {
...SharedMiddlewares,
@@ -20,6 +27,24 @@ export default class API extends Server {
redis: RedisClient(),
}
+ handleWsUpgrade = async (context, token, res) => {
+ if (!token) {
+ return res.upgrade(context)
+ }
+
+ context = await InjectedAuth(context, token, res).catch(() => {
+ res.close(401, "Failed to verify auth token")
+ return false
+ })
+
+ if (!context || !context.user) {
+ res.close(401, "Unauthorized or missing auth token")
+ return false
+ }
+
+ return res.upgrade(context)
+ }
+
async onInitialize() {
await this.contexts.db.initialize()
await this.contexts.redis.initialize()
diff --git a/vessel b/vessel
index e1d44d19..1c714842 160000
--- a/vessel
+++ b/vessel
@@ -1 +1 @@
-Subproject commit e1d44d19e6270c8ed9cb63a041e0fac8c49b988f
+Subproject commit 1c7148423b014ea292307b2525382b77a0ba4c73