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 && ( -
- } - > - Resume - -
- )} - - - {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")} +
+
+

{link.value}

+
+
+ ) } 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 + ) } -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 ( -
-
- ); -} - -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 && ( - 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 && ( -
-

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 && ( - {customApp.name} + {appMetadata.icon && ( + {appMetadata.title} )}
@@ -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 &&
-

Replies

- - -
- } -
+ +
+ )} +
+ ) } -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 ( +
+ + + + ))} +
+ +
+ ) +}) + +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}

+ + +
+ + + + + + {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 ( + +
+ +