BetterYTM.user.js 392 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509651065116512651365146515651665176518651965206521652265236524652565266527652865296530653165326533653465356536653765386539654065416542654365446545654665476548654965506551655265536554655565566557655865596560656165626563656465656566656765686569657065716572657365746575657665776578657965806581658265836584658565866587658865896590659165926593659465956596659765986599660066016602660366046605660666076608660966106611661266136614661566166617661866196620662166226623662466256626662766286629663066316632663366346635663666376638663966406641664266436644664566466647664866496650665166526653665466556656665766586659666066616662666366646665666666676668666966706671667266736674667566766677667866796680668166826683668466856686668766886689669066916692669366946695669666976698669967006701670267036704670567066707670867096710671167126713671467156716671767186719672067216722672367246725672667276728672967306731673267336734673567366737673867396740674167426743674467456746674767486749675067516752675367546755675667576758675967606761676267636764676567666767676867696770677167726773677467756776677767786779678067816782678367846785678667876788678967906791679267936794679567966797679867996800680168026803680468056806680768086809681068116812681368146815681668176818681968206821682268236824682568266827682868296830683168326833683468356836683768386839684068416842684368446845684668476848684968506851685268536854685568566857685868596860686168626863686468656866686768686869687068716872687368746875687668776878687968806881688268836884688568866887688868896890689168926893689468956896689768986899690069016902690369046905690669076908690969106911691269136914691569166917691869196920692169226923692469256926692769286929693069316932693369346935693669376938693969406941694269436944694569466947694869496950695169526953695469556956695769586959696069616962696369646965696669676968696969706971697269736974697569766977697869796980698169826983698469856986698769886989699069916992699369946995699669976998699970007001700270037004700570067007700870097010701170127013701470157016701770187019702070217022702370247025702670277028702970307031703270337034703570367037703870397040704170427043704470457046704770487049705070517052705370547055705670577058705970607061706270637064706570667067706870697070707170727073707470757076707770787079708070817082708370847085708670877088708970907091709270937094709570967097709870997100710171027103710471057106710771087109711071117112711371147115711671177118711971207121712271237124712571267127712871297130713171327133713471357136713771387139714071417142714371447145714671477148714971507151715271537154715571567157715871597160716171627163716471657166716771687169717071717172717371747175717671777178717971807181718271837184718571867187718871897190719171927193719471957196719771987199720072017202720372047205720672077208720972107211721272137214721572167217721872197220722172227223722472257226722772287229723072317232723372347235723672377238723972407241724272437244724572467247724872497250725172527253725472557256725772587259726072617262726372647265726672677268726972707271727272737274727572767277727872797280728172827283728472857286728772887289729072917292729372947295729672977298729973007301730273037304730573067307730873097310731173127313731473157316731773187319732073217322732373247325732673277328732973307331733273337334733573367337733873397340734173427343734473457346734773487349735073517352735373547355735673577358735973607361736273637364736573667367736873697370737173727373737473757376737773787379738073817382738373847385738673877388738973907391739273937394739573967397739873997400740174027403740474057406740774087409741074117412741374147415741674177418741974207421742274237424742574267427742874297430743174327433743474357436743774387439744074417442744374447445744674477448744974507451745274537454745574567457745874597460746174627463746474657466746774687469747074717472747374747475747674777478747974807481748274837484748574867487748874897490749174927493749474957496749774987499750075017502750375047505750675077508750975107511751275137514751575167517751875197520752175227523752475257526752775287529753075317532753375347535753675377538753975407541754275437544754575467547754875497550755175527553755475557556755775587559756075617562756375647565756675677568756975707571757275737574757575767577757875797580758175827583758475857586758775887589759075917592759375947595759675977598759976007601760276037604760576067607760876097610761176127613761476157616761776187619762076217622762376247625762676277628762976307631763276337634763576367637763876397640764176427643764476457646764776487649765076517652765376547655765676577658765976607661766276637664766576667667766876697670767176727673767476757676767776787679768076817682768376847685768676877688768976907691769276937694769576967697769876997700770177027703770477057706770777087709771077117712771377147715771677177718771977207721772277237724772577267727772877297730773177327733773477357736773777387739774077417742774377447745774677477748774977507751775277537754775577567757775877597760776177627763776477657766776777687769777077717772777377747775777677777778777977807781778277837784778577867787778877897790779177927793779477957796779777987799780078017802780378047805780678077808780978107811781278137814781578167817781878197820782178227823782478257826782778287829783078317832783378347835783678377838783978407841784278437844784578467847784878497850785178527853785478557856785778587859786078617862786378647865786678677868786978707871787278737874787578767877787878797880788178827883788478857886788778887889789078917892789378947895789678977898789979007901790279037904790579067907790879097910791179127913791479157916791779187919792079217922792379247925792679277928792979307931793279337934793579367937793879397940794179427943794479457946794779487949795079517952795379547955795679577958795979607961796279637964796579667967796879697970797179727973797479757976797779787979798079817982798379847985798679877988798979907991799279937994799579967997799879998000800180028003800480058006800780088009801080118012801380148015801680178018801980208021802280238024802580268027802880298030803180328033803480358036803780388039804080418042804380448045804680478048804980508051805280538054805580568057805880598060806180628063806480658066806780688069807080718072807380748075807680778078807980808081808280838084808580868087808880898090809180928093809480958096809780988099810081018102810381048105810681078108810981108111811281138114811581168117811881198120812181228123812481258126812781288129813081318132813381348135813681378138813981408141814281438144814581468147814881498150815181528153815481558156815781588159816081618162816381648165816681678168816981708171817281738174817581768177817881798180818181828183818481858186818781888189
  1. // ==UserScript==
  2. // @name BetterYTM
  3. // @namespace https://github.com/Sv443/BetterYTM
  4. // @version 2.0.0
  5. // @description Lots of configurable layout and user experience improvements for YouTube Music™ and YouTube™
  6. // @description:de-DE Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™
  7. // @description:en-US Configurable layout and user experience improvements for YouTube Music™ and YouTube™
  8. // @description:en-UK Configurable layout and user experience improvements for YouTube Music™ and YouTube™
  9. // @description:es-ES Mejoras de diseño y experiencia de usuario configurables para YouTube Music™ y YouTube™
  10. // @description:fr-FR Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music™ et YouTube™
  11. // @description:hi-IN YouTube Music™ और YouTube™ के लिए कॉन्फ़िगर करने योग्य लेआउट और उपयोगकर्ता अनुभव में सुधार
  12. // @description:ja-JA YouTube Music™ と YouTube™ の構成可能なレイアウトとユーザー エクスペリエンスの向上
  13. // @description:pt-BR Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music™ e o YouTube™
  14. // @description:zh-CN YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进
  15. // @homepageURL https://github.com/Sv443/BetterYTM#readme
  16. // @supportURL https://github.com/Sv443/BetterYTM/issues
  17. // @license AGPL-3.0-only
  18. // @author Sv443
  19. // @copyright Sv443 (https://github.com/Sv443)
  20. // @icon https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/images/logo/logo_dev_48.png
  21. // @match https://music.youtube.com/*
  22. // @match https://www.youtube.com/*
  23. // @run-at document-start
  24. // @connect api.sv443.net
  25. // @connect github.com
  26. // @connect raw.githubusercontent.com
  27. // @grant GM.getValue
  28. // @grant GM.setValue
  29. // @grant GM.deleteValue
  30. // @grant GM.getResourceUrl
  31. // @grant GM.setClipboard
  32. // @grant GM.xmlHttpRequest
  33. // @grant GM.openInTab
  34. // @grant unsafeWindow
  35. // @noframes
  36. // @resource css-above_queue_btns https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/style/aboveQueueBtns.css
  37. // @resource css-anchor_improvements https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/style/anchorImprovements.css
  38. // @resource css-bundle https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/dist/BetterYTM.css
  39. // @resource css-fix_hdr https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/style/fixHDR.css
  40. // @resource css-fix_spacing https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/style/fixSpacing.css
  41. // @resource css-show_votes https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/style/showVotes.css
  42. // @resource css-vol_slider_size https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/style/volSliderSize.css
  43. // @resource doc-changelog https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/changelog.md
  44. // @resource icon-advanced_mode https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/plus_circle_small.svg
  45. // @resource icon-arrow_down https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/arrow_down.svg
  46. // @resource icon-auto_like https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/auto_like.svg
  47. // @resource icon-auto_like_enabled https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/auto_like_enabled.svg
  48. // @resource icon-clear_list https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/clear_list.svg
  49. // @resource icon-copy https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/copy.svg
  50. // @resource icon-delete https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/delete.svg
  51. // @resource icon-edit https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/edit.svg
  52. // @resource icon-error https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/error.svg
  53. // @resource icon-experimental https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/beaker_small.svg
  54. // @resource icon-globe https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/globe.svg
  55. // @resource icon-globe_small https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/globe_small.svg
  56. // @resource icon-help https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/help.svg
  57. // @resource icon-image https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/image.svg
  58. // @resource icon-image_filled https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/image_filled.svg
  59. // @resource icon-link https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/link.svg
  60. // @resource icon-lyrics https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/lyrics.svg
  61. // @resource icon-reload https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/refresh.svg
  62. // @resource icon-skip_to https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/skip_to.svg
  63. // @resource icon-spinner https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/spinner.svg
  64. // @resource icon-upload https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/icons/upload.svg
  65. // @resource img-close https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/images/close.png
  66. // @resource img-discord https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/images/external/discord.png
  67. // @resource img-github https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/images/external/github.png
  68. // @resource img-greasyfork https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/images/external/greasyfork.png
  69. // @resource img-logo https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/images/logo/logo_48.png
  70. // @resource img-logo_dev https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/images/logo/logo_dev_48.png
  71. // @resource img-openuserjs https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/images/external/openuserjs.png
  72. // @resource trans-de_DE https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/translations/de_DE.json
  73. // @resource trans-en_UK https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/translations/en_UK.json
  74. // @resource trans-en_US https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/translations/en_US.json
  75. // @resource trans-es_ES https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/translations/es_ES.json
  76. // @resource trans-fr_FR https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/translations/fr_FR.json
  77. // @resource trans-hi_IN https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/translations/hi_IN.json
  78. // @resource trans-ja_JA https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/translations/ja_JA.json
  79. // @resource trans-pt_BR https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/translations/pt_BR.json
  80. // @resource trans-zh_CN https://raw.githubusercontent.com/Sv443/BetterYTM/49e6b2fd/assets/translations/zh_CN.json
  81. // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/fuse.basic.js
  82. // @require https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.js
  83. // @require https://cdn.jsdelivr.net/npm/[email protected]/lib/umd/index.js
  84. // @grant GM.registerMenuCommand
  85. // @grant GM.listValues
  86. // ==/UserScript==
  87. /*
  88. ▄▄▄ ▄ ▄▄▄▄▄▄ ▄
  89. █ █ ▄▄▄ █ █ ▄▄▄ ▄ ▄█ █ █ █▀▄▀█
  90. █▀▀▄ █▄█ █▀ █▀ █▄█ █▀ █ █ █ █
  91. █▄▄▀ ▀▄▄ ▀▄▄ ▀▄▄ ▀▄▄ █ █ █ █ █
  92. Made with ❤️ by Sv443
  93. I welcome every contribution on GitHub!
  94. https://github.com/Sv443/BetterYTM
  95. */
  96. /* Disclaimer: I am not affiliated with or endorsed by YouTube, Google, Alphabet, Genius or anyone else */
  97. /* C&D this 🖕 */
  98. var UserUtils = (function (exports) {
  99. var __defProp = Object.defineProperty;
  100. var __getOwnPropSymbols = Object.getOwnPropertySymbols;
  101. var __hasOwnProp = Object.prototype.hasOwnProperty;
  102. var __propIsEnum = Object.prototype.propertyIsEnumerable;
  103. var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
  104. var __spreadValues = (a, b) => {
  105. for (var prop in b || (b = {}))
  106. if (__hasOwnProp.call(b, prop))
  107. __defNormalProp(a, prop, b[prop]);
  108. if (__getOwnPropSymbols)
  109. for (var prop of __getOwnPropSymbols(b)) {
  110. if (__propIsEnum.call(b, prop))
  111. __defNormalProp(a, prop, b[prop]);
  112. }
  113. return a;
  114. };
  115. var __objRest = (source, exclude) => {
  116. var target = {};
  117. for (var prop in source)
  118. if (__hasOwnProp.call(source, prop) && exclude.indexOf(prop) < 0)
  119. target[prop] = source[prop];
  120. if (source != null && __getOwnPropSymbols)
  121. for (var prop of __getOwnPropSymbols(source)) {
  122. if (exclude.indexOf(prop) < 0 && __propIsEnum.call(source, prop))
  123. target[prop] = source[prop];
  124. }
  125. return target;
  126. };
  127. var __publicField = (obj, key, value) => {
  128. __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
  129. return value;
  130. };
  131. var __async = (__this, __arguments, generator) => {
  132. return new Promise((resolve, reject) => {
  133. var fulfilled = (value) => {
  134. try {
  135. step(generator.next(value));
  136. } catch (e) {
  137. reject(e);
  138. }
  139. };
  140. var rejected = (value) => {
  141. try {
  142. step(generator.throw(value));
  143. } catch (e) {
  144. reject(e);
  145. }
  146. };
  147. var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
  148. step((generator = generator.apply(__this, __arguments)).next());
  149. });
  150. };
  151. // lib/math.ts
  152. function clamp(value, min, max) {
  153. return Math.max(Math.min(value, max), min);
  154. }
  155. function mapRange(value, range1min, range1max, range2min, range2max) {
  156. if (Number(range1min) === 0 && Number(range2min) === 0)
  157. return value * (range2max / range1max);
  158. return (value - range1min) * ((range2max - range2min) / (range1max - range1min)) + range2min;
  159. }
  160. function randRange(...args) {
  161. let min, max;
  162. if (typeof args[0] === "number" && typeof args[1] === "number")
  163. [min, max] = args;
  164. else if (typeof args[0] === "number" && typeof args[1] !== "number") {
  165. min = 0;
  166. [max] = args;
  167. } else
  168. throw new TypeError(`Wrong parameter(s) provided - expected: "number" and "number|undefined", got: "${typeof args[0]}" and "${typeof args[1]}"`);
  169. min = Number(min);
  170. max = Number(max);
  171. if (isNaN(min) || isNaN(max))
  172. return NaN;
  173. if (min > max)
  174. throw new TypeError(`Parameter "min" can't be bigger than "max"`);
  175. return Math.floor(Math.random() * (max - min + 1)) + min;
  176. }
  177. // lib/array.ts
  178. function randomItem(array) {
  179. return randomItemIndex(array)[0];
  180. }
  181. function randomItemIndex(array) {
  182. if (array.length === 0)
  183. return [void 0, void 0];
  184. const idx = randRange(array.length - 1);
  185. return [array[idx], idx];
  186. }
  187. function takeRandomItem(arr) {
  188. const [itm, idx] = randomItemIndex(arr);
  189. if (idx === void 0)
  190. return void 0;
  191. arr.splice(idx, 1);
  192. return itm;
  193. }
  194. function randomizeArray(array) {
  195. const retArray = [...array];
  196. if (array.length === 0)
  197. return retArray;
  198. for (let i = retArray.length - 1; i > 0; i--) {
  199. const j = Math.floor(randRange(0, 1e4) / 1e4 * (i + 1));
  200. [retArray[i], retArray[j]] = [retArray[j], retArray[i]];
  201. }
  202. return retArray;
  203. }
  204. // lib/DataStore.ts
  205. var DataStore = class {
  206. /**
  207. * Creates an instance of DataStore to manage a sync & async database that is cached in memory and persistently saved across sessions.
  208. * Supports migrating data from older versions to newer ones and populating the cache with default data if no persistent data is found.
  209. *
  210. * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue`
  211. * ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultData`
  212. *
  213. * @template TData The type of the data that is saved in persistent storage (will be automatically inferred from `options.defaultData`) - this should also be the type of the data format associated with the current `options.formatVersion`
  214. * @param options The options for this DataStore instance
  215. */
  216. constructor(options) {
  217. __publicField(this, "id");
  218. __publicField(this, "formatVersion");
  219. __publicField(this, "defaultData");
  220. __publicField(this, "encodeData");
  221. __publicField(this, "decodeData");
  222. __publicField(this, "cachedData");
  223. __publicField(this, "migrations");
  224. this.id = options.id;
  225. this.formatVersion = options.formatVersion;
  226. this.defaultData = options.defaultData;
  227. this.cachedData = options.defaultData;
  228. this.migrations = options.migrations;
  229. this.encodeData = options.encodeData;
  230. this.decodeData = options.decodeData;
  231. }
  232. /**
  233. * Loads the data saved in persistent storage into the in-memory cache and also returns it.
  234. * Automatically populates persistent storage with default data if it doesn't contain any data yet.
  235. * Also runs all necessary migration functions if the data format has changed since the last time the data was saved.
  236. */
  237. loadData() {
  238. return __async(this, null, function* () {
  239. try {
  240. const gmData = yield this.getValue(`_uucfg-${this.id}`, this.defaultData);
  241. let gmFmtVer = Number(yield this.getValue(`_uucfgver-${this.id}`, NaN));
  242. if (typeof gmData !== "string") {
  243. yield this.saveDefaultData();
  244. return __spreadValues({}, this.defaultData);
  245. }
  246. const isEncoded = yield this.getValue(`_uucfgenc-${this.id}`, false);
  247. let saveData = false;
  248. if (isNaN(gmFmtVer)) {
  249. yield this.setValue(`_uucfgver-${this.id}`, gmFmtVer = this.formatVersion);
  250. saveData = true;
  251. }
  252. let parsed = yield this.deserializeData(gmData, isEncoded);
  253. if (gmFmtVer < this.formatVersion && this.migrations)
  254. parsed = yield this.runMigrations(parsed, gmFmtVer);
  255. if (saveData)
  256. yield this.setData(parsed);
  257. this.cachedData = __spreadValues({}, parsed);
  258. return this.cachedData;
  259. } catch (err) {
  260. console.warn("Error while parsing JSON data, resetting it to the default value.", err);
  261. yield this.saveDefaultData();
  262. return this.defaultData;
  263. }
  264. });
  265. }
  266. /**
  267. * Returns a copy of the data from the in-memory cache.
  268. * Use {@linkcode loadData()} to get fresh data from persistent storage (usually not necessary since the cache should always exactly reflect persistent storage).
  269. * @param deepCopy Whether to return a deep copy of the data (default: `false`) - only necessary if your data object is nested and may have a bigger performance impact if enabled
  270. */
  271. getData(deepCopy = false) {
  272. return deepCopy ? this.deepCopy(this.cachedData) : __spreadValues({}, this.cachedData);
  273. }
  274. /** Saves the data synchronously to the in-memory cache and asynchronously to the persistent storage */
  275. setData(data) {
  276. this.cachedData = data;
  277. const useEncoding = this.encodingEnabled();
  278. return new Promise((resolve) => __async(this, null, function* () {
  279. yield Promise.all([
  280. this.setValue(`_uucfg-${this.id}`, yield this.serializeData(data, useEncoding)),
  281. this.setValue(`_uucfgver-${this.id}`, this.formatVersion),
  282. this.setValue(`_uucfgenc-${this.id}`, useEncoding)
  283. ]);
  284. resolve();
  285. }));
  286. }
  287. /** Saves the default data passed in the constructor synchronously to the in-memory cache and asynchronously to persistent storage */
  288. saveDefaultData() {
  289. return __async(this, null, function* () {
  290. this.cachedData = this.defaultData;
  291. const useEncoding = this.encodingEnabled();
  292. return new Promise((resolve) => __async(this, null, function* () {
  293. yield Promise.all([
  294. this.setValue(`_uucfg-${this.id}`, yield this.serializeData(this.defaultData, useEncoding)),
  295. this.setValue(`_uucfgver-${this.id}`, this.formatVersion),
  296. this.setValue(`_uucfgenc-${this.id}`, useEncoding)
  297. ]);
  298. resolve();
  299. }));
  300. });
  301. }
  302. /**
  303. * Call this method to clear all persistently stored data associated with this DataStore instance.
  304. * The in-memory cache will be left untouched, so you may still access the data with {@linkcode getData()}
  305. * Calling {@linkcode loadData()} or {@linkcode setData()} after this method was called will recreate persistent storage with the cached or default data.
  306. *
  307. * ⚠️ This requires the additional directive `@grant GM.deleteValue`
  308. */
  309. deleteData() {
  310. return __async(this, null, function* () {
  311. yield Promise.all([
  312. this.deleteValue(`_uucfg-${this.id}`),
  313. this.deleteValue(`_uucfgver-${this.id}`),
  314. this.deleteValue(`_uucfgenc-${this.id}`)
  315. ]);
  316. });
  317. }
  318. /**
  319. * Runs all necessary migration functions consecutively and saves the result to the in-memory cache and persistent storage and also returns it.
  320. * This method is automatically called by {@linkcode loadData()} if the data format has changed since the last time the data was saved.
  321. * Though calling this method manually is not necessary, it can be useful if you want to run migrations for special occasions like a user importing potentially outdated data that has been previously exported.
  322. *
  323. * If one of the migrations fails, the data will be reset to the default value if `resetOnError` is set to `true` (default). Otherwise, an error will be thrown and no data will be saved.
  324. */
  325. runMigrations(oldData, oldFmtVer, resetOnError = true) {
  326. return __async(this, null, function* () {
  327. if (!this.migrations)
  328. return oldData;
  329. let newData = oldData;
  330. const sortedMigrations = Object.entries(this.migrations).sort(([a], [b]) => Number(a) - Number(b));
  331. let lastFmtVer = oldFmtVer;
  332. for (const [fmtVer, migrationFunc] of sortedMigrations) {
  333. const ver = Number(fmtVer);
  334. if (oldFmtVer < this.formatVersion && oldFmtVer < ver) {
  335. try {
  336. const migRes = migrationFunc(newData);
  337. newData = migRes instanceof Promise ? yield migRes : migRes;
  338. lastFmtVer = oldFmtVer = ver;
  339. } catch (err) {
  340. if (!resetOnError)
  341. throw new Error(`Error while running migration function for format version '${fmtVer}'`);
  342. console.error(`Error while running migration function for format version '${fmtVer}' - resetting to the default value.`, err);
  343. yield this.saveDefaultData();
  344. return this.getData();
  345. }
  346. }
  347. }
  348. yield Promise.all([
  349. this.setValue(`_uucfg-${this.id}`, yield this.serializeData(newData)),
  350. this.setValue(`_uucfgver-${this.id}`, lastFmtVer),
  351. this.setValue(`_uucfgenc-${this.id}`, this.encodingEnabled())
  352. ]);
  353. return this.cachedData = __spreadValues({}, newData);
  354. });
  355. }
  356. /** Returns whether encoding and decoding are enabled for this DataStore instance */
  357. encodingEnabled() {
  358. return Boolean(this.encodeData && this.decodeData);
  359. }
  360. /** Serializes the data using the optional this.encodeData() and returns it as a string */
  361. serializeData(data, useEncoding = true) {
  362. return __async(this, null, function* () {
  363. const stringData = JSON.stringify(data);
  364. if (!this.encodingEnabled() || !useEncoding)
  365. return stringData;
  366. const encRes = this.encodeData(stringData);
  367. if (encRes instanceof Promise)
  368. return yield encRes;
  369. return encRes;
  370. });
  371. }
  372. /** Deserializes the data using the optional this.decodeData() and returns it as a JSON object */
  373. deserializeData(data, useEncoding = true) {
  374. return __async(this, null, function* () {
  375. let decRes = this.encodingEnabled() && useEncoding ? this.decodeData(data) : void 0;
  376. if (decRes instanceof Promise)
  377. decRes = yield decRes;
  378. return JSON.parse(decRes != null ? decRes : data);
  379. });
  380. }
  381. /** Copies a JSON-compatible object and loses all its internal references in the process */
  382. deepCopy(obj) {
  383. return JSON.parse(JSON.stringify(obj));
  384. }
  385. /** Gets a value from persistent storage - can be overwritten in a subclass if you want to use something other than GM storage */
  386. getValue(name, defaultValue) {
  387. return __async(this, null, function* () {
  388. return GM.getValue(name, defaultValue);
  389. });
  390. }
  391. /** Sets a value in persistent storage - can be overwritten in a subclass if you want to use something other than GM storage */
  392. setValue(name, value) {
  393. return __async(this, null, function* () {
  394. return GM.setValue(name, value);
  395. });
  396. }
  397. /** Deletes a value from persistent storage - can be overwritten in a subclass if you want to use something other than GM storage */
  398. deleteValue(name) {
  399. return __async(this, null, function* () {
  400. return GM.deleteValue(name);
  401. });
  402. }
  403. };
  404. // lib/DataStoreSerializer.ts
  405. var DataStoreSerializer = class {
  406. constructor(stores, options = {}) {
  407. __publicField(this, "stores");
  408. __publicField(this, "options");
  409. if (!getUnsafeWindow().crypto || !getUnsafeWindow().crypto.subtle)
  410. throw new Error("DataStoreSerializer has to run in a secure context (HTTPS)!");
  411. this.stores = stores;
  412. this.options = __spreadValues({
  413. addChecksum: true,
  414. ensureIntegrity: true
  415. }, options);
  416. }
  417. /** Calculates the checksum of a string */
  418. calcChecksum(input) {
  419. return __async(this, null, function* () {
  420. return computeHash(input, "SHA-256");
  421. });
  422. }
  423. /** Serializes a DataStore instance */
  424. serializeStore(storeInst) {
  425. return __async(this, null, function* () {
  426. const data = storeInst.encodingEnabled() ? yield storeInst.encodeData(JSON.stringify(storeInst.getData())) : JSON.stringify(storeInst.getData());
  427. const checksum = this.options.addChecksum ? yield this.calcChecksum(data) : void 0;
  428. return {
  429. id: storeInst.id,
  430. data,
  431. formatVersion: storeInst.formatVersion,
  432. encoded: storeInst.encodingEnabled(),
  433. checksum
  434. };
  435. });
  436. }
  437. /** Serializes the data stores into a string */
  438. serialize() {
  439. return __async(this, null, function* () {
  440. const serData = [];
  441. for (const store of this.stores)
  442. serData.push(yield this.serializeStore(store));
  443. return JSON.stringify(serData);
  444. });
  445. }
  446. /**
  447. * Deserializes the data exported via {@linkcode serialize()} and imports it into the DataStore instances.
  448. * Also triggers the migration process if the data format has changed.
  449. */
  450. deserialize(serializedData) {
  451. return __async(this, null, function* () {
  452. const deserStores = JSON.parse(serializedData);
  453. for (const storeData of deserStores) {
  454. const storeInst = this.stores.find((s) => s.id === storeData.id);
  455. if (!storeInst)
  456. throw new Error(`DataStore instance with ID "${storeData.id}" not found! Make sure to provide it in the DataStoreSerializer constructor.`);
  457. if (this.options.ensureIntegrity && typeof storeData.checksum === "string") {
  458. const checksum = yield this.calcChecksum(storeData.data);
  459. if (checksum !== storeData.checksum)
  460. throw new Error(`Checksum mismatch for DataStore with ID "${storeData.id}"!
  461. Expected: ${storeData.checksum}
  462. Has: ${checksum}`);
  463. }
  464. const decodedData = storeData.encoded && storeInst.encodingEnabled() ? yield storeInst.decodeData(storeData.data) : storeData.data;
  465. if (storeData.formatVersion && !isNaN(Number(storeData.formatVersion)) && Number(storeData.formatVersion) < storeInst.formatVersion)
  466. yield storeInst.runMigrations(JSON.parse(decodedData), Number(storeData.formatVersion), false);
  467. else
  468. yield storeInst.setData(JSON.parse(decodedData));
  469. }
  470. });
  471. }
  472. };
  473. // lib/dom.ts
  474. function getUnsafeWindow() {
  475. try {
  476. return unsafeWindow;
  477. } catch (e) {
  478. return window;
  479. }
  480. }
  481. function addParent(element, newParent) {
  482. const oldParent = element.parentNode;
  483. if (!oldParent)
  484. throw new Error("Element doesn't have a parent node");
  485. oldParent.replaceChild(newParent, element);
  486. newParent.appendChild(element);
  487. return newParent;
  488. }
  489. function addGlobalStyle(style) {
  490. const styleElem = document.createElement("style");
  491. styleElem.innerHTML = style;
  492. document.head.appendChild(styleElem);
  493. return styleElem;
  494. }
  495. function preloadImages(srcUrls, rejects = false) {
  496. const promises = srcUrls.map((src) => new Promise((res, rej) => {
  497. const image = new Image();
  498. image.src = src;
  499. image.addEventListener("load", () => res(image));
  500. image.addEventListener("error", (evt) => rejects && rej(evt));
  501. }));
  502. return Promise.allSettled(promises);
  503. }
  504. function openInNewTab(href, background) {
  505. try {
  506. GM.openInTab(href, background);
  507. } catch (e) {
  508. const openElem = document.createElement("a");
  509. Object.assign(openElem, {
  510. className: "userutils-open-in-new-tab",
  511. target: "_blank",
  512. rel: "noopener noreferrer",
  513. href
  514. });
  515. openElem.style.display = "none";
  516. document.body.appendChild(openElem);
  517. openElem.click();
  518. setTimeout(openElem.remove, 50);
  519. }
  520. }
  521. function interceptEvent(eventObject, eventName, predicate = () => true) {
  522. Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 100);
  523. if (isNaN(Error.stackTraceLimit))
  524. Error.stackTraceLimit = 100;
  525. (function(original) {
  526. eventObject.__proto__.addEventListener = function(...args) {
  527. var _a, _b;
  528. const origListener = typeof args[1] === "function" ? args[1] : (_b = (_a = args[1]) == null ? void 0 : _a.handleEvent) != null ? _b : () => void 0;
  529. args[1] = function(...a) {
  530. if (args[0] === eventName && predicate(Array.isArray(a) ? a[0] : a))
  531. return;
  532. else
  533. return origListener.apply(this, a);
  534. };
  535. original.apply(this, args);
  536. };
  537. })(eventObject.__proto__.addEventListener);
  538. }
  539. function interceptWindowEvent(eventName, predicate = () => true) {
  540. return interceptEvent(getUnsafeWindow(), eventName, predicate);
  541. }
  542. function isScrollable(element) {
  543. const { overflowX, overflowY } = getComputedStyle(element);
  544. return {
  545. vertical: (overflowY === "scroll" || overflowY === "auto") && element.scrollHeight > element.clientHeight,
  546. horizontal: (overflowX === "scroll" || overflowX === "auto") && element.scrollWidth > element.clientWidth
  547. };
  548. }
  549. function observeElementProp(element, property, callback) {
  550. const elementPrototype = Object.getPrototypeOf(element);
  551. if (elementPrototype.hasOwnProperty(property)) {
  552. const descriptor = Object.getOwnPropertyDescriptor(elementPrototype, property);
  553. Object.defineProperty(element, property, {
  554. get: function() {
  555. var _a;
  556. return (_a = descriptor == null ? void 0 : descriptor.get) == null ? void 0 : _a.apply(this, arguments);
  557. },
  558. set: function() {
  559. var _a;
  560. const oldValue = this[property];
  561. (_a = descriptor == null ? void 0 : descriptor.set) == null ? void 0 : _a.apply(this, arguments);
  562. const newValue = this[property];
  563. if (typeof callback === "function") {
  564. callback.bind(this, oldValue, newValue);
  565. }
  566. return newValue;
  567. }
  568. });
  569. }
  570. }
  571. function getSiblingsFrame(refElement, siblingAmount, refElementAlignment = "center-top", includeRef = true) {
  572. var _a, _b;
  573. const siblings = [...(_b = (_a = refElement.parentNode) == null ? void 0 : _a.childNodes) != null ? _b : []];
  574. const elemSiblIdx = siblings.indexOf(refElement);
  575. if (elemSiblIdx === -1)
  576. throw new Error("Element doesn't have a parent node");
  577. if (refElementAlignment === "top")
  578. return [...siblings.slice(elemSiblIdx + Number(!includeRef), elemSiblIdx + siblingAmount + Number(!includeRef))];
  579. else if (refElementAlignment.startsWith("center-")) {
  580. const halfAmount = (refElementAlignment === "center-bottom" ? Math.ceil : Math.floor)(siblingAmount / 2);
  581. const startIdx = Math.max(0, elemSiblIdx - halfAmount);
  582. const topOffset = Number(refElementAlignment === "center-top" && siblingAmount % 2 === 0 && includeRef);
  583. const btmOffset = Number(refElementAlignment === "center-bottom" && siblingAmount % 2 !== 0 && includeRef);
  584. const startIdxWithOffset = startIdx + topOffset + btmOffset;
  585. return [
  586. ...siblings.filter((_, idx) => includeRef || idx !== elemSiblIdx).slice(startIdxWithOffset, startIdxWithOffset + siblingAmount)
  587. ];
  588. } else if (refElementAlignment === "bottom")
  589. return [...siblings.slice(elemSiblIdx - siblingAmount + Number(includeRef), elemSiblIdx + Number(includeRef))];
  590. return [];
  591. }
  592. // lib/misc.ts
  593. function autoPlural(word, num) {
  594. if (Array.isArray(num) || num instanceof NodeList)
  595. num = num.length;
  596. return `${word}${num === 1 ? "" : "s"}`;
  597. }
  598. function pauseFor(time) {
  599. return new Promise((res) => {
  600. setTimeout(() => res(), time);
  601. });
  602. }
  603. function debounce(func, timeout = 300, edge = "falling") {
  604. let timer;
  605. return function(...args) {
  606. if (edge === "rising") {
  607. if (!timer) {
  608. func.apply(this, args);
  609. timer = setTimeout(() => timer = void 0, timeout);
  610. }
  611. } else {
  612. clearTimeout(timer);
  613. timer = setTimeout(() => func.apply(this, args), timeout);
  614. }
  615. };
  616. }
  617. function fetchAdvanced(_0) {
  618. return __async(this, arguments, function* (input, options = {}) {
  619. const { timeout = 1e4 } = options;
  620. let signalOpts = {}, id = void 0;
  621. if (timeout >= 0) {
  622. const controller = new AbortController();
  623. id = setTimeout(() => controller.abort(), timeout);
  624. signalOpts = { signal: controller.signal };
  625. }
  626. const res = yield fetch(input, __spreadValues(__spreadValues({}, options), signalOpts));
  627. clearTimeout(id);
  628. return res;
  629. });
  630. }
  631. function insertValues(input, ...values) {
  632. return input.replace(/%\d/gm, (match) => {
  633. var _a, _b;
  634. const argIndex = Number(match.substring(1)) - 1;
  635. return (_b = (_a = values[argIndex]) != null ? _a : match) == null ? void 0 : _b.toString();
  636. });
  637. }
  638. function compress(input, compressionFormat, outputType = "string") {
  639. return __async(this, null, function* () {
  640. const byteArray = typeof input === "string" ? new TextEncoder().encode(input) : input;
  641. const comp = new CompressionStream(compressionFormat);
  642. const writer = comp.writable.getWriter();
  643. writer.write(byteArray);
  644. writer.close();
  645. const buf = yield new Response(comp.readable).arrayBuffer();
  646. return outputType === "arrayBuffer" ? buf : ab2str(buf);
  647. });
  648. }
  649. function decompress(input, compressionFormat, outputType = "string") {
  650. return __async(this, null, function* () {
  651. const byteArray = typeof input === "string" ? str2ab(input) : input;
  652. const decomp = new DecompressionStream(compressionFormat);
  653. const writer = decomp.writable.getWriter();
  654. writer.write(byteArray);
  655. writer.close();
  656. const buf = yield new Response(decomp.readable).arrayBuffer();
  657. return outputType === "arrayBuffer" ? buf : new TextDecoder().decode(buf);
  658. });
  659. }
  660. function ab2str(buf) {
  661. return getUnsafeWindow().btoa(
  662. new Uint8Array(buf).reduce((data, byte) => data + String.fromCharCode(byte), "")
  663. );
  664. }
  665. function str2ab(str) {
  666. return Uint8Array.from(getUnsafeWindow().atob(str), (c) => c.charCodeAt(0));
  667. }
  668. function computeHash(input, algorithm = "SHA-256") {
  669. return __async(this, null, function* () {
  670. let data;
  671. if (typeof input === "string") {
  672. const encoder = new TextEncoder();
  673. data = encoder.encode(input);
  674. } else
  675. data = input;
  676. const hashBuffer = yield crypto.subtle.digest(algorithm, data);
  677. const hashArray = Array.from(new Uint8Array(hashBuffer));
  678. const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
  679. return hashHex;
  680. });
  681. }
  682. function randomId(length = 16, radix = 16, enhancedEntropy = false) {
  683. if (enhancedEntropy) {
  684. const arr = new Uint8Array(length);
  685. crypto.getRandomValues(arr);
  686. return Array.from(
  687. arr,
  688. (v) => mapRange(v, 0, 255, 0, radix).toString(radix).substring(0, 1)
  689. ).join("");
  690. }
  691. return Array.from(
  692. { length },
  693. () => Math.floor(Math.random() * radix).toString(radix)
  694. ).join("");
  695. }
  696. // lib/SelectorObserver.ts
  697. var domLoaded = false;
  698. document.addEventListener("DOMContentLoaded", () => domLoaded = true);
  699. var SelectorObserver = class {
  700. constructor(baseElement, options = {}) {
  701. __publicField(this, "enabled", false);
  702. __publicField(this, "baseElement");
  703. __publicField(this, "observer");
  704. __publicField(this, "observerOptions");
  705. __publicField(this, "customOptions");
  706. __publicField(this, "listenerMap");
  707. this.baseElement = baseElement;
  708. this.listenerMap = /* @__PURE__ */ new Map();
  709. const _a = options, {
  710. defaultDebounce,
  711. defaultDebounceEdge,
  712. disableOnNoListeners,
  713. enableOnAddListener
  714. } = _a, observerOptions = __objRest(_a, [
  715. "defaultDebounce",
  716. "defaultDebounceEdge",
  717. "disableOnNoListeners",
  718. "enableOnAddListener"
  719. ]);
  720. this.observerOptions = __spreadValues({
  721. childList: true,
  722. subtree: true
  723. }, observerOptions);
  724. this.customOptions = {
  725. defaultDebounce: defaultDebounce != null ? defaultDebounce : 0,
  726. defaultDebounceEdge: defaultDebounceEdge != null ? defaultDebounceEdge : "rising",
  727. disableOnNoListeners: disableOnNoListeners != null ? disableOnNoListeners : false,
  728. enableOnAddListener: enableOnAddListener != null ? enableOnAddListener : true
  729. };
  730. if (typeof this.customOptions.checkInterval !== "number") {
  731. this.observer = new MutationObserver(() => this.checkAllSelectors());
  732. } else {
  733. this.checkAllSelectors();
  734. setInterval(() => this.checkAllSelectors(), this.customOptions.checkInterval);
  735. }
  736. }
  737. /** Call to check all selectors in the {@linkcode listenerMap} using {@linkcode checkSelector()} */
  738. checkAllSelectors() {
  739. if (!this.enabled || !domLoaded)
  740. return;
  741. for (const [selector, listeners] of this.listenerMap.entries())
  742. this.checkSelector(selector, listeners);
  743. }
  744. /** Checks if the element(s) with the given {@linkcode selector} exist in the DOM and calls the respective {@linkcode listeners} accordingly */
  745. checkSelector(selector, listeners) {
  746. var _a;
  747. if (!this.enabled)
  748. return;
  749. const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
  750. if (!baseElement)
  751. return;
  752. const all = listeners.some((listener) => listener.all);
  753. const one = listeners.some((listener) => !listener.all);
  754. const allElements = all ? baseElement.querySelectorAll(selector) : null;
  755. const oneElement = one ? baseElement.querySelector(selector) : null;
  756. for (const options of listeners) {
  757. if (options.all) {
  758. if (allElements && allElements.length > 0) {
  759. options.listener(allElements);
  760. if (!options.continuous)
  761. this.removeListener(selector, options);
  762. }
  763. } else {
  764. if (oneElement) {
  765. options.listener(oneElement);
  766. if (!options.continuous)
  767. this.removeListener(selector, options);
  768. }
  769. }
  770. if (((_a = this.listenerMap.get(selector)) == null ? void 0 : _a.length) === 0)
  771. this.listenerMap.delete(selector);
  772. if (this.listenerMap.size === 0 && this.customOptions.disableOnNoListeners)
  773. this.disable();
  774. }
  775. }
  776. /**
  777. * Starts observing the children of the base element for changes to the given {@linkcode selector} according to the set {@linkcode options}
  778. * @param selector The selector to observe
  779. * @param options Options for the selector observation
  780. * @param options.listener Gets called whenever the selector was found in the DOM
  781. * @param [options.all] Whether to use `querySelectorAll()` instead - default is false
  782. * @param [options.continuous] Whether to call the listener continuously instead of just once - default is false
  783. * @param [options.debounce] Whether to debounce the listener to reduce calls to `querySelector` or `querySelectorAll` - set undefined or <=0 to disable (default)
  784. * @returns Returns a function that can be called to remove this listener more easily
  785. */
  786. addListener(selector, options) {
  787. options = __spreadValues({
  788. all: false,
  789. continuous: false,
  790. debounce: 0
  791. }, options);
  792. if (options.debounce && options.debounce > 0 || this.customOptions.defaultDebounce && this.customOptions.defaultDebounce > 0) {
  793. options.listener = debounce(
  794. options.listener,
  795. options.debounce || this.customOptions.defaultDebounce,
  796. options.debounceEdge || this.customOptions.defaultDebounceEdge
  797. );
  798. }
  799. if (this.listenerMap.has(selector))
  800. this.listenerMap.get(selector).push(options);
  801. else
  802. this.listenerMap.set(selector, [options]);
  803. if (this.enabled === false && this.customOptions.enableOnAddListener)
  804. this.enable();
  805. this.checkSelector(selector, [options]);
  806. return () => this.removeListener(selector, options);
  807. }
  808. /** Disables the observation of the child elements */
  809. disable() {
  810. var _a;
  811. if (!this.enabled)
  812. return;
  813. this.enabled = false;
  814. (_a = this.observer) == null ? void 0 : _a.disconnect();
  815. }
  816. /**
  817. * Enables or reenables the observation of the child elements.
  818. * @param immediatelyCheckSelectors Whether to immediately check if all previously registered selectors exist (default is true)
  819. * @returns Returns true when the observation was enabled, false otherwise (e.g. when the base element wasn't found)
  820. */
  821. enable(immediatelyCheckSelectors = true) {
  822. var _a;
  823. const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
  824. if (this.enabled || !baseElement)
  825. return false;
  826. this.enabled = true;
  827. (_a = this.observer) == null ? void 0 : _a.observe(baseElement, this.observerOptions);
  828. if (immediatelyCheckSelectors)
  829. this.checkAllSelectors();
  830. return true;
  831. }
  832. /** Returns whether the observation of the child elements is currently enabled */
  833. isEnabled() {
  834. return this.enabled;
  835. }
  836. /** Removes all listeners that have been registered with {@linkcode addListener()} */
  837. clearListeners() {
  838. this.listenerMap.clear();
  839. }
  840. /**
  841. * Removes all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()}
  842. * @returns Returns true when all listeners for the associated selector were found and removed, false otherwise
  843. */
  844. removeAllListeners(selector) {
  845. return this.listenerMap.delete(selector);
  846. }
  847. /**
  848. * Removes a single listener for the given {@linkcode selector} and {@linkcode options} that has been registered with {@linkcode addListener()}
  849. * @returns Returns true when the listener was found and removed, false otherwise
  850. */
  851. removeListener(selector, options) {
  852. const listeners = this.listenerMap.get(selector);
  853. if (!listeners)
  854. return false;
  855. const index = listeners.indexOf(options);
  856. if (index > -1) {
  857. listeners.splice(index, 1);
  858. return true;
  859. }
  860. return false;
  861. }
  862. /** Returns all listeners that have been registered with {@linkcode addListener()} */
  863. getAllListeners() {
  864. return this.listenerMap;
  865. }
  866. /** Returns all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()} */
  867. getListeners(selector) {
  868. return this.listenerMap.get(selector);
  869. }
  870. };
  871. // lib/translation.ts
  872. var trans = {};
  873. var curLang;
  874. function tr(key, ...args) {
  875. var _a;
  876. if (!curLang)
  877. return key;
  878. const trText = (_a = trans[curLang]) == null ? void 0 : _a[key];
  879. if (!trText)
  880. return key;
  881. if (args.length > 0 && trText.match(/%\d/)) {
  882. return insertValues(trText, ...args);
  883. }
  884. return trText;
  885. }
  886. tr.addLanguage = (language, translations) => {
  887. trans[language] = translations;
  888. };
  889. tr.setLanguage = (language) => {
  890. curLang = language;
  891. };
  892. tr.getLanguage = () => {
  893. return curLang;
  894. };
  895. exports.DataStore = DataStore;
  896. exports.DataStoreSerializer = DataStoreSerializer;
  897. exports.SelectorObserver = SelectorObserver;
  898. exports.addGlobalStyle = addGlobalStyle;
  899. exports.addParent = addParent;
  900. exports.autoPlural = autoPlural;
  901. exports.clamp = clamp;
  902. exports.compress = compress;
  903. exports.computeHash = computeHash;
  904. exports.debounce = debounce;
  905. exports.decompress = decompress;
  906. exports.fetchAdvanced = fetchAdvanced;
  907. exports.getSiblingsFrame = getSiblingsFrame;
  908. exports.getUnsafeWindow = getUnsafeWindow;
  909. exports.insertValues = insertValues;
  910. exports.interceptEvent = interceptEvent;
  911. exports.interceptWindowEvent = interceptWindowEvent;
  912. exports.isScrollable = isScrollable;
  913. exports.mapRange = mapRange;
  914. exports.observeElementProp = observeElementProp;
  915. exports.openInNewTab = openInNewTab;
  916. exports.pauseFor = pauseFor;
  917. exports.preloadImages = preloadImages;
  918. exports.randRange = randRange;
  919. exports.randomId = randomId;
  920. exports.randomItem = randomItem;
  921. exports.randomItemIndex = randomItemIndex;
  922. exports.randomizeArray = randomizeArray;
  923. exports.takeRandomItem = takeRandomItem;
  924. exports.tr = tr;
  925. return exports;
  926. })({});
  927. (function(UserUtils,compareVersions,marked,Fuse){'use strict';function _interopNamespaceDefault(e){var n=Object.create(null);if(e){Object.keys(e).forEach(function(k){if(k!=='default'){var d=Object.getOwnPropertyDescriptor(e,k);Object.defineProperty(n,k,d.get?d:{enumerable:true,get:function(){return e[k]}});}})}n.default=e;return Object.freeze(n)}var UserUtils__namespace=/*#__PURE__*/_interopNamespaceDefault(UserUtils);var compareVersions__namespace=/*#__PURE__*/_interopNamespaceDefault(compareVersions);/******************************************************************************
  928. Copyright (c) Microsoft Corporation.
  929. Permission to use, copy, modify, and/or distribute this software for any
  930. purpose with or without fee is hereby granted.
  931. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
  932. REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
  933. AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
  934. INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
  935. LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
  936. OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
  937. PERFORMANCE OF THIS SOFTWARE.
  938. ***************************************************************************** */
  939. /* global Reflect, Promise, SuppressedError, Symbol */
  940. function __rest(s, e) {
  941. var t = {};
  942. for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
  943. t[p] = s[p];
  944. if (s != null && typeof Object.getOwnPropertySymbols === "function")
  945. for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
  946. if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
  947. t[p[i]] = s[p[i]];
  948. }
  949. return t;
  950. }
  951. function __awaiter(thisArg, _arguments, P, generator) {
  952. function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
  953. return new (P || (P = Promise))(function (resolve, reject) {
  954. function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
  955. function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
  956. function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
  957. step((generator = generator.apply(thisArg, _arguments || [])).next());
  958. });
  959. }
  960. function __values(o) {
  961. var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
  962. if (m) return m.call(o);
  963. if (o && typeof o.length === "number") return {
  964. next: function () {
  965. if (o && i >= o.length) o = void 0;
  966. return { value: o && o[i++], done: !o };
  967. }
  968. };
  969. throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
  970. }
  971. function __asyncValues(o) {
  972. if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
  973. var m = o[Symbol.asyncIterator], i;
  974. return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
  975. function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
  976. function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
  977. }
  978. typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
  979. var e = new Error(message);
  980. return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
  981. };// I know TS enums are impure but it doesn't really matter here, plus imo they are cooler than pure enums anyway
  982. var LogLevel;
  983. (function (LogLevel) {
  984. LogLevel[LogLevel["Debug"] = 0] = "Debug";
  985. LogLevel[LogLevel["Info"] = 1] = "Info";
  986. })(LogLevel || (LogLevel = {}));
  987. //#region plugins
  988. /**
  989. * Intents (permissions) BYTM has to grant your plugin for it to be able to access certain features.
  990. * TODO: this feature is unfinished, but you should still specify the intents your plugin needs.
  991. * Never request more permissions than you need, as this is a bad practice and can lead to your plugin being rejected.
  992. */
  993. var PluginIntent;
  994. (function (PluginIntent) {
  995. /** Plugin has access to hidden config values */
  996. PluginIntent[PluginIntent["HiddenConfigValues"] = 1] = "HiddenConfigValues";
  997. /** Plugin can write to the feature configuration */
  998. PluginIntent[PluginIntent["WriteFeatureConfig"] = 2] = "WriteFeatureConfig";
  999. /** Plugin can write to the lyrics cache */
  1000. PluginIntent[PluginIntent["WriteLyricsCache"] = 4] = "WriteLyricsCache";
  1001. /** Plugin can add new translations and overwrite existing ones */
  1002. PluginIntent[PluginIntent["WriteTranslations"] = 8] = "WriteTranslations";
  1003. /** Plugin can create modal dialogs */
  1004. PluginIntent[PluginIntent["CreateModalDialogs"] = 16] = "CreateModalDialogs";
  1005. /** Plugin can read and write auto-like data */
  1006. PluginIntent[PluginIntent["ReadAndWriteAutoLikeData"] = 32] = "ReadAndWriteAutoLikeData";
  1007. })(PluginIntent || (PluginIntent = {}));// these variables will have their values replaced by the build script:
  1008. const modeRaw = "development";
  1009. const branchRaw = "develop";
  1010. const hostRaw = "github";
  1011. const buildNumberRaw = "49e6b2fd";
  1012. /** The mode in which the script was built (production or development) */
  1013. const mode = (modeRaw.match(/^#{{.+}}$/) ? "production" : modeRaw);
  1014. /** The branch to use in various URLs that point to the GitHub repo */
  1015. const branch = (branchRaw.match(/^#{{.+}}$/) ? "main" : branchRaw);
  1016. /** Path to the GitHub repo */
  1017. const repo = "Sv443/BetterYTM";
  1018. /** Which host the userscript was installed from */
  1019. const host = (hostRaw.match(/^#{{.+}}$/) ? "github" : hostRaw);
  1020. /** The build number of the userscript */
  1021. const buildNumber = (buildNumberRaw.match(/^#{{.+}}$/) ? "BUILD_ERROR!" : buildNumberRaw); // asserted as generic string instead of literal
  1022. /** The URL search parameters at the earliest possible time */
  1023. const initialParams = new URL(location.href).searchParams;
  1024. /** Default compression format used throughout BYTM */
  1025. const compressionFormat = "deflate-raw";
  1026. /** Whether sessionStorage is available and working */
  1027. typeof (sessionStorage === null || sessionStorage === void 0 ? void 0 : sessionStorage.setItem) === "function"
  1028. && (() => {
  1029. try {
  1030. const key = `_bytm_test_${UserUtils.randomId(4, 36)}`;
  1031. sessionStorage.setItem(key, "test");
  1032. sessionStorage.removeItem(key);
  1033. return true;
  1034. }
  1035. catch (_a) {
  1036. return false;
  1037. }
  1038. })();
  1039. /**
  1040. * Fallback and initial value of how much info should be logged to the devtools console
  1041. * 0 = Debug (show everything) or 1 = Info (show only important stuff)
  1042. */
  1043. const defaultLogLevel = mode === "production" ? LogLevel.Info : LogLevel.Debug;
  1044. /** Info about the userscript, parsed from the userscript header (tools/post-build.js) */
  1045. const scriptInfo = {
  1046. name: GM.info.script.name,
  1047. version: GM.info.script.version,
  1048. namespace: GM.info.script.namespace,
  1049. };/** A fraction of this max value will be removed from the "last viewed" timestamp when adding penalized cache entries */
  1050. const maxViewedPenalty = 1000 * 60 * 60 * 24 * 5; // 5 days
  1051. /** A fraction of this max value will be removed from the "added" timestamp when adding penalized cache entries */
  1052. const maxAddedPenalty = 1000 * 60 * 60 * 24 * 15; // 15 days
  1053. let canCompress$2 = true;
  1054. const lyricsCacheMgr = new UserUtils.DataStore({
  1055. id: "bytm-lyrics-cache",
  1056. defaultData: {
  1057. cache: [],
  1058. },
  1059. formatVersion: 1,
  1060. encodeData: (data) => canCompress$2 ? UserUtils.compress(data, compressionFormat, "string") : data,
  1061. decodeData: (data) => canCompress$2 ? UserUtils.decompress(data, compressionFormat, "string") : data,
  1062. });
  1063. function initLyricsCache() {
  1064. return __awaiter(this, void 0, void 0, function* () {
  1065. canCompress$2 = yield compressionSupported();
  1066. const data = yield lyricsCacheMgr.loadData();
  1067. log(`Initialized lyrics cache with ${data.cache.length} entries:`, data);
  1068. emitInterface("bytm:lyricsCacheReady");
  1069. return data;
  1070. });
  1071. }
  1072. /**
  1073. * Returns the cache entry for the passed artist and song, or undefined if it doesn't exist yet
  1074. * {@linkcode artist} and {@linkcode song} need to be sanitized first!
  1075. * @param refreshEntry If true, the timestamp of the entry will be set to the current time
  1076. */
  1077. function getLyricsCacheEntry(artist, song, refreshEntry = true) {
  1078. const { cache } = lyricsCacheMgr.getData();
  1079. const entry = cache.find(e => e.artist === artist && e.song === song);
  1080. if (entry && Date.now() - (entry === null || entry === void 0 ? void 0 : entry.added) > getFeature("lyricsCacheTTL") * 1000 * 60 * 60 * 24) {
  1081. deleteLyricsCacheEntry(artist, song);
  1082. return undefined;
  1083. }
  1084. // refresh timestamp of the entry by mutating cache
  1085. if (entry && refreshEntry)
  1086. updateLyricsCacheEntry(artist, song);
  1087. return entry;
  1088. }
  1089. /** Updates the "last viewed" timestamp of the cache entry for the passed artist and song */
  1090. function updateLyricsCacheEntry(artist, song) {
  1091. const { cache } = lyricsCacheMgr.getData();
  1092. const idx = cache.findIndex(e => e.artist === artist && e.song === song);
  1093. if (idx !== -1) {
  1094. const newEntry = cache.splice(idx, 1)[0];
  1095. newEntry.viewed = Date.now();
  1096. log(`Updating cache entry for '${artist} - ${song}' to`, newEntry);
  1097. lyricsCacheMgr.setData({ cache: [newEntry, ...cache] });
  1098. }
  1099. }
  1100. /** Deletes the cache entry for the passed artist and song */
  1101. function deleteLyricsCacheEntry(artist, song) {
  1102. const { cache } = lyricsCacheMgr.getData();
  1103. const idx = cache.findIndex(e => e.artist === artist && e.song === song);
  1104. if (idx !== -1) {
  1105. cache.splice(idx, 1);
  1106. lyricsCacheMgr.setData({ cache });
  1107. }
  1108. }
  1109. /** Clears the lyrics cache locally and clears it in persistent storage */
  1110. function clearLyricsCache() {
  1111. emitInterface("bytm:lyricsCacheCleared");
  1112. return lyricsCacheMgr.setData({ cache: [] });
  1113. }
  1114. /** Returns the full lyrics cache array */
  1115. function getLyricsCache() {
  1116. return lyricsCacheMgr.getData().cache;
  1117. }
  1118. /**
  1119. * Adds the provided "best" (non-penalized) entry into the lyrics URL cache, synchronously to RAM and asynchronously to GM storage
  1120. * {@linkcode artist} and {@linkcode song} need to be sanitized first!
  1121. */
  1122. function addLyricsCacheEntryBest(artist, song, url) {
  1123. // refresh entry if it exists and don't overwrite / duplicate it
  1124. const cachedEntry = getLyricsCacheEntry(artist, song, true);
  1125. if (cachedEntry)
  1126. return;
  1127. const { cache } = lyricsCacheMgr.getData();
  1128. const entry = {
  1129. artist, song, url, viewed: Date.now(), added: Date.now(),
  1130. };
  1131. cache.push(entry);
  1132. cache.sort((a, b) => b.viewed - a.viewed);
  1133. // always keep the cache <= max size
  1134. cache.splice(getFeature("lyricsCacheMaxSize"));
  1135. log("Added cache entry for best result", artist, "-", song, "\n", entry);
  1136. emitInterface("bytm:lyricsCacheEntryAdded", { entry, type: "best" });
  1137. return lyricsCacheMgr.setData({ cache });
  1138. }
  1139. /**
  1140. * Adds the provided entry into the lyrics URL cache, synchronously to RAM and asynchronously to GM storage
  1141. * Also adds a penalty to the viewed timestamp and added timestamp to decrease entry's lifespan in cache
  1142. *
  1143. * ⚠️ `artist` and `song` need to be sanitized first!
  1144. * @param penaltyFr Fraction of the max bounds {@linkcode maxViewedPenalty} and {@linkcode maxAddedPenalty} to remove from the timestamp values - has to be between 0 and 1 - default is 0 (no penalty) - (0.25 = only penalized by a quarter of the max penalty)
  1145. */
  1146. function addLyricsCacheEntryPenalized(artist, song, url, penaltyFr = 0) {
  1147. // refresh entry if it exists and don't overwrite / duplicate it
  1148. const cachedEntry = getLyricsCacheEntry(artist, song, true);
  1149. if (cachedEntry)
  1150. return;
  1151. const { cache } = lyricsCacheMgr.getData();
  1152. penaltyFr = UserUtils.clamp(penaltyFr, 0, 1);
  1153. const viewedPenalty = maxViewedPenalty * penaltyFr;
  1154. const addedPenalty = maxAddedPenalty * penaltyFr;
  1155. const entry = {
  1156. artist,
  1157. song,
  1158. url,
  1159. viewed: Date.now() - viewedPenalty,
  1160. added: Date.now() - addedPenalty,
  1161. };
  1162. cache.push(entry);
  1163. cache.sort((a, b) => b.viewed - a.viewed);
  1164. // always keep the cache <= max size
  1165. cache.splice(getFeature("lyricsCacheMaxSize"));
  1166. log("Added penalized cache entry for", artist, "-", song, "with penalty fraction", penaltyFr, "\n", entry);
  1167. emitInterface("bytm:lyricsCacheEntryAdded", { entry, type: "penalized" });
  1168. return lyricsCacheMgr.setData({ cache });
  1169. }let createNanoEvents = () => ({
  1170. emit(event, ...args) {
  1171. for (
  1172. let i = 0,
  1173. callbacks = this.events[event] || [],
  1174. length = callbacks.length;
  1175. i < length;
  1176. i++
  1177. ) {
  1178. callbacks[i](...args);
  1179. }
  1180. },
  1181. events: {},
  1182. on(event, cb) {
  1183. (this.events[event] ||= []).push(cb);
  1184. return () => {
  1185. this.events[event] = this.events[event]?.filter(i => cb !== i);
  1186. }
  1187. }
  1188. });/** Class that can be extended or instantiated by itself to create an event emitter with helper methods and a strongly typed event map */
  1189. class NanoEmitter {
  1190. constructor(options = {}) {
  1191. Object.defineProperty(this, "events", {
  1192. enumerable: true,
  1193. configurable: true,
  1194. writable: true,
  1195. value: createNanoEvents()
  1196. });
  1197. Object.defineProperty(this, "eventUnsubscribes", {
  1198. enumerable: true,
  1199. configurable: true,
  1200. writable: true,
  1201. value: []
  1202. });
  1203. Object.defineProperty(this, "emitterOptions", {
  1204. enumerable: true,
  1205. configurable: true,
  1206. writable: true,
  1207. value: void 0
  1208. });
  1209. this.emitterOptions = Object.assign({ publicEmit: false }, options);
  1210. }
  1211. /** Subscribes to an event - returns a function that unsubscribes the event listener */
  1212. on(event, cb) {
  1213. // eslint-disable-next-line prefer-const
  1214. let unsub;
  1215. const unsubProxy = () => {
  1216. if (!unsub)
  1217. return;
  1218. unsub();
  1219. this.eventUnsubscribes = this.eventUnsubscribes.filter(u => u !== unsub);
  1220. };
  1221. unsub = this.events.on(event, cb);
  1222. this.eventUnsubscribes.push(unsub);
  1223. return unsubProxy;
  1224. }
  1225. /** Subscribes to an event and calls the callback or resolves the Promise only once */
  1226. once(event, cb) {
  1227. return new Promise((resolve) => {
  1228. // eslint-disable-next-line prefer-const
  1229. let unsub;
  1230. const onceProxy = ((...args) => {
  1231. unsub();
  1232. cb === null || cb === void 0 ? void 0 : cb(...args);
  1233. resolve(args);
  1234. });
  1235. unsub = this.on(event, onceProxy);
  1236. });
  1237. }
  1238. /** Emits an event on this instance - Needs `publicEmit` to be set to true in the constructor! */
  1239. emit(event, ...args) {
  1240. if (this.emitterOptions.publicEmit) {
  1241. this.events.emit(event, ...args);
  1242. return true;
  1243. }
  1244. return false;
  1245. }
  1246. /** Unsubscribes all event listeners */
  1247. unsubscribeAll() {
  1248. for (const unsub of this.eventUnsubscribes)
  1249. unsub();
  1250. this.eventUnsubscribes = [];
  1251. }
  1252. }/** Contains all translation keys of all initialized and loaded translations */
  1253. const allTrKeys = new Map();
  1254. /** Contains the identifiers of all initialized and loaded translation locales */
  1255. const initializedLocales = new Set();
  1256. /** Initializes the translations */
  1257. function initTranslations(locale) {
  1258. return __awaiter(this, void 0, void 0, function* () {
  1259. var _a, _b;
  1260. if (initializedLocales.has(locale))
  1261. return;
  1262. initializedLocales.add(locale);
  1263. try {
  1264. const transFile = yield fetchLocaleJson(locale);
  1265. let fallbackTrans = {};
  1266. if (getFeature("localeFallback"))
  1267. fallbackTrans = yield fetchLocaleJson("en_US");
  1268. // merge with base translations if specified
  1269. const baseTransFile = transFile.base ? yield fetchLocaleJson(transFile.base) : undefined;
  1270. const translations = Object.assign(Object.assign(Object.assign({}, ((_a = fallbackTrans === null || fallbackTrans === void 0 ? void 0 : fallbackTrans.translations) !== null && _a !== void 0 ? _a : {})), ((_b = baseTransFile === null || baseTransFile === void 0 ? void 0 : baseTransFile.translations) !== null && _b !== void 0 ? _b : {})), transFile.translations);
  1271. UserUtils.tr.addLanguage(locale, translations);
  1272. allTrKeys.set(locale, new Set(Object.keys(translations)));
  1273. info(`Loaded translations for locale '${locale}'`);
  1274. }
  1275. catch (err) {
  1276. const errStr = `Couldn't load translations for locale '${locale}'`;
  1277. error(errStr, err);
  1278. throw new Error(errStr);
  1279. }
  1280. });
  1281. }
  1282. /** Fetches the translation JSON file of the passed locale */
  1283. function fetchLocaleJson(locale) {
  1284. return __awaiter(this, void 0, void 0, function* () {
  1285. const url = yield getResourceUrl(`trans-${locale}`);
  1286. const res = yield UserUtils.fetchAdvanced(url);
  1287. if (res.status < 200 || res.status >= 300)
  1288. throw new Error(`Failed to fetch translation file for locale '${locale}'`);
  1289. return yield res.json();
  1290. });
  1291. }
  1292. /** Sets the current language for translations */
  1293. function setLocale(locale) {
  1294. UserUtils.tr.setLanguage(locale);
  1295. setGlobalProp("locale", locale);
  1296. emitInterface("bytm:setLocale", { locale });
  1297. }
  1298. /** Returns the currently set language */
  1299. function getLocale() {
  1300. return UserUtils.tr.getLanguage();
  1301. }
  1302. /** Returns whether the given translation key exists in the current locale */
  1303. function hasKey(key) {
  1304. return hasKeyFor(getLocale(), key);
  1305. }
  1306. /** Returns whether the given translation key exists in the given locale */
  1307. function hasKeyFor(locale, key) {
  1308. var _a, _b;
  1309. return (_b = (_a = allTrKeys.get(locale)) === null || _a === void 0 ? void 0 : _a.has(key)) !== null && _b !== void 0 ? _b : false;
  1310. }
  1311. /** Returns the translated string for the given key, after optionally inserting values */
  1312. function t(key, ...values) {
  1313. return UserUtils.tr(key, ...values);
  1314. }
  1315. /**
  1316. * Returns the translated string for the given key with an added pluralization identifier based on the passed `num`
  1317. * Tries to fall back to the non-pluralized syntax if no translation was found
  1318. */
  1319. function tp(key, num, ...values) {
  1320. if (typeof num !== "number")
  1321. num = num.length;
  1322. const plNum = num === 1 ? "1" : "n";
  1323. const trans = t(`${key}-${plNum}`, ...values);
  1324. if (trans === key)
  1325. return t(key, ...values);
  1326. return trans;
  1327. }// TODO: remove export as soon as config menu is migrated to use BytmDialog
  1328. /** ID of the last opened (top-most) dialog */
  1329. let currentDialogId = null;
  1330. /** IDs of all currently open dialogs, top-most first */
  1331. const openDialogs = [];
  1332. /** TODO: remove as soon as config menu is migrated to use BytmDialog */
  1333. const setCurrentDialogId = (id) => currentDialogId = id;
  1334. /** Creates and manages a modal dialog element */
  1335. class BytmDialog extends NanoEmitter {
  1336. constructor(options) {
  1337. super();
  1338. Object.defineProperty(this, "options", {
  1339. enumerable: true,
  1340. configurable: true,
  1341. writable: true,
  1342. value: void 0
  1343. });
  1344. Object.defineProperty(this, "id", {
  1345. enumerable: true,
  1346. configurable: true,
  1347. writable: true,
  1348. value: void 0
  1349. });
  1350. Object.defineProperty(this, "dialogOpen", {
  1351. enumerable: true,
  1352. configurable: true,
  1353. writable: true,
  1354. value: false
  1355. });
  1356. Object.defineProperty(this, "dialogMounted", {
  1357. enumerable: true,
  1358. configurable: true,
  1359. writable: true,
  1360. value: false
  1361. });
  1362. this.options = Object.assign({ closeOnBgClick: true, closeOnEscPress: true, closeBtnEnabled: true, destroyOnClose: false, unmountOnClose: true, smallHeader: false, verticalAlign: "center" }, options);
  1363. this.id = options.id;
  1364. }
  1365. //#region public
  1366. /** Call after DOMContentLoaded to pre-render the dialog and invisibly mount it in the DOM */
  1367. mount() {
  1368. return __awaiter(this, void 0, void 0, function* () {
  1369. if (this.dialogMounted)
  1370. return;
  1371. this.dialogMounted = true;
  1372. const bgElem = document.createElement("div");
  1373. bgElem.id = `bytm-${this.id}-dialog-bg`;
  1374. bgElem.classList.add("bytm-dialog-bg");
  1375. if (this.options.closeOnBgClick)
  1376. bgElem.ariaLabel = bgElem.title = t("close_menu_tooltip");
  1377. bgElem.style.visibility = "hidden";
  1378. bgElem.style.display = "none";
  1379. bgElem.inert = true;
  1380. bgElem.appendChild(yield this.getDialogContent());
  1381. document.body.appendChild(bgElem);
  1382. this.attachListeners(bgElem);
  1383. addStyle(`\
  1384. #bytm-${this.id}-dialog-bg {
  1385. --bytm-dialog-width-max: ${this.options.width}px;
  1386. --bytm-dialog-height-max: ${this.options.height}px;
  1387. }`, `dialog-${this.id}-vars`);
  1388. this.events.emit("render");
  1389. return bgElem;
  1390. });
  1391. }
  1392. /** Closes the dialog and clears all its contents (unmounts elements from the DOM) in preparation for a new rendering call */
  1393. unmount() {
  1394. var _a;
  1395. this.close();
  1396. this.dialogMounted = false;
  1397. const clearSelectors = [
  1398. `#bytm-${this.id}-dialog-bg`,
  1399. `#bytm-style-dialog-${this.id}`,
  1400. ];
  1401. for (const sel of clearSelectors) {
  1402. const elem = document.querySelector(sel);
  1403. (elem === null || elem === void 0 ? void 0 : elem.hasChildNodes()) && clearInner(elem);
  1404. (_a = document.querySelector(sel)) === null || _a === void 0 ? void 0 : _a.remove();
  1405. }
  1406. this.events.emit("clear");
  1407. }
  1408. /** Clears the DOM of the dialog and then renders it again */
  1409. remount() {
  1410. return __awaiter(this, void 0, void 0, function* () {
  1411. this.unmount();
  1412. yield this.mount();
  1413. });
  1414. }
  1415. /**
  1416. * Opens the dialog - also mounts it if it hasn't been mounted yet
  1417. * Prevents default action and immediate propagation of the passed event
  1418. */
  1419. open(e) {
  1420. return __awaiter(this, void 0, void 0, function* () {
  1421. var _a, _b, _c;
  1422. e === null || e === void 0 ? void 0 : e.preventDefault();
  1423. e === null || e === void 0 ? void 0 : e.stopImmediatePropagation();
  1424. if (this.isOpen())
  1425. return;
  1426. this.dialogOpen = true;
  1427. if (openDialogs.includes(this.id))
  1428. throw new Error(`A dialog with the same ID of '${this.id}' already exists and is open!`);
  1429. if (!this.isMounted())
  1430. yield this.mount();
  1431. const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`);
  1432. if (!dialogBg)
  1433. return warn(`Couldn't find background element for dialog with ID '${this.id}'`);
  1434. dialogBg.style.visibility = "visible";
  1435. dialogBg.style.display = "block";
  1436. dialogBg.inert = false;
  1437. currentDialogId = this.id;
  1438. openDialogs.unshift(this.id);
  1439. // make sure all other dialogs are inert
  1440. for (const dialogId of openDialogs) {
  1441. if (dialogId !== this.id) {
  1442. // special treatment for the old config menu, as always
  1443. if (dialogId === "cfg-menu")
  1444. (_a = document.querySelector("#bytm-cfg-menu-bg")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true");
  1445. else
  1446. (_b = document.querySelector(`#bytm-${dialogId}-dialog-bg`)) === null || _b === void 0 ? void 0 : _b.setAttribute("inert", "true");
  1447. }
  1448. }
  1449. // make sure body is inert and scroll is locked
  1450. document.body.classList.add("bytm-disable-scroll");
  1451. (_c = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _c === void 0 ? void 0 : _c.setAttribute("inert", "true");
  1452. this.events.emit("open");
  1453. emitInterface("bytm:dialogOpened", this);
  1454. emitInterface(`bytm:dialogOpened:${this.id}`, this);
  1455. return dialogBg;
  1456. });
  1457. }
  1458. /** Closes the dialog - prevents default action and immediate propagation of the passed event */
  1459. close(e) {
  1460. var _a, _b, _c, _d;
  1461. e === null || e === void 0 ? void 0 : e.preventDefault();
  1462. e === null || e === void 0 ? void 0 : e.stopImmediatePropagation();
  1463. if (!this.isOpen())
  1464. return;
  1465. this.dialogOpen = false;
  1466. const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`);
  1467. if (!dialogBg)
  1468. return warn(`Couldn't find background element for dialog with ID '${this.id}'`);
  1469. dialogBg.style.visibility = "hidden";
  1470. dialogBg.style.display = "none";
  1471. dialogBg.inert = true;
  1472. openDialogs.splice(openDialogs.indexOf(this.id), 1);
  1473. currentDialogId = (_a = openDialogs[0]) !== null && _a !== void 0 ? _a : null;
  1474. // make sure the new top-most dialog is not inert
  1475. if (currentDialogId) {
  1476. // special treatment for the old config menu, as always
  1477. if (currentDialogId === "cfg-menu")
  1478. (_b = document.querySelector("#bytm-cfg-menu-bg")) === null || _b === void 0 ? void 0 : _b.removeAttribute("inert");
  1479. else
  1480. (_c = document.querySelector(`#bytm-${currentDialogId}-dialog-bg`)) === null || _c === void 0 ? void 0 : _c.removeAttribute("inert");
  1481. }
  1482. // remove the scroll lock and inert attribute on the body if no dialogs are open
  1483. if (openDialogs.length === 0) {
  1484. document.body.classList.remove("bytm-disable-scroll");
  1485. (_d = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _d === void 0 ? void 0 : _d.removeAttribute("inert");
  1486. }
  1487. this.events.emit("close");
  1488. emitInterface("bytm:dialogClosed", this);
  1489. emitInterface(`bytm:dialogClosed:${this.id}`, this);
  1490. if (this.options.destroyOnClose)
  1491. this.destroy();
  1492. // don't destroy *and* unmount at the same time
  1493. else if (this.options.unmountOnClose)
  1494. this.unmount();
  1495. }
  1496. /** Returns true if the dialog is currently open */
  1497. isOpen() {
  1498. return this.dialogOpen;
  1499. }
  1500. /** Returns true if the dialog is currently mounted */
  1501. isMounted() {
  1502. return this.dialogMounted;
  1503. }
  1504. /** Clears the DOM of the dialog and removes all event listeners */
  1505. destroy() {
  1506. this.unmount();
  1507. this.events.emit("destroy");
  1508. this.unsubscribeAll();
  1509. }
  1510. //#region static
  1511. /** Returns the ID of the top-most dialog (the dialog that has been opened last) */
  1512. static getCurrentDialogId() {
  1513. return currentDialogId;
  1514. }
  1515. /** Returns the IDs of all currently open dialogs, top-most first */
  1516. static getOpenDialogs() {
  1517. return openDialogs;
  1518. }
  1519. //#region protected
  1520. /** Called once to attach all generic event listeners */
  1521. attachListeners(bgElem) {
  1522. if (this.options.closeOnBgClick) {
  1523. bgElem.addEventListener("click", (e) => {
  1524. var _a;
  1525. if (this.isOpen() && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === `bytm-${this.id}-dialog-bg`)
  1526. this.close(e);
  1527. });
  1528. }
  1529. if (this.options.closeOnEscPress) {
  1530. document.body.addEventListener("keydown", (e) => {
  1531. if (e.key === "Escape" && this.isOpen() && BytmDialog.getCurrentDialogId() === this.id)
  1532. this.close(e);
  1533. });
  1534. }
  1535. }
  1536. //#region private
  1537. /** Returns the dialog content element and all its children */
  1538. getDialogContent() {
  1539. return __awaiter(this, void 0, void 0, function* () {
  1540. var _a, _b, _c, _d;
  1541. const header = (_b = (_a = this.options).renderHeader) === null || _b === void 0 ? void 0 : _b.call(_a);
  1542. const footer = (_d = (_c = this.options).renderFooter) === null || _d === void 0 ? void 0 : _d.call(_c);
  1543. const dialogWrapperEl = document.createElement("div");
  1544. dialogWrapperEl.id = `bytm-${this.id}-dialog`;
  1545. dialogWrapperEl.classList.add("bytm-dialog");
  1546. dialogWrapperEl.ariaLabel = dialogWrapperEl.title = "";
  1547. dialogWrapperEl.role = "dialog";
  1548. dialogWrapperEl.setAttribute("aria-labelledby", `bytm-${this.id}-dialog-title`);
  1549. dialogWrapperEl.setAttribute("aria-describedby", `bytm-${this.id}-dialog-body`);
  1550. if (this.options.verticalAlign !== "center")
  1551. dialogWrapperEl.classList.add(`align-${this.options.verticalAlign}`);
  1552. //#region header
  1553. const headerWrapperEl = document.createElement("div");
  1554. headerWrapperEl.classList.add("bytm-dialog-header");
  1555. this.options.small && headerWrapperEl.classList.add("small");
  1556. if (header) {
  1557. const headerTitleWrapperEl = document.createElement("div");
  1558. headerTitleWrapperEl.id = `bytm-${this.id}-dialog-title`;
  1559. headerTitleWrapperEl.classList.add("bytm-dialog-title-wrapper");
  1560. headerTitleWrapperEl.role = "heading";
  1561. headerTitleWrapperEl.ariaLevel = "1";
  1562. headerTitleWrapperEl.appendChild(header instanceof Promise ? yield header : header);
  1563. headerWrapperEl.appendChild(headerTitleWrapperEl);
  1564. }
  1565. else {
  1566. // insert element to pad the header height
  1567. const padEl = document.createElement("div");
  1568. padEl.classList.add("bytm-dialog-header-pad", this.options.small ? "small" : "");
  1569. headerWrapperEl.appendChild(padEl);
  1570. }
  1571. if (this.options.closeBtnEnabled) {
  1572. const closeBtnEl = document.createElement("img");
  1573. closeBtnEl.classList.add("bytm-dialog-close");
  1574. this.options.small && closeBtnEl.classList.add("small");
  1575. closeBtnEl.src = yield getResourceUrl("img-close");
  1576. closeBtnEl.role = "button";
  1577. closeBtnEl.tabIndex = 0;
  1578. closeBtnEl.alt = closeBtnEl.title = closeBtnEl.ariaLabel = t("close_menu_tooltip");
  1579. onInteraction(closeBtnEl, () => this.close());
  1580. headerWrapperEl.appendChild(closeBtnEl);
  1581. }
  1582. dialogWrapperEl.appendChild(headerWrapperEl);
  1583. //#region body
  1584. const dialogBodyElem = document.createElement("div");
  1585. dialogBodyElem.id = `bytm-${this.id}-dialog-body`;
  1586. dialogBodyElem.classList.add("bytm-dialog-body");
  1587. this.options.small && dialogBodyElem.classList.add("small");
  1588. const body = this.options.renderBody();
  1589. dialogBodyElem.appendChild(body instanceof Promise ? yield body : body);
  1590. dialogWrapperEl.appendChild(dialogBodyElem);
  1591. //#region footer
  1592. if (footer) {
  1593. const footerWrapper = document.createElement("div");
  1594. footerWrapper.classList.add("bytm-dialog-footer-cont");
  1595. dialogWrapperEl.appendChild(footerWrapper);
  1596. footerWrapper.appendChild(footer instanceof Promise ? yield footer : footer);
  1597. }
  1598. return dialogWrapperEl;
  1599. });
  1600. }
  1601. }/**
  1602. * Creates an element with a ripple effect on click.
  1603. * @param clickEl If passed, this element will be modified to have the ripple effect. Otherwise, a new element will be created.
  1604. * @returns The passed element or the newly created element with the ripple effect.
  1605. */
  1606. function createRipple(rippleElement, properties) {
  1607. const props = Object.assign({ speed: "normal" }, properties);
  1608. const rippleEl = rippleElement !== null && rippleElement !== void 0 ? rippleElement : document.createElement("div");
  1609. rippleEl.classList.add("bytm-ripple", props.speed);
  1610. const updateRippleWidth = () => rippleEl.style.setProperty("--bytm-ripple-cont-width", `${rippleEl.clientWidth}px`);
  1611. rippleEl.addEventListener("mousedown", (e) => {
  1612. updateRippleWidth();
  1613. const x = e.clientX - rippleEl.getBoundingClientRect().left;
  1614. const y = e.clientY - rippleEl.getBoundingClientRect().top;
  1615. const rippleAreaEl = document.createElement("span");
  1616. rippleAreaEl.classList.add("bytm-ripple-area");
  1617. rippleAreaEl.style.left = `${Math.round(x)}px`;
  1618. rippleAreaEl.style.top = `${Math.round(y)}px`;
  1619. if (rippleEl.firstChild)
  1620. rippleEl.insertBefore(rippleAreaEl, rippleEl.firstChild);
  1621. else
  1622. rippleEl.appendChild(rippleAreaEl);
  1623. rippleAreaEl.addEventListener("animationend", () => rippleAreaEl.remove());
  1624. });
  1625. updateRippleWidth();
  1626. return rippleEl;
  1627. }/**
  1628. * Creates a generic, circular button element.
  1629. * If `href` is provided, the button will be an anchor element.
  1630. * If `onClick` is provided, the button will be a div element.
  1631. * Provide either `resourceName` or `src` to specify the icon inside the button.
  1632. */
  1633. function createCircularBtn(_a) {
  1634. return __awaiter(this, void 0, void 0, function* () {
  1635. var { title, ripple = true } = _a, rest = __rest(_a, ["title", "ripple"]);
  1636. let btnElem;
  1637. if ("href" in rest && rest.href) {
  1638. btnElem = document.createElement("a");
  1639. btnElem.href = rest.href;
  1640. btnElem.role = "button";
  1641. btnElem.target = "_blank";
  1642. btnElem.rel = "noopener noreferrer";
  1643. }
  1644. else if ("onClick" in rest && rest.onClick) {
  1645. btnElem = document.createElement("div");
  1646. rest.onClick && onInteraction(btnElem, rest.onClick);
  1647. }
  1648. else
  1649. throw new TypeError("Either 'href' or 'onClick' must be provided");
  1650. btnElem.classList.add("bytm-generic-btn");
  1651. btnElem.ariaLabel = btnElem.title = title;
  1652. btnElem.tabIndex = 0;
  1653. btnElem.role = "button";
  1654. const imgElem = document.createElement("img");
  1655. imgElem.classList.add("bytm-generic-btn-img");
  1656. imgElem.src = "src" in rest
  1657. ? rest.src instanceof Promise
  1658. ? yield rest.src
  1659. : rest.src
  1660. : yield getResourceUrl(rest.resourceName);
  1661. btnElem.appendChild(imgElem);
  1662. return ripple ? createRipple(btnElem) : btnElem;
  1663. });
  1664. }const interactionKeys = ["Enter", " ", "Space"];
  1665. /**
  1666. * Adds generic, accessible interaction listeners to the passed element.
  1667. * All listeners have the default behavior prevented and stop propagation (for keyboard events only as long as the captured key is valid).
  1668. * @param listenerOptions Provide a {@linkcode listenerOptions} object to configure the listeners
  1669. */
  1670. function onInteraction(elem, listener, listenerOptions) {
  1671. const _a = listenerOptions !== null && listenerOptions !== void 0 ? listenerOptions : {}, { preventDefault = true, stopPropagation = true } = _a, listenerOpts = __rest(_a, ["preventDefault", "stopPropagation"]);
  1672. const proxListener = (e) => {
  1673. if (e instanceof KeyboardEvent) {
  1674. if (interactionKeys.includes(e.key)) {
  1675. preventDefault && e.preventDefault();
  1676. stopPropagation && e.stopPropagation();
  1677. }
  1678. else
  1679. return;
  1680. }
  1681. else if (e instanceof MouseEvent) {
  1682. preventDefault && e.preventDefault();
  1683. stopPropagation && e.stopPropagation();
  1684. }
  1685. // clean up the other listener that isn't automatically removed if `once` is set
  1686. (listenerOpts === null || listenerOpts === void 0 ? void 0 : listenerOpts.once) && e.type === "keydown" && elem.removeEventListener("click", proxListener, listenerOpts);
  1687. (listenerOpts === null || listenerOpts === void 0 ? void 0 : listenerOpts.once) && e.type === "click" && elem.removeEventListener("keydown", proxListener, listenerOpts);
  1688. listener(e);
  1689. };
  1690. elem.addEventListener("click", proxListener, listenerOpts);
  1691. elem.addEventListener("keydown", proxListener, listenerOpts);
  1692. }//#region class
  1693. /** Generic dialog for exporting and importing any string of data */
  1694. class ExImDialog extends BytmDialog {
  1695. constructor(options) {
  1696. super(Object.assign({ renderHeader: () => ExImDialog.renderHeader(options), renderBody: () => ExImDialog.renderBody(options), renderFooter: undefined, closeOnBgClick: true, closeOnEscPress: true, closeBtnEnabled: true, unmountOnClose: true, small: true }, options));
  1697. }
  1698. //#region header
  1699. static renderHeader(opts) {
  1700. return __awaiter(this, void 0, void 0, function* () {
  1701. const headerEl = document.createElement("h2");
  1702. headerEl.classList.add("bytm-menu-title");
  1703. headerEl.role = "heading";
  1704. headerEl.ariaLevel = "1";
  1705. headerEl.tabIndex = 0;
  1706. headerEl.textContent = headerEl.ariaLabel = typeof opts.title === "function"
  1707. ? yield opts.title()
  1708. : opts.title;
  1709. return headerEl;
  1710. });
  1711. }
  1712. //#region body
  1713. static renderBody(opts) {
  1714. return __awaiter(this, void 0, void 0, function* () {
  1715. const panesCont = document.createElement("div");
  1716. panesCont.classList.add("bytm-exim-dialog-panes-cont");
  1717. //#region export
  1718. const exportPane = document.createElement("div");
  1719. exportPane.classList.add("bytm-exim-dialog-pane", "export");
  1720. {
  1721. const descEl = document.createElement("p");
  1722. descEl.classList.add("bytm-exim-dialog-desc");
  1723. descEl.role = "note";
  1724. descEl.tabIndex = 0;
  1725. descEl.textContent = descEl.ariaLabel = typeof opts.descExport === "function"
  1726. ? yield opts.descExport()
  1727. : opts.descExport;
  1728. const dataEl = document.createElement("textarea");
  1729. dataEl.classList.add("bytm-exim-dialog-data");
  1730. dataEl.readOnly = true;
  1731. dataEl.tabIndex = 0;
  1732. dataEl.value = t("click_to_reveal");
  1733. onInteraction(dataEl, () => __awaiter(this, void 0, void 0, function* () {
  1734. dataEl.value = typeof opts.exportData === "function" ? yield opts.exportData() : opts.exportData;
  1735. }));
  1736. const exportCenterBtnCont = document.createElement("div");
  1737. exportCenterBtnCont.classList.add("bytm-exim-dialog-center-btn-cont");
  1738. const copyBtn = createRipple(yield createLongBtn({
  1739. title: t("copy_to_clipboard"),
  1740. text: t("copy"),
  1741. resourceName: "icon-copy",
  1742. onClick(_a) {
  1743. return __awaiter(this, arguments, void 0, function* ({ shiftKey }) {
  1744. const copyData = shiftKey && opts.exportDataSpecial ? opts.exportDataSpecial : opts.exportData;
  1745. copyToClipboard(typeof copyData === "function" ? yield copyData() : copyData);
  1746. yield showToast({ message: t("copied_to_clipboard") });
  1747. });
  1748. },
  1749. }));
  1750. exportCenterBtnCont.appendChild(copyBtn);
  1751. exportPane.append(descEl, dataEl, exportCenterBtnCont);
  1752. }
  1753. //#region import
  1754. const importPane = document.createElement("div");
  1755. importPane.classList.add("bytm-exim-dialog-pane", "import");
  1756. {
  1757. const descEl = document.createElement("p");
  1758. descEl.classList.add("bytm-exim-dialog-desc");
  1759. descEl.role = "note";
  1760. descEl.tabIndex = 0;
  1761. descEl.textContent = descEl.ariaLabel = typeof opts.descImport === "function"
  1762. ? yield opts.descImport()
  1763. : opts.descImport;
  1764. const dataEl = document.createElement("textarea");
  1765. dataEl.classList.add("bytm-exim-dialog-data");
  1766. dataEl.tabIndex = 0;
  1767. const importCenterBtnCont = document.createElement("div");
  1768. importCenterBtnCont.classList.add("bytm-exim-dialog-center-btn-cont");
  1769. const importBtn = createRipple(yield createLongBtn({
  1770. title: t("start_import_tooltip"),
  1771. text: t("import"),
  1772. resourceName: "icon-upload",
  1773. onClick: () => opts.onImport(dataEl.value),
  1774. }));
  1775. importCenterBtnCont.appendChild(importBtn);
  1776. importPane.append(descEl, dataEl, importCenterBtnCont);
  1777. }
  1778. panesCont.append(exportPane, importPane);
  1779. return panesCont;
  1780. });
  1781. }
  1782. }/** EventEmitter instance that is used to detect various changes to the site and userscript */
  1783. const siteEvents = new NanoEmitter({
  1784. publicEmit: true,
  1785. });
  1786. let observers = [];
  1787. let lastWatchId = null;
  1788. let lastPathname = null;
  1789. let lastFullscreen;
  1790. /** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */
  1791. function initSiteEvents() {
  1792. return __awaiter(this, void 0, void 0, function* () {
  1793. try {
  1794. if (getDomain() === "ytm") {
  1795. //#region queue
  1796. // the queue container always exists so it doesn't need an extra init function
  1797. const queueObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => {
  1798. if (addedNodes.length > 0 || removedNodes.length > 0) {
  1799. info(`Detected queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  1800. emitSiteEvent("queueChanged", target);
  1801. }
  1802. });
  1803. // only observe added or removed elements
  1804. addSelectorListener("sidePanel", "#contents.ytmusic-player-queue", {
  1805. listener: (el) => {
  1806. queueObs.observe(el, {
  1807. childList: true,
  1808. });
  1809. },
  1810. });
  1811. const autoplayObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => {
  1812. if (addedNodes.length > 0 || removedNodes.length > 0) {
  1813. info(`Detected autoplay queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  1814. emitSiteEvent("autoplayQueueChanged", target);
  1815. }
  1816. });
  1817. addSelectorListener("sidePanel", "ytmusic-player-queue #automix-contents", {
  1818. listener: (el) => {
  1819. autoplayObs.observe(el, {
  1820. childList: true,
  1821. });
  1822. },
  1823. });
  1824. //#region player bar
  1825. let lastTitle = null;
  1826. addSelectorListener("playerBarInfo", "yt-formatted-string.title", {
  1827. continuous: true,
  1828. listener: (titleElem) => {
  1829. const oldTitle = lastTitle;
  1830. const newTitle = titleElem.textContent;
  1831. if (newTitle === lastTitle || !newTitle)
  1832. return;
  1833. lastTitle = newTitle;
  1834. info(`Detected song change - old title: "${oldTitle}" - new title: "${newTitle}"`);
  1835. emitSiteEvent("songTitleChanged", newTitle, oldTitle);
  1836. runIntervalChecks();
  1837. },
  1838. });
  1839. info("Successfully initialized SiteEvents observers");
  1840. observers = observers.concat([
  1841. queueObs,
  1842. autoplayObs,
  1843. ]);
  1844. //#region player
  1845. const playerFullscreenObs = new MutationObserver(([{ target }]) => {
  1846. var _a;
  1847. const isFullscreen = ((_a = target.getAttribute("player-ui-state")) === null || _a === void 0 ? void 0 : _a.toUpperCase()) === "FULLSCREEN";
  1848. if (lastFullscreen !== isFullscreen || typeof lastFullscreen === "undefined") {
  1849. emitSiteEvent("fullscreenToggled", isFullscreen);
  1850. lastFullscreen = isFullscreen;
  1851. }
  1852. });
  1853. if (getDomain() === "ytm") {
  1854. const registerFullScreenObs = () => addSelectorListener("mainPanel", "ytmusic-player#player", {
  1855. listener: (el) => {
  1856. playerFullscreenObs.observe(el, {
  1857. attributeFilter: ["player-ui-state"],
  1858. });
  1859. },
  1860. });
  1861. if (globserversReady)
  1862. registerFullScreenObs();
  1863. else
  1864. window.addEventListener("bytm:observersReady", registerFullScreenObs, { once: true });
  1865. }
  1866. }
  1867. window.addEventListener("bytm:ready", () => {
  1868. runIntervalChecks();
  1869. setInterval(runIntervalChecks, 100);
  1870. if (getDomain() === "ytm") {
  1871. addSelectorListener("mainPanel", "ytmusic-player #song-video #movie_player .ytp-title-text > a", {
  1872. listener(el) {
  1873. const urlRefObs = new MutationObserver(([{ target }]) => {
  1874. var _a;
  1875. if (!target || !((_a = target === null || target === void 0 ? void 0 : target.href) === null || _a === void 0 ? void 0 : _a.includes("/watch")))
  1876. return;
  1877. const watchId = new URL(target.href).searchParams.get("v");
  1878. checkWatchIdChange(watchId);
  1879. });
  1880. urlRefObs.observe(el, {
  1881. attributeFilter: ["href"],
  1882. });
  1883. }
  1884. });
  1885. }
  1886. if (getDomain() === "ytm") {
  1887. setInterval(checkWatchIdChange, 250);
  1888. checkWatchIdChange();
  1889. }
  1890. }, {
  1891. once: true,
  1892. });
  1893. }
  1894. catch (err) {
  1895. error("Couldn't initialize site event observers due to an error:\n", err);
  1896. }
  1897. });
  1898. }
  1899. let bytmReady = false;
  1900. window.addEventListener("bytm:ready", () => bytmReady = true, { once: true });
  1901. /** Emits a site event with the given key and arguments - if `bytm:ready` has not been emitted yet, all events will be queued until it is */
  1902. function emitSiteEvent(key, ...args) {
  1903. try {
  1904. if (!bytmReady) {
  1905. window.addEventListener("bytm:ready", () => {
  1906. bytmReady = true;
  1907. emitSiteEvent(key, ...args);
  1908. }, { once: true });
  1909. return;
  1910. }
  1911. siteEvents.emit(key, ...args);
  1912. emitInterface(`bytm:siteEvent:${key}`, args);
  1913. }
  1914. catch (err) {
  1915. error(`Couldn't emit site event "${key}" due to an error:\n`, err);
  1916. }
  1917. }
  1918. //#region other
  1919. /** Checks if the watch ID has changed and emits a `watchIdChanged` siteEvent if it has */
  1920. function checkWatchIdChange(newId) {
  1921. const newWatchId = newId !== null && newId !== void 0 ? newId : new URL(location.href).searchParams.get("v");
  1922. if (newWatchId && newWatchId !== lastWatchId) {
  1923. info(`Detected watch ID change - old ID: "${lastWatchId}" - new ID: "${newWatchId}"`);
  1924. emitSiteEvent("watchIdChanged", newWatchId, lastWatchId);
  1925. lastWatchId = newWatchId;
  1926. }
  1927. }
  1928. /** Periodically called to check for changes in the URL and emit associated siteEvents */
  1929. function runIntervalChecks() {
  1930. if (!lastWatchId)
  1931. checkWatchIdChange();
  1932. if (location.pathname !== lastPathname) {
  1933. emitSiteEvent("pathChanged", String(location.pathname), lastPathname);
  1934. lastPathname = String(location.pathname);
  1935. }
  1936. }let otherHotkeyInputActive = false;
  1937. const reservedKeys = ["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "Meta", "Tab", "Space", " "];
  1938. /** Creates a hotkey input element */
  1939. function createHotkeyInput({ initialValue, onChange, createTitle }) {
  1940. var _a;
  1941. const initialHotkey = initialValue;
  1942. let currentHotkey;
  1943. if (!createTitle)
  1944. createTitle = (value) => value;
  1945. const wrapperElem = document.createElement("div");
  1946. wrapperElem.classList.add("bytm-hotkey-wrapper");
  1947. const infoElem = document.createElement("span");
  1948. infoElem.classList.add("bytm-hotkey-info");
  1949. const inputElem = document.createElement("button");
  1950. inputElem.role = "button";
  1951. inputElem.classList.add("bytm-ftconf-input", "bytm-hotkey-input", "bytm-btn");
  1952. inputElem.dataset.state = "inactive";
  1953. inputElem.innerText = (_a = initialValue === null || initialValue === void 0 ? void 0 : initialValue.code) !== null && _a !== void 0 ? _a : t("hotkey_input_click_to_change");
  1954. inputElem.ariaLabel = inputElem.title = createTitle(hotkeyToString(initialValue));
  1955. const resetElem = document.createElement("span");
  1956. resetElem.classList.add("bytm-hotkey-reset", "bytm-link", "bytm-hidden");
  1957. resetElem.role = "button";
  1958. resetElem.tabIndex = 0;
  1959. resetElem.textContent = `(${t("reset")})`;
  1960. resetElem.ariaLabel = resetElem.title = t("hotkey_input_click_to_reset_tooltip");
  1961. const deactivate = () => {
  1962. var _a;
  1963. if (!otherHotkeyInputActive)
  1964. return;
  1965. emitSiteEvent("hotkeyInputActive", false);
  1966. otherHotkeyInputActive = false;
  1967. const curHk = currentHotkey !== null && currentHotkey !== void 0 ? currentHotkey : initialValue;
  1968. inputElem.innerText = (_a = curHk === null || curHk === void 0 ? void 0 : curHk.code) !== null && _a !== void 0 ? _a : t("hotkey_input_click_to_change");
  1969. inputElem.dataset.state = "inactive";
  1970. inputElem.ariaLabel = inputElem.title = createTitle(hotkeyToString(curHk));
  1971. infoElem.innerHTML = curHk ? getHotkeyInfoHtml(curHk) : "";
  1972. };
  1973. const activate = () => {
  1974. if (otherHotkeyInputActive)
  1975. return;
  1976. emitSiteEvent("hotkeyInputActive", true);
  1977. otherHotkeyInputActive = true;
  1978. inputElem.innerText = "< ... >";
  1979. inputElem.dataset.state = "active";
  1980. inputElem.ariaLabel = inputElem.title = t("hotkey_input_click_to_cancel_tooltip");
  1981. };
  1982. const resetClicked = (e) => {
  1983. e.preventDefault();
  1984. e.stopImmediatePropagation();
  1985. onChange(initialValue);
  1986. currentHotkey = initialValue;
  1987. deactivate();
  1988. inputElem.innerText = initialValue.code;
  1989. infoElem.innerHTML = getHotkeyInfoHtml(initialValue);
  1990. resetElem.classList.add("bytm-hidden");
  1991. };
  1992. onInteraction(resetElem, resetClicked);
  1993. if (initialValue)
  1994. infoElem.innerHTML = getHotkeyInfoHtml(initialValue);
  1995. let lastKeyDown;
  1996. document.addEventListener("keypress", (e) => {
  1997. if (inputElem.dataset.state === "inactive")
  1998. return;
  1999. if ((lastKeyDown === null || lastKeyDown === void 0 ? void 0 : lastKeyDown.code) === e.code && (lastKeyDown === null || lastKeyDown === void 0 ? void 0 : lastKeyDown.shift) === e.shiftKey && (lastKeyDown === null || lastKeyDown === void 0 ? void 0 : lastKeyDown.ctrl) === e.ctrlKey && (lastKeyDown === null || lastKeyDown === void 0 ? void 0 : lastKeyDown.alt) === e.altKey)
  2000. return;
  2001. e.preventDefault();
  2002. e.stopImmediatePropagation();
  2003. const hotkey = {
  2004. code: e.code,
  2005. shift: e.shiftKey,
  2006. ctrl: e.ctrlKey,
  2007. alt: e.altKey,
  2008. };
  2009. inputElem.innerText = hotkey.code;
  2010. inputElem.dataset.state = "inactive";
  2011. infoElem.innerHTML = getHotkeyInfoHtml(hotkey);
  2012. inputElem.ariaLabel = inputElem.title = t("hotkey_input_click_to_cancel_tooltip");
  2013. onChange(hotkey);
  2014. currentHotkey = hotkey;
  2015. });
  2016. document.addEventListener("keydown", (e) => {
  2017. if (reservedKeys.filter(k => k !== "Tab").includes(e.code))
  2018. return;
  2019. if (inputElem.dataset.state !== "active")
  2020. return;
  2021. if (e.code === "Tab" || e.code === " " || e.code === "Space" || e.code === "Escape" || e.code === "Enter") {
  2022. deactivate();
  2023. return;
  2024. }
  2025. if (["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight"].includes(e.code))
  2026. return;
  2027. e.preventDefault();
  2028. e.stopImmediatePropagation();
  2029. const hotkey = {
  2030. code: e.code,
  2031. shift: e.shiftKey,
  2032. ctrl: e.ctrlKey,
  2033. alt: e.altKey,
  2034. };
  2035. const keyChanged = (initialHotkey === null || initialHotkey === void 0 ? void 0 : initialHotkey.code) !== hotkey.code || (initialHotkey === null || initialHotkey === void 0 ? void 0 : initialHotkey.shift) !== hotkey.shift || (initialHotkey === null || initialHotkey === void 0 ? void 0 : initialHotkey.ctrl) !== hotkey.ctrl || (initialHotkey === null || initialHotkey === void 0 ? void 0 : initialHotkey.alt) !== hotkey.alt;
  2036. lastKeyDown = hotkey;
  2037. onChange(hotkey);
  2038. currentHotkey = hotkey;
  2039. if (keyChanged) {
  2040. deactivate();
  2041. resetElem.classList.remove("bytm-hidden");
  2042. }
  2043. else
  2044. resetElem.classList.add("bytm-hidden");
  2045. inputElem.innerText = hotkey.code;
  2046. inputElem.dataset.state = "inactive";
  2047. infoElem.innerHTML = getHotkeyInfoHtml(hotkey);
  2048. });
  2049. siteEvents.on("cfgMenuClosed", deactivate);
  2050. inputElem.addEventListener("click", () => {
  2051. if (inputElem.dataset.state === "inactive")
  2052. activate();
  2053. else
  2054. deactivate();
  2055. });
  2056. inputElem.addEventListener("keydown", (e) => {
  2057. if (reservedKeys.includes(e.code))
  2058. return;
  2059. if (inputElem.dataset.state === "inactive")
  2060. activate();
  2061. });
  2062. wrapperElem.appendChild(resetElem);
  2063. wrapperElem.appendChild(infoElem);
  2064. wrapperElem.appendChild(inputElem);
  2065. return wrapperElem;
  2066. }
  2067. /** Returns HTML for the hotkey modifier keys info element */
  2068. function getHotkeyInfoHtml(hotkey) {
  2069. const modifiers = [];
  2070. hotkey.ctrl && modifiers.push(`<kbd class="bytm-kbd">${t("hotkey_key_ctrl")}</kbd>`);
  2071. hotkey.shift && modifiers.push(`<kbd class="bytm-kbd">${t("hotkey_key_shift")}</kbd>`);
  2072. hotkey.alt && modifiers.push(`<kbd class="bytm-kbd">${getOS() === "mac" ? t("hotkey_key_mac_option") : t("hotkey_key_alt")}</kbd>`);
  2073. return `\
  2074. <div style="display: flex; align-items: center;">
  2075. <span>
  2076. ${modifiers.reduce((a, c) => `${a ? a + " " : ""}${c}`, "")}
  2077. </span>
  2078. <span style="padding: 0px 5px;">
  2079. ${modifiers.length > 0 ? "+" : ""}
  2080. </span>
  2081. </div>`;
  2082. }
  2083. /** Crude OS detection for keyboard layout purposes */
  2084. function getOS() {
  2085. if (navigator.userAgent.match(/mac(\s?os|intel)/i))
  2086. return "mac";
  2087. return "other";
  2088. }
  2089. /** Converts a hotkey object to a string */
  2090. function hotkeyToString(hotkey) {
  2091. if (!hotkey)
  2092. return t("hotkey_key_none");
  2093. let str = "";
  2094. if (hotkey.ctrl)
  2095. str += `${t("hotkey_key_ctrl")}+`;
  2096. if (hotkey.shift)
  2097. str += `${t("hotkey_key_shift")}+`;
  2098. if (hotkey.alt)
  2099. str += `${getOS() === "mac" ? t("hotkey_key_mac_option") : t("hotkey_key_alt")}+`;
  2100. str += hotkey.code;
  2101. return str;
  2102. }/**
  2103. * Creates a generic, circular, long button element with an icon and text.
  2104. * Has classes for the enabled and disabled states for easier styling.
  2105. * If `href` is provided, the button will be an anchor element.
  2106. * If `onClick` or `onToggle` is provided, the button will be a div element.
  2107. * Provide either `resourceName` or `src` to specify the icon inside the button.
  2108. */
  2109. function createLongBtn(_a) {
  2110. return __awaiter(this, void 0, void 0, function* () {
  2111. var _b;
  2112. var { title, text, iconPosition, ripple } = _a, rest = __rest(_a, ["title", "text", "iconPosition", "ripple"]);
  2113. if (["href", "onClick", "onToggle"].every((key) => !(key in rest)))
  2114. throw new TypeError("Either 'href', 'onClick' or 'onToggle' must be provided");
  2115. let btnElem;
  2116. if ("href" in rest && rest.href) {
  2117. btnElem = document.createElement("a");
  2118. btnElem.href = rest.href;
  2119. btnElem.role = "button";
  2120. btnElem.target = "_blank";
  2121. btnElem.rel = "noopener noreferrer";
  2122. }
  2123. else
  2124. btnElem = document.createElement("div");
  2125. if ("toggle" in rest && rest.toggle) {
  2126. btnElem.classList.add("bytm-toggle");
  2127. if ("toggleInitialState" in rest && rest.toggleInitialState)
  2128. btnElem.classList.add("toggled");
  2129. }
  2130. onInteraction(btnElem, (evt) => {
  2131. var _a;
  2132. if ("onClick" in rest)
  2133. rest.onClick(evt);
  2134. if ("toggle" in rest && rest.toggle && ((_a = rest.togglePredicate) !== null && _a !== void 0 ? _a : (() => true))(evt))
  2135. rest.onToggle(btnElem.classList.toggle("toggled"), evt);
  2136. });
  2137. btnElem.classList.add("bytm-generic-btn", "long");
  2138. btnElem.ariaLabel = btnElem.title = title;
  2139. btnElem.tabIndex = 0;
  2140. btnElem.role = "button";
  2141. const imgElem = document.createElement("src" in rest ? "img" : "div");
  2142. imgElem.classList.add("bytm-generic-btn-img", iconPosition !== null && iconPosition !== void 0 ? iconPosition : "left");
  2143. if ("src" in rest)
  2144. imgElem.src = rest.src;
  2145. else
  2146. imgElem.innerHTML = (_b = yield resourceAsString(rest.resourceName)) !== null && _b !== void 0 ? _b : "";
  2147. const txtElem = document.createElement("span");
  2148. txtElem.classList.add("bytm-generic-long-btn-txt", "bytm-no-select");
  2149. txtElem.textContent = txtElem.ariaLabel = text;
  2150. iconPosition === "left" || !iconPosition && btnElem.appendChild(imgElem);
  2151. btnElem.appendChild(txtElem);
  2152. iconPosition === "right" && btnElem.appendChild(imgElem);
  2153. return ripple ? createRipple(btnElem, { speed: "normal" }) : btnElem;
  2154. });
  2155. }let timeout;
  2156. /** Shows a toast message with an icon */
  2157. function showIconToast(_a) {
  2158. return __awaiter(this, void 0, void 0, function* () {
  2159. var { duration = getFeature("toastDuration"), position = "tr" } = _a, rest = __rest(_a, ["duration", "position"]);
  2160. if (duration <= 0)
  2161. return info("Toast duration is <= 0, so it won't be shown");
  2162. const toastWrapper = document.createElement("div");
  2163. toastWrapper.classList.add("bytm-toast-flex-wrapper");
  2164. if ("iconSrc" in rest) {
  2165. const toastIcon = document.createElement("img");
  2166. toastIcon.classList.add("bytm-toast-icon", "img");
  2167. toastIcon.src = rest.iconSrc instanceof Promise ? yield rest.iconSrc : rest.iconSrc;
  2168. toastWrapper.appendChild(toastIcon);
  2169. }
  2170. else {
  2171. const toastIcon = document.createElement("div");
  2172. toastIcon.classList.add("bytm-toast-icon");
  2173. const iconHtml = yield resourceAsString(rest.icon);
  2174. if (iconHtml)
  2175. toastIcon.innerHTML = iconHtml;
  2176. toastWrapper.appendChild(toastIcon);
  2177. }
  2178. const toastMessage = document.createElement("div");
  2179. toastMessage.classList.add("bytm-toast-message");
  2180. if ("message" in rest)
  2181. toastMessage.textContent = rest.message;
  2182. else
  2183. toastMessage.appendChild(rest.element);
  2184. toastWrapper.appendChild(toastMessage);
  2185. yield showToast({
  2186. duration,
  2187. position,
  2188. element: toastWrapper,
  2189. title: "message" in rest ? rest.message : rest.title,
  2190. });
  2191. });
  2192. }
  2193. /** Shows a toast message in the top right corner of the screen by default */
  2194. function showToast(_a) {
  2195. return __awaiter(this, void 0, void 0, function* () {
  2196. var { duration = getFeature("toastDuration"), position = "tr" } = _a, rest = __rest(_a, ["duration", "position"]);
  2197. if (duration <= 0)
  2198. return info("Toast duration is <= 0, so it won't be shown");
  2199. const toastEl = document.querySelector("#bytm-toast");
  2200. if (toastEl)
  2201. yield closeToast();
  2202. const toastElem = document.createElement("div");
  2203. toastElem.id = "bytm-toast";
  2204. toastElem.role = "alert";
  2205. toastElem.ariaLive = "polite";
  2206. toastElem.ariaAtomic = "true";
  2207. toastElem.addEventListener("click", () => __awaiter(this, void 0, void 0, function* () { return yield closeToast(); }), { once: true });
  2208. if ("message" in rest)
  2209. toastElem.title = toastElem.ariaLabel = toastElem.textContent = rest.message;
  2210. else {
  2211. toastElem.appendChild(rest.element);
  2212. toastElem.title = toastElem.ariaLabel = rest.title;
  2213. }
  2214. document.body.appendChild(toastElem);
  2215. yield UserUtils.pauseFor(100);
  2216. toastElem.classList.add("visible", `pos-${position.toLowerCase()}`);
  2217. if (duration < Number.POSITIVE_INFINITY)
  2218. timeout = setTimeout(() => __awaiter(this, void 0, void 0, function* () { return yield closeToast(); }), duration * 1000);
  2219. });
  2220. }
  2221. /** Closes the currently open toast */
  2222. function closeToast() {
  2223. return __awaiter(this, void 0, void 0, function* () {
  2224. timeout && clearTimeout(timeout);
  2225. const toastEls = document.querySelectorAll("#bytm-toast");
  2226. if (toastEls.length === 0)
  2227. return;
  2228. yield Promise.allSettled(Array.from(toastEls).map((toastEl) => __awaiter(this, void 0, void 0, function* () {
  2229. toastEl.classList.remove("visible");
  2230. yield UserUtils.pauseFor(300);
  2231. toastEl.remove();
  2232. yield UserUtils.pauseFor(100);
  2233. })));
  2234. });
  2235. }/** Creates a simple toggle element */
  2236. function createToggleInput(_a) {
  2237. return __awaiter(this, arguments, void 0, function* ({ onChange, initialValue = false, id = UserUtils.randomId(8, 26), labelPos = "left", }) {
  2238. const wrapperEl = document.createElement("div");
  2239. wrapperEl.classList.add("bytm-toggle-input-wrapper", "bytm-no-select");
  2240. wrapperEl.role = "switch";
  2241. wrapperEl.tabIndex = 0;
  2242. const labelEl = labelPos !== "off" && document.createElement("label");
  2243. if (labelEl) {
  2244. labelEl.classList.add("bytm-toggle-input-label");
  2245. labelEl.textContent = t(`toggled_${initialValue ? "on" : "off"}`);
  2246. if (id)
  2247. labelEl.htmlFor = `bytm-toggle-input-${id}`;
  2248. }
  2249. const toggleWrapperEl = document.createElement("div");
  2250. toggleWrapperEl.classList.add("bytm-toggle-input");
  2251. toggleWrapperEl.tabIndex = -1;
  2252. const toggleEl = document.createElement("input");
  2253. toggleEl.type = "checkbox";
  2254. toggleEl.checked = initialValue;
  2255. toggleEl.dataset.toggled = String(Boolean(initialValue));
  2256. toggleEl.tabIndex = -1;
  2257. if (id)
  2258. toggleEl.id = `bytm-toggle-input-${id}`;
  2259. const toggleKnobEl = document.createElement("div");
  2260. toggleKnobEl.classList.add("bytm-toggle-input-knob");
  2261. toggleKnobEl.innerHTML = "&nbsp;";
  2262. const toggleElClicked = (e) => {
  2263. e.preventDefault();
  2264. e.stopPropagation();
  2265. onChange(toggleEl.checked);
  2266. toggleEl.dataset.toggled = String(Boolean(toggleEl.checked));
  2267. if (labelEl)
  2268. labelEl.textContent = t(`toggled_${toggleEl.checked ? "on" : "off"}`);
  2269. wrapperEl.ariaValueText = t(`toggled_${toggleEl.checked ? "on" : "off"}`);
  2270. };
  2271. toggleEl.addEventListener("change", toggleElClicked);
  2272. wrapperEl.addEventListener("keydown", (e) => {
  2273. if (["Space", " ", "Enter"].includes(e.code)) {
  2274. toggleEl.checked = !toggleEl.checked;
  2275. toggleElClicked(e);
  2276. }
  2277. });
  2278. toggleEl.appendChild(toggleKnobEl);
  2279. toggleWrapperEl.appendChild(toggleEl);
  2280. labelEl && labelPos === "left" && wrapperEl.appendChild(labelEl);
  2281. wrapperEl.appendChild(toggleWrapperEl);
  2282. labelEl && labelPos === "right" && wrapperEl.appendChild(labelEl);
  2283. return wrapperEl;
  2284. });
  2285. }let autoLikeDialog = null;
  2286. let autoLikeImExDialog = null;
  2287. /** Creates and/or returns the import dialog */
  2288. function getAutoLikeDialog() {
  2289. return __awaiter(this, void 0, void 0, function* () {
  2290. if (!autoLikeDialog) {
  2291. yield initAutoLikeStore();
  2292. autoLikeDialog = new BytmDialog({
  2293. id: "auto-like-channels",
  2294. width: 700,
  2295. height: 1000,
  2296. closeBtnEnabled: true,
  2297. closeOnBgClick: true,
  2298. closeOnEscPress: true,
  2299. destroyOnClose: true,
  2300. small: true,
  2301. verticalAlign: "top",
  2302. renderHeader: renderHeader$4,
  2303. renderBody: renderBody$4,
  2304. renderFooter: renderFooter$1,
  2305. });
  2306. siteEvents.on("autoLikeChannelsUpdated", () => __awaiter(this, void 0, void 0, function* () {
  2307. if (autoLikeImExDialog === null || autoLikeImExDialog === void 0 ? void 0 : autoLikeImExDialog.isOpen())
  2308. autoLikeImExDialog.unmount();
  2309. if (autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.isOpen()) {
  2310. autoLikeDialog.unmount();
  2311. yield autoLikeDialog.open();
  2312. log("Auto-like channels updated, refreshed dialog");
  2313. }
  2314. }));
  2315. }
  2316. if (!autoLikeImExDialog) {
  2317. autoLikeImExDialog = new ExImDialog({
  2318. id: "auto-like-channels-export-import",
  2319. width: 800,
  2320. height: 600,
  2321. // try to compress the data if possible
  2322. exportData: () => __awaiter(this, void 0, void 0, function* () {
  2323. return (yield compressionSupported())
  2324. ? yield UserUtils.compress(JSON.stringify(autoLikeStore.getData()), compressionFormat, "string")
  2325. : JSON.stringify(autoLikeStore.getData());
  2326. }),
  2327. // copy plain when shift-clicking the copy button
  2328. exportDataSpecial: () => JSON.stringify(autoLikeStore.getData()),
  2329. onImport(data) {
  2330. return __awaiter(this, void 0, void 0, function* () {
  2331. try {
  2332. const parsed = yield tryToDecompressAndParse(data);
  2333. log("Trying to import auto-like data:", parsed);
  2334. if (!parsed || typeof parsed !== "object")
  2335. return alert(t("import_error_invalid"));
  2336. if (!parsed.channels || typeof parsed.channels !== "object" || Object.keys(parsed.channels).length === 0)
  2337. return alert(t("import_error_no_data"));
  2338. yield autoLikeStore.setData(parsed);
  2339. emitSiteEvent("autoLikeChannelsUpdated");
  2340. showToast({ message: t("import_success") });
  2341. autoLikeImExDialog === null || autoLikeImExDialog === void 0 ? void 0 : autoLikeImExDialog.unmount();
  2342. }
  2343. catch (err) {
  2344. error("Couldn't import auto-like channels data:", err);
  2345. }
  2346. });
  2347. },
  2348. title: () => t("auto_like_export_import_title"),
  2349. descImport: () => t("auto_like_import_desc"),
  2350. descExport: () => t("auto_like_export_desc"),
  2351. });
  2352. }
  2353. return autoLikeDialog;
  2354. });
  2355. }
  2356. //#region header
  2357. function renderHeader$4() {
  2358. return __awaiter(this, void 0, void 0, function* () {
  2359. const headerEl = document.createElement("h2");
  2360. headerEl.classList.add("bytm-dialog-title");
  2361. headerEl.role = "heading";
  2362. headerEl.ariaLevel = "1";
  2363. headerEl.tabIndex = 0;
  2364. headerEl.textContent = headerEl.ariaLabel = t("auto_like_channels_dialog_title");
  2365. return headerEl;
  2366. });
  2367. }
  2368. //#region body
  2369. function renderBody$4() {
  2370. return __awaiter(this, void 0, void 0, function* () {
  2371. const contElem = document.createElement("div");
  2372. const descriptionEl = document.createElement("p");
  2373. descriptionEl.classList.add("bytm-auto-like-channels-desc");
  2374. descriptionEl.textContent = t("auto_like_channels_dialog_desc");
  2375. descriptionEl.tabIndex = 0;
  2376. contElem.appendChild(descriptionEl);
  2377. const searchCont = document.createElement("div");
  2378. searchCont.classList.add("bytm-auto-like-channels-search-cont");
  2379. contElem.appendChild(searchCont);
  2380. const searchbarEl = document.createElement("input");
  2381. searchbarEl.classList.add("bytm-auto-like-channels-searchbar");
  2382. searchbarEl.placeholder = t("search_placeholder");
  2383. searchbarEl.type = "search";
  2384. searchbarEl.tabIndex = 0;
  2385. searchbarEl.addEventListener("input", () => {
  2386. var _a, _b, _c;
  2387. const searchVal = searchbarEl.value.trim().toLowerCase();
  2388. const rows = document.querySelectorAll(".bytm-auto-like-channel-row");
  2389. for (const row of rows) {
  2390. const name = (_c = (_b = (_a = row.querySelector(".bytm-auto-like-channel-name")) === null || _a === void 0 ? void 0 : _a.textContent) === null || _b === void 0 ? void 0 : _b.trim().toLowerCase()) !== null && _c !== void 0 ? _c : "";
  2391. row.classList.toggle("hidden", !name.includes(searchVal));
  2392. }
  2393. });
  2394. searchCont.appendChild(searchbarEl);
  2395. const searchClearEl = document.createElement("button");
  2396. searchClearEl.classList.add("bytm-auto-like-channels-search-clear");
  2397. searchClearEl.title = searchClearEl.ariaLabel = t("search_clear");
  2398. searchClearEl.tabIndex = 0;
  2399. searchClearEl.innerText = "×";
  2400. onInteraction(searchClearEl, () => {
  2401. searchbarEl.value = "";
  2402. searchbarEl.dispatchEvent(new Event("input"));
  2403. });
  2404. searchCont.appendChild(searchClearEl);
  2405. const channelListCont = document.createElement("div");
  2406. channelListCont.id = "bytm-auto-like-channels-list";
  2407. const removeChannel = (id) => autoLikeStore.setData({
  2408. channels: autoLikeStore.getData().channels.filter((ch) => ch.id !== id),
  2409. });
  2410. const setChannelEnabled = (id, enabled) => UserUtils.debounce(() => autoLikeStore.setData({
  2411. channels: autoLikeStore.getData().channels
  2412. .map((ch) => ch.id === id ? Object.assign(Object.assign({}, ch), { enabled }) : ch),
  2413. }), 250, "rising");
  2414. const sortedChannels = autoLikeStore
  2415. .getData().channels
  2416. .sort((a, b) => a.name.localeCompare(b.name));
  2417. for (const { name: chanName, id: chanId, enabled } of sortedChannels) {
  2418. const rowElem = document.createElement("div");
  2419. rowElem.classList.add("bytm-auto-like-channel-row");
  2420. const leftCont = document.createElement("div");
  2421. leftCont.classList.add("bytm-auto-like-channel-row-left-cont");
  2422. const nameLabelEl = document.createElement("label");
  2423. nameLabelEl.ariaLabel = nameLabelEl.title = chanName;
  2424. nameLabelEl.htmlFor = `bytm-auto-like-channel-list-toggle-${chanId}`;
  2425. nameLabelEl.classList.add("bytm-auto-like-channel-name-label");
  2426. const nameElem = document.createElement("a");
  2427. nameElem.classList.add("bytm-auto-like-channel-name", "bytm-link");
  2428. nameElem.ariaLabel = nameElem.textContent = chanName;
  2429. nameElem.href = (!chanId.startsWith("@") && getDomain() === "ytm")
  2430. ? `https://music.youtube.com/channel/${chanId}`
  2431. : `https://youtube.com/${chanId.startsWith("@") ? chanId : `channel/${chanId}`}`;
  2432. nameElem.target = "_blank";
  2433. nameElem.rel = "noopener noreferrer";
  2434. nameElem.tabIndex = 0;
  2435. const idElem = document.createElement("span");
  2436. idElem.classList.add("bytm-auto-like-channel-id");
  2437. idElem.textContent = idElem.title = chanId;
  2438. nameLabelEl.appendChild(nameElem);
  2439. nameLabelEl.appendChild(idElem);
  2440. const toggleElem = yield createToggleInput({
  2441. id: `bytm-auto-like-channel-list-toggle-${chanId}`,
  2442. labelPos: "off",
  2443. initialValue: enabled,
  2444. onChange: (en) => setChannelEnabled(chanId, en),
  2445. });
  2446. toggleElem.classList.add("bytm-auto-like-channel-toggle");
  2447. const btnCont = document.createElement("div");
  2448. btnCont.classList.add("bytm-auto-like-channel-row-btn-cont");
  2449. const editBtn = yield createCircularBtn({
  2450. resourceName: "icon-edit",
  2451. title: t("edit_entry"),
  2452. onClick() {
  2453. return __awaiter(this, void 0, void 0, function* () {
  2454. var _a, _b, _c;
  2455. const newNamePr = (_a = prompt(t("auto_like_channel_edit_name_prompt"), chanName)) === null || _a === void 0 ? void 0 : _a.trim();
  2456. if (!newNamePr || newNamePr.length === 0)
  2457. return;
  2458. const newName = newNamePr.length > 0 ? newNamePr : chanName;
  2459. const newIdPr = (_b = prompt(t("auto_like_channel_edit_id_prompt"), chanId)) === null || _b === void 0 ? void 0 : _b.trim();
  2460. if (!newIdPr || newIdPr.length === 0)
  2461. return;
  2462. const newId = newIdPr.length > 0 ? (_c = getChannelIdFromPrompt(newIdPr)) !== null && _c !== void 0 ? _c : chanId : chanId;
  2463. yield autoLikeStore.setData({
  2464. channels: autoLikeStore.getData().channels
  2465. .map((ch) => ch.id === chanId ? Object.assign(Object.assign({}, ch), { name: newName, id: newId }) : ch),
  2466. });
  2467. emitSiteEvent("autoLikeChannelsUpdated");
  2468. });
  2469. },
  2470. });
  2471. btnCont.appendChild(editBtn);
  2472. const removeBtn = yield createCircularBtn({
  2473. resourceName: "icon-delete",
  2474. title: t("remove_entry"),
  2475. onClick() {
  2476. return __awaiter(this, void 0, void 0, function* () {
  2477. yield removeChannel(chanId);
  2478. rowElem.remove();
  2479. });
  2480. },
  2481. });
  2482. btnCont.appendChild(removeBtn);
  2483. leftCont.appendChild(toggleElem);
  2484. leftCont.appendChild(nameLabelEl);
  2485. rowElem.appendChild(leftCont);
  2486. rowElem.appendChild(btnCont);
  2487. channelListCont.appendChild(rowElem);
  2488. }
  2489. contElem.appendChild(channelListCont);
  2490. return contElem;
  2491. });
  2492. }
  2493. //#region footer
  2494. function renderFooter$1() {
  2495. const wrapperEl = document.createElement("div");
  2496. wrapperEl.classList.add("bytm-auto-like-channels-footer-wrapper");
  2497. const addNewBtnElem = document.createElement("button");
  2498. addNewBtnElem.classList.add("bytm-btn");
  2499. addNewBtnElem.textContent = t("new_entry");
  2500. addNewBtnElem.ariaLabel = addNewBtnElem.title = t("new_entry_tooltip");
  2501. wrapperEl.appendChild(addNewBtnElem);
  2502. const importExportBtnElem = document.createElement("button");
  2503. importExportBtnElem.classList.add("bytm-btn");
  2504. importExportBtnElem.textContent = t("export_import");
  2505. importExportBtnElem.ariaLabel = importExportBtnElem.title = t("auto_like_export_or_import_tooltip");
  2506. wrapperEl.appendChild(importExportBtnElem);
  2507. onInteraction(addNewBtnElem, addAutoLikeEntryPrompts);
  2508. onInteraction(importExportBtnElem, openImportExportAutoLikeChannelsDialog);
  2509. return wrapperEl;
  2510. }
  2511. function openImportExportAutoLikeChannelsDialog() {
  2512. return __awaiter(this, void 0, void 0, function* () {
  2513. yield (autoLikeImExDialog === null || autoLikeImExDialog === void 0 ? void 0 : autoLikeImExDialog.open());
  2514. });
  2515. }
  2516. //#region add prompt
  2517. function addAutoLikeEntryPrompts() {
  2518. return __awaiter(this, void 0, void 0, function* () {
  2519. var _a, _b, _c;
  2520. yield autoLikeStore.loadData();
  2521. const idPrompt = (_a = prompt(t("add_auto_like_channel_id_prompt"))) === null || _a === void 0 ? void 0 : _a.trim();
  2522. if (!idPrompt)
  2523. return;
  2524. const id = (_b = parseChannelIdFromUrl(idPrompt)) !== null && _b !== void 0 ? _b : (idPrompt.trim().startsWith("@") ? idPrompt.trim() : null);
  2525. if (!id || id.length <= 0)
  2526. return alert(t("add_auto_like_channel_invalid_id"));
  2527. let overwriteName = false;
  2528. if (autoLikeStore.getData().channels.some((ch) => ch.id === id)) {
  2529. if (!confirm(t("add_auto_like_channel_already_exists_prompt_new_name")))
  2530. return;
  2531. overwriteName = true;
  2532. }
  2533. const name = (_c = prompt(t("add_auto_like_channel_name_prompt"))) === null || _c === void 0 ? void 0 : _c.trim();
  2534. if (!name || name.length === 0)
  2535. return;
  2536. yield autoLikeStore.setData(overwriteName
  2537. ? {
  2538. channels: autoLikeStore.getData().channels
  2539. .map((ch) => ch.id === id ? Object.assign(Object.assign({}, ch), { name }) : ch),
  2540. }
  2541. : {
  2542. channels: [
  2543. ...autoLikeStore.getData().channels,
  2544. { id, name, enabled: true },
  2545. ],
  2546. });
  2547. emitSiteEvent("autoLikeChannelsUpdated");
  2548. const unsub = autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.on("clear", () => __awaiter(this, void 0, void 0, function* () {
  2549. unsub === null || unsub === void 0 ? void 0 : unsub();
  2550. yield (autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.open());
  2551. }));
  2552. autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.unmount();
  2553. });
  2554. }
  2555. function getChannelIdFromPrompt(promptStr) {
  2556. const isId = promptStr.match(/^@?.+$/);
  2557. const isUrl = promptStr.match(/^(?:https?:\/\/)?(?:www\.)?(?:music\.)?youtube\.com\/(?:channel\/|@)([a-zA-Z0-9_-]+)/);
  2558. const id = ((isId === null || isId === void 0 ? void 0 : isId[0]) || (isUrl === null || isUrl === void 0 ? void 0 : isUrl[1]) || "").trim();
  2559. return id.length > 0 ? id : null;
  2560. }let changelogDialog = null;
  2561. /** Creates and/or returns the changelog dialog */
  2562. function getChangelogDialog() {
  2563. return __awaiter(this, void 0, void 0, function* () {
  2564. if (!changelogDialog) {
  2565. changelogDialog = new BytmDialog({
  2566. id: "changelog",
  2567. width: 1000,
  2568. height: 800,
  2569. closeBtnEnabled: true,
  2570. closeOnBgClick: true,
  2571. closeOnEscPress: true,
  2572. small: true,
  2573. verticalAlign: "top",
  2574. renderHeader: renderHeader$3,
  2575. renderBody: renderBody$3,
  2576. });
  2577. changelogDialog.on("render", () => {
  2578. const mdContElem = document.querySelector("#bytm-changelog-dialog-text");
  2579. if (!mdContElem)
  2580. return;
  2581. const anchors = mdContElem.querySelectorAll("a");
  2582. for (const anchor of anchors) {
  2583. anchor.ariaLabel = anchor.title = anchor.href;
  2584. anchor.target = "_blank";
  2585. }
  2586. const firstDetails = mdContElem.querySelector("details");
  2587. if (firstDetails)
  2588. firstDetails.open = true;
  2589. });
  2590. }
  2591. return changelogDialog;
  2592. });
  2593. }
  2594. function renderHeader$3() {
  2595. return __awaiter(this, void 0, void 0, function* () {
  2596. const headerEl = document.createElement("h2");
  2597. headerEl.classList.add("bytm-dialog-title");
  2598. headerEl.role = "heading";
  2599. headerEl.ariaLevel = "1";
  2600. headerEl.tabIndex = 0;
  2601. headerEl.textContent = headerEl.ariaLabel = t("changelog_menu_title", scriptInfo.name);
  2602. return headerEl;
  2603. });
  2604. }
  2605. function renderBody$3() {
  2606. return __awaiter(this, void 0, void 0, function* () {
  2607. const contElem = document.createElement("div");
  2608. const mdContElem = document.createElement("div");
  2609. mdContElem.id = "bytm-changelog-dialog-text";
  2610. mdContElem.classList.add("bytm-markdown-container");
  2611. mdContElem.innerHTML = yield getChangelogHtmlWithDetails();
  2612. contElem.appendChild(mdContElem);
  2613. return contElem;
  2614. });
  2615. }let featHelpDialog = null;
  2616. let curFeatKey = null;
  2617. /** Creates or modifies the help dialog for a specific feature and returns it */
  2618. function getFeatHelpDialog(_a) {
  2619. return __awaiter(this, arguments, void 0, function* ({ featKey, }) {
  2620. curFeatKey = featKey;
  2621. if (!featHelpDialog) {
  2622. featHelpDialog = new BytmDialog({
  2623. id: "feat-help",
  2624. width: 600,
  2625. height: 400,
  2626. closeBtnEnabled: true,
  2627. closeOnBgClick: true,
  2628. closeOnEscPress: true,
  2629. small: true,
  2630. renderHeader: renderHeader$2,
  2631. renderBody: renderBody$2,
  2632. });
  2633. // make config menu inert while help dialog is open
  2634. featHelpDialog.on("open", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true"); });
  2635. featHelpDialog.on("close", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert"); });
  2636. }
  2637. return featHelpDialog;
  2638. });
  2639. }
  2640. function renderHeader$2() {
  2641. return __awaiter(this, void 0, void 0, function* () {
  2642. const headerEl = document.createElement("div");
  2643. const helpIconSvg = yield resourceAsString("icon-help");
  2644. if (helpIconSvg)
  2645. headerEl.innerHTML = helpIconSvg;
  2646. return headerEl;
  2647. });
  2648. }
  2649. function renderBody$2() {
  2650. return __awaiter(this, void 0, void 0, function* () {
  2651. var _a, _b;
  2652. const contElem = document.createElement("div");
  2653. const featDescElem = document.createElement("h3");
  2654. featDescElem.role = "subheading";
  2655. featDescElem.tabIndex = 0;
  2656. featDescElem.textContent = t(`feature_desc_${curFeatKey}`);
  2657. featDescElem.id = "bytm-feat-help-dialog-desc";
  2658. const helpTextElem = document.createElement("div");
  2659. helpTextElem.id = "bytm-feat-help-dialog-text";
  2660. helpTextElem.tabIndex = 0;
  2661. // @ts-ignore
  2662. const helpText = (_b = (_a = featInfo[curFeatKey]) === null || _a === void 0 ? void 0 : _a.helpText) === null || _b === void 0 ? void 0 : _b.call(_a);
  2663. helpTextElem.textContent = helpText !== null && helpText !== void 0 ? helpText : t(`feature_helptext_${curFeatKey}`);
  2664. contElem.appendChild(featDescElem);
  2665. contElem.appendChild(helpTextElem);
  2666. return contElem;
  2667. });
  2668. }var name = "betterytm";
  2669. var userscriptName = "BetterYTM";
  2670. var version = "2.0.0";
  2671. var description = "Lots of configurable layout and user experience improvements for YouTube Music™ and YouTube™";
  2672. var homepage = "https://github.com/Sv443/BetterYTM";
  2673. var main = "./src/index.ts";
  2674. var type = "module";
  2675. var scripts = {
  2676. dev: "concurrently \"nodemon --exec pnpm run build-private-dev\" \"pnpm run serve\"",
  2677. serve: "pnpm run node-ts ./src/tools/serve.ts",
  2678. lint: "tsc --noEmit && eslint .",
  2679. build: "rollup -c",
  2680. "build-private-dev": "rollup -c --config-mode development --config-host github --config-branch develop --config-assetSource=local",
  2681. "build-preview": "rollup -c --config-mode development --config-host github --config-branch develop",
  2682. preview: "pnpm run build-preview && pnpm run serve --auto-exit=3",
  2683. "build-prod": "pnpm run build-prod-gh && pnpm run build-prod-gf && pnpm run build-prod-oujs",
  2684. "build-prod-base": "rollup -c --config-mode production --config-branch main",
  2685. "build-prod-gh": "pnpm run build-prod-base --config-host github",
  2686. "build-prod-gf": "pnpm run build-prod-base --config-host greasyfork --config-suffix _gf",
  2687. "build-prod-oujs": "pnpm run build-prod-base --config-host openuserjs --config-suffix _oujs",
  2688. "post-build": "pnpm run node-ts ./src/tools/post-build.ts",
  2689. "tr-progress": "pnpm run node-ts ./src/tools/tr-progress.ts",
  2690. "tr-format": "pnpm run node-ts ./src/tools/tr-format.ts",
  2691. "tr-prep": "pnpm run tr-format -p",
  2692. "gen-readme": "pnpm run node-ts ./src/tools/gen-readme.ts",
  2693. "node-ts": "node --no-warnings=ExperimentalWarning --enable-source-maps --loader ts-node/esm",
  2694. invisible: "node --enable-source-maps src/tools/run-invisible.mjs",
  2695. test: "pnpm run node-ts ./test.ts",
  2696. knip: "knip",
  2697. storybook: "storybook dev -p 6006",
  2698. "build-storybook": "storybook build"
  2699. };
  2700. var engines = {
  2701. node: ">=19",
  2702. npm: ">=8"
  2703. };
  2704. var repository = {
  2705. type: "git",
  2706. url: "git+https://github.com/Sv443/BetterYTM.git"
  2707. };
  2708. var author = {
  2709. name: "Sv443",
  2710. url: "https://github.com/Sv443"
  2711. };
  2712. var license = "AGPL-3.0-only";
  2713. var bugs = {
  2714. url: "https://github.com/Sv443/BetterYTM/issues"
  2715. };
  2716. var funding = {
  2717. type: "github",
  2718. url: "https://github.com/sponsors/Sv443"
  2719. };
  2720. var hosts = {
  2721. github: "https://github.com/Sv443/BetterYTM",
  2722. greasyfork: "https://greasyfork.org/en/scripts/475682-betterytm",
  2723. openuserjs: "https://openuserjs.org/scripts/Sv443/BetterYTM"
  2724. };
  2725. var updates = {
  2726. github: "https://github.com/Sv443/BetterYTM/releases",
  2727. greasyfork: "https://greasyfork.org/en/scripts/475682-betterytm",
  2728. openuserjs: "https://openuserjs.org/scripts/Sv443/BetterYTM"
  2729. };
  2730. var dependencies = {
  2731. "@sv443-network/userutils": "^6.3.0",
  2732. "compare-versions": "^6.1.0",
  2733. "fuse.js": "^7.0.0",
  2734. marked: "^12.0.2",
  2735. nanoevents: "^9.0.0"
  2736. };
  2737. var devDependencies = {
  2738. "@chromatic-com/storybook": "^1.5.0",
  2739. "@eslint/eslintrc": "^3.1.0",
  2740. "@rollup/plugin-json": "^6.1.0",
  2741. "@rollup/plugin-node-resolve": "^15.2.3",
  2742. "@rollup/plugin-terser": "^0.4.4",
  2743. "@rollup/plugin-typescript": "^11.1.6",
  2744. "@storybook/addon-essentials": "^8.1.10",
  2745. "@storybook/addon-interactions": "^8.1.10",
  2746. "@storybook/addon-links": "^8.1.10",
  2747. "@storybook/blocks": "^8.1.10",
  2748. "@storybook/html": "^8.1.10",
  2749. "@storybook/html-vite": "^8.1.10",
  2750. "@storybook/test": "^8.1.10",
  2751. "@types/express": "^4.17.21",
  2752. "@types/greasemonkey": "^4.0.7",
  2753. "@types/node": "^20.14.8",
  2754. "@typescript-eslint/eslint-plugin": "8.0.0-alpha.49",
  2755. "@typescript-eslint/parser": "8.0.0-alpha.49",
  2756. "@typescript-eslint/utils": "8.0.0-alpha.49",
  2757. concurrently: "^8.2.2",
  2758. dotenv: "^16.4.5",
  2759. eslint: "^9.5.0",
  2760. "eslint-plugin-storybook": "0.9.0--canary.156.ed236ca.0",
  2761. express: "^4.19.2",
  2762. globals: "^15.6.0",
  2763. knip: "^5.22.2",
  2764. nodemon: "^3.1.4",
  2765. pnpm: "^9.4.0",
  2766. rollup: "^4.18.0",
  2767. "rollup-plugin-execute": "^1.1.1",
  2768. "rollup-plugin-import-css": "^3.5.0",
  2769. storybook: "^8.1.10",
  2770. "storybook-dark-mode": "^4.0.2",
  2771. "ts-node": "^10.9.2",
  2772. tslib: "^2.6.3",
  2773. typescript: "^5.5.2"
  2774. };
  2775. var browserslist = [
  2776. "last 1 version",
  2777. "> 1%",
  2778. "not dead"
  2779. ];
  2780. var nodemonConfig = {
  2781. watch: [
  2782. "src/**",
  2783. "assets/**",
  2784. "rollup.config.mjs",
  2785. ".env",
  2786. "changelog.md",
  2787. "package.json"
  2788. ],
  2789. ext: "ts,mts,js,jsx,mjs,json,html,css,svg,png",
  2790. ignore: [
  2791. "dist/*",
  2792. "dev/*",
  2793. "*/stories/*"
  2794. ]
  2795. };
  2796. var pkg = {
  2797. name: name,
  2798. userscriptName: userscriptName,
  2799. version: version,
  2800. description: description,
  2801. homepage: homepage,
  2802. main: main,
  2803. type: type,
  2804. scripts: scripts,
  2805. engines: engines,
  2806. repository: repository,
  2807. author: author,
  2808. license: license,
  2809. bugs: bugs,
  2810. funding: funding,
  2811. hosts: hosts,
  2812. updates: updates,
  2813. dependencies: dependencies,
  2814. devDependencies: devDependencies,
  2815. browserslist: browserslist,
  2816. nodemonConfig: nodemonConfig
  2817. };let verNotifDialog = null;
  2818. /** Creates and/or returns the dialog to be shown when a new version is available */
  2819. function getVersionNotifDialog(_a) {
  2820. return __awaiter(this, arguments, void 0, function* ({ latestTag, }) {
  2821. if (!verNotifDialog) {
  2822. const changelogMdFull = yield getChangelogMd();
  2823. // I messed up because this should be 0 so the changelog will always need to have an extra div at the top for backwards compatibility
  2824. const changelogMd = changelogMdFull.split("<div class=\"split\">")[1];
  2825. const changelogHtml = yield parseMarkdown(changelogMd);
  2826. verNotifDialog = new BytmDialog({
  2827. id: "version-notif",
  2828. width: 600,
  2829. height: 800,
  2830. closeBtnEnabled: false,
  2831. closeOnBgClick: false,
  2832. closeOnEscPress: true,
  2833. destroyOnClose: true,
  2834. small: true,
  2835. renderHeader: renderHeader$1,
  2836. renderBody: () => renderBody$1({
  2837. latestTag,
  2838. changelogHtml,
  2839. }),
  2840. });
  2841. }
  2842. return verNotifDialog;
  2843. });
  2844. }
  2845. function renderHeader$1() {
  2846. return __awaiter(this, void 0, void 0, function* () {
  2847. const logoEl = document.createElement("img");
  2848. logoEl.classList.add("bytm-dialog-header-img", "bytm-no-select");
  2849. logoEl.src = yield getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  2850. logoEl.alt = "BetterYTM logo";
  2851. return logoEl;
  2852. });
  2853. }
  2854. let disableUpdateCheck = false;
  2855. function renderBody$1(_a) {
  2856. return __awaiter(this, arguments, void 0, function* ({ latestTag, changelogHtml, }) {
  2857. disableUpdateCheck = false;
  2858. const hostPlatformNames = {
  2859. github: "GitHub",
  2860. greasyfork: "GreasyFork",
  2861. openuserjs: "OpenUserJS",
  2862. };
  2863. const wrapperEl = document.createElement("div");
  2864. const pEl = document.createElement("p");
  2865. pEl.textContent = t("new_version_available", scriptInfo.name, scriptInfo.version, latestTag, hostPlatformNames[host]);
  2866. wrapperEl.appendChild(pEl);
  2867. const changelogDetailsEl = document.createElement("details");
  2868. changelogDetailsEl.id = "bytm-version-notif-changelog-details";
  2869. changelogDetailsEl.open = false;
  2870. const changelogSummaryEl = document.createElement("summary");
  2871. changelogSummaryEl.role = "button";
  2872. changelogSummaryEl.tabIndex = 0;
  2873. changelogSummaryEl.ariaLabel = changelogSummaryEl.title = changelogSummaryEl.textContent = t("expand_release_notes");
  2874. changelogDetailsEl.appendChild(changelogSummaryEl);
  2875. changelogDetailsEl.addEventListener("toggle", () => {
  2876. changelogSummaryEl.ariaLabel = changelogSummaryEl.title = changelogSummaryEl.textContent = changelogDetailsEl.open ? t("collapse_release_notes") : t("expand_release_notes");
  2877. });
  2878. const changelogEl = document.createElement("p");
  2879. changelogEl.id = "bytm-version-notif-changelog-cont";
  2880. changelogEl.classList.add("bytm-markdown-container");
  2881. changelogEl.innerHTML = changelogHtml;
  2882. changelogEl.querySelectorAll("a").forEach((a) => {
  2883. a.target = "_blank";
  2884. a.rel = "noopener noreferrer";
  2885. });
  2886. changelogDetailsEl.appendChild(changelogEl);
  2887. wrapperEl.appendChild(changelogDetailsEl);
  2888. const disableUpdCheckEl = document.createElement("div");
  2889. disableUpdCheckEl.id = "bytm-disable-update-check-wrapper";
  2890. if (!getFeature("versionCheck"))
  2891. disableUpdateCheck = true;
  2892. const disableToggleEl = yield createToggleInput({
  2893. id: "disable-update-check",
  2894. initialValue: disableUpdateCheck,
  2895. labelPos: "off",
  2896. onChange(checked) {
  2897. disableUpdateCheck = checked;
  2898. if (checked)
  2899. btnClose.textContent = t("close_and_ignore_until_reenabled");
  2900. else
  2901. btnClose.textContent = t("close_and_ignore_for_24h");
  2902. },
  2903. });
  2904. const labelWrapperEl = document.createElement("div");
  2905. labelWrapperEl.classList.add("bytm-disable-update-check-toggle-label-wrapper");
  2906. const labelEl = document.createElement("label");
  2907. labelEl.htmlFor = "bytm-toggle-disable-update-check";
  2908. labelEl.textContent = t("disable_update_check");
  2909. const secondaryLabelEl = document.createElement("span");
  2910. secondaryLabelEl.classList.add("bytm-secondary-label");
  2911. secondaryLabelEl.textContent = t("reenable_in_config_menu");
  2912. labelWrapperEl.appendChild(labelEl);
  2913. labelWrapperEl.appendChild(secondaryLabelEl);
  2914. disableUpdCheckEl.appendChild(disableToggleEl);
  2915. disableUpdCheckEl.appendChild(labelWrapperEl);
  2916. wrapperEl.appendChild(disableUpdCheckEl);
  2917. verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.on("close", () => __awaiter(this, void 0, void 0, function* () {
  2918. const config = getFeatures();
  2919. const recreateCfgMenu = config.versionCheck === disableUpdateCheck;
  2920. if (config.versionCheck && disableUpdateCheck)
  2921. config.versionCheck = false;
  2922. else if (!config.versionCheck && !disableUpdateCheck)
  2923. config.versionCheck = true;
  2924. yield setFeatures(config);
  2925. recreateCfgMenu && emitSiteEvent("recreateCfgMenu");
  2926. }));
  2927. const btnWrapper = document.createElement("div");
  2928. btnWrapper.id = "bytm-version-notif-dialog-btns";
  2929. const btnUpdate = document.createElement("button");
  2930. btnUpdate.classList.add("bytm-btn");
  2931. btnUpdate.tabIndex = 0;
  2932. btnUpdate.textContent = t("open_update_page_install_manually", hostPlatformNames[host]);
  2933. onInteraction(btnUpdate, () => {
  2934. window.open(pkg.updates[host]);
  2935. verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.close();
  2936. });
  2937. const btnClose = document.createElement("button");
  2938. btnClose.classList.add("bytm-btn");
  2939. btnClose.tabIndex = 0;
  2940. btnClose.textContent = t("close_and_ignore_for_24h");
  2941. onInteraction(btnClose, () => verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.close());
  2942. btnWrapper.appendChild(btnUpdate);
  2943. btnWrapper.appendChild(btnClose);
  2944. wrapperEl.appendChild(btnWrapper);
  2945. return wrapperEl;
  2946. });
  2947. }//#region create menu
  2948. let isCfgMenuMounted = false;
  2949. let isCfgMenuOpen = false;
  2950. /** Threshold in pixels from the top of the options container that dictates for how long the scroll indicator is shown */
  2951. const scrollIndicatorOffsetThreshold = 50;
  2952. let scrollIndicatorEnabled = true;
  2953. /** Locale at the point of initializing the config menu */
  2954. let initLocale;
  2955. /** Stringified config at the point of initializing the config menu */
  2956. let initConfig$1;
  2957. /** Timeout id for the "copied" text in the hidden value copy button */
  2958. let hiddenCopiedTxtTimeout;
  2959. /**
  2960. * Adds an element to open the BetterYTM menu
  2961. * @deprecated to be replaced with new menu - see https://github.com/Sv443/BetterYTM/issues/23
  2962. */
  2963. function mountCfgMenu() {
  2964. return __awaiter(this, void 0, void 0, function* () {
  2965. var _a, _b, _c, _d;
  2966. if (isCfgMenuMounted)
  2967. return;
  2968. isCfgMenuMounted = true;
  2969. initLocale = getFeature("locale");
  2970. initConfig$1 = getFeatures();
  2971. const initLangReloadText = t("lang_changed_prompt_reload");
  2972. //#region bg & container
  2973. const backgroundElem = document.createElement("div");
  2974. backgroundElem.id = "bytm-cfg-menu-bg";
  2975. backgroundElem.classList.add("bytm-menu-bg");
  2976. backgroundElem.ariaLabel = backgroundElem.title = t("close_menu_tooltip");
  2977. backgroundElem.style.visibility = "hidden";
  2978. backgroundElem.style.display = "none";
  2979. backgroundElem.addEventListener("click", (e) => {
  2980. var _a;
  2981. if (isCfgMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-cfg-menu-bg")
  2982. closeCfgMenu(e);
  2983. });
  2984. document.body.addEventListener("keydown", (e) => {
  2985. if (isCfgMenuOpen && e.key === "Escape" && BytmDialog.getCurrentDialogId() === "cfg-menu")
  2986. closeCfgMenu(e);
  2987. });
  2988. const menuContainer = document.createElement("div");
  2989. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  2990. menuContainer.classList.add("bytm-menu");
  2991. menuContainer.id = "bytm-cfg-menu";
  2992. //#region title bar
  2993. const headerElem = document.createElement("div");
  2994. headerElem.classList.add("bytm-menu-header");
  2995. const titleLogoHeaderCont = document.createElement("div");
  2996. titleLogoHeaderCont.classList.add("bytm-menu-title-logo-header-cont");
  2997. const titleCont = document.createElement("div");
  2998. titleCont.classList.add("bytm-menu-titlecont");
  2999. titleCont.role = "heading";
  3000. titleCont.ariaLevel = "1";
  3001. const titleLogoElem = document.createElement("img");
  3002. const logoSrc = yield getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`);
  3003. titleLogoElem.classList.add("bytm-cfg-menu-logo", "bytm-no-select");
  3004. if (logoSrc)
  3005. titleLogoElem.src = logoSrc;
  3006. titleLogoHeaderCont.appendChild(titleLogoElem);
  3007. const titleElem = document.createElement("h2");
  3008. titleElem.classList.add("bytm-menu-title");
  3009. const titleTextElem = document.createElement("div");
  3010. titleTextElem.textContent = t("config_menu_title", scriptInfo.name);
  3011. titleElem.appendChild(titleTextElem);
  3012. const linksCont = document.createElement("div");
  3013. linksCont.id = "bytm-menu-linkscont";
  3014. linksCont.role = "navigation";
  3015. const linkTitlesShort = {
  3016. github: "GitHub",
  3017. greasyfork: "GreasyFork",
  3018. openuserjs: "OpenUserJS",
  3019. discord: "Discord",
  3020. };
  3021. const addLink = (imgSrc, href, title, titleKey) => {
  3022. const anchorElem = document.createElement("a");
  3023. anchorElem.classList.add("bytm-menu-link", "bytm-no-select");
  3024. anchorElem.rel = "noopener noreferrer";
  3025. anchorElem.href = href;
  3026. anchorElem.target = "_blank";
  3027. anchorElem.tabIndex = 0;
  3028. anchorElem.role = "button";
  3029. anchorElem.ariaLabel = anchorElem.title = title;
  3030. const extendedAnchorEl = document.createElement("a");
  3031. extendedAnchorEl.classList.add("bytm-menu-link", "extended-link", "bytm-no-select");
  3032. extendedAnchorEl.rel = "noopener noreferrer";
  3033. extendedAnchorEl.href = href;
  3034. extendedAnchorEl.target = "_blank";
  3035. extendedAnchorEl.tabIndex = -1;
  3036. extendedAnchorEl.textContent = linkTitlesShort[titleKey];
  3037. extendedAnchorEl.ariaLabel = extendedAnchorEl.title = title;
  3038. const imgElem = document.createElement("img");
  3039. imgElem.classList.add("bytm-menu-img");
  3040. imgElem.src = imgSrc;
  3041. anchorElem.appendChild(imgElem);
  3042. anchorElem.appendChild(extendedAnchorEl);
  3043. linksCont.appendChild(anchorElem);
  3044. };
  3045. const links = [
  3046. ["github", yield getResourceUrl("img-github"), scriptInfo.namespace, t("open_github", scriptInfo.name), "github"],
  3047. ["greasyfork", yield getResourceUrl("img-greasyfork"), pkg.hosts.greasyfork, t("open_greasyfork", scriptInfo.name), "greasyfork"],
  3048. ["openuserjs", yield getResourceUrl("img-openuserjs"), pkg.hosts.openuserjs, t("open_openuserjs", scriptInfo.name), "openuserjs"],
  3049. ];
  3050. const hostLink = links.find(([name]) => name === host);
  3051. const otherLinks = links.filter(([name]) => name !== host);
  3052. const reorderedLinks = hostLink ? [hostLink, ...otherLinks] : links;
  3053. for (const [, ...args] of reorderedLinks)
  3054. addLink(...args);
  3055. addLink(yield getResourceUrl("img-discord"), "https://dc.sv443.net/", t("open_discord"), "discord");
  3056. const closeElem = document.createElement("img");
  3057. closeElem.classList.add("bytm-menu-close");
  3058. closeElem.role = "button";
  3059. closeElem.tabIndex = 0;
  3060. closeElem.src = yield getResourceUrl("img-close");
  3061. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  3062. onInteraction(closeElem, closeCfgMenu);
  3063. titleCont.appendChild(titleElem);
  3064. titleCont.appendChild(linksCont);
  3065. titleLogoHeaderCont.appendChild(titleCont);
  3066. headerElem.appendChild(titleLogoHeaderCont);
  3067. headerElem.appendChild(closeElem);
  3068. //#region footer
  3069. const footerCont = document.createElement("div");
  3070. footerCont.classList.add("bytm-menu-footer-cont");
  3071. const reloadFooterCont = document.createElement("div");
  3072. const reloadFooterEl = document.createElement("div");
  3073. reloadFooterEl.classList.add("bytm-menu-footer", "hidden");
  3074. reloadFooterEl.setAttribute("aria-hidden", "true");
  3075. reloadFooterEl.textContent = t("reload_hint");
  3076. reloadFooterEl.role = "alert";
  3077. reloadFooterEl.ariaLive = "polite";
  3078. const reloadTxtEl = document.createElement("button");
  3079. reloadTxtEl.classList.add("bytm-btn");
  3080. reloadTxtEl.style.marginLeft = "10px";
  3081. reloadTxtEl.textContent = t("reload_now");
  3082. reloadTxtEl.ariaLabel = reloadTxtEl.title = t("reload_tooltip");
  3083. reloadTxtEl.addEventListener("click", () => {
  3084. closeCfgMenu();
  3085. disableBeforeUnload();
  3086. location.reload();
  3087. });
  3088. reloadFooterEl.appendChild(reloadTxtEl);
  3089. reloadFooterCont.appendChild(reloadFooterEl);
  3090. /** For copying plain when shift-clicking the copy button or when compression is not supported */
  3091. const exportDataSpecial = () => JSON.stringify({ formatVersion, data: getFeatures() });
  3092. const exImDlg = new ExImDialog({
  3093. id: "bytm-config-export-import",
  3094. width: 800,
  3095. height: 600,
  3096. // try to compress the data if possible
  3097. exportData: () => __awaiter(this, void 0, void 0, function* () {
  3098. return (yield compressionSupported())
  3099. ? yield UserUtils.compress(JSON.stringify({ formatVersion, data: getFeatures() }), compressionFormat, "string")
  3100. : exportDataSpecial();
  3101. }),
  3102. exportDataSpecial,
  3103. onImport(data) {
  3104. return __awaiter(this, void 0, void 0, function* () {
  3105. try {
  3106. const parsed = yield tryToDecompressAndParse(data.trim());
  3107. log("Trying to import configuration:", parsed);
  3108. if (!parsed || typeof parsed !== "object")
  3109. return alert(t("import_error_invalid"));
  3110. if (typeof parsed.formatVersion !== "number")
  3111. return alert(t("import_error_no_format_version"));
  3112. if (typeof parsed.data !== "object" || parsed.data === null || Object.keys(parsed.data).length === 0)
  3113. return alert(t("import_error_no_data"));
  3114. if (parsed.formatVersion < formatVersion) {
  3115. let newData = JSON.parse(JSON.stringify(parsed.data));
  3116. const sortedMigrations = Object.entries(migrations)
  3117. .sort(([a], [b]) => Number(a) - Number(b));
  3118. let curFmtVer = Number(parsed.formatVersion);
  3119. for (const [fmtVer, migrationFunc] of sortedMigrations) {
  3120. const ver = Number(fmtVer);
  3121. if (curFmtVer < formatVersion && curFmtVer < ver) {
  3122. try {
  3123. const migRes = JSON.parse(JSON.stringify(migrationFunc(newData)));
  3124. newData = migRes instanceof Promise ? yield migRes : migRes;
  3125. curFmtVer = ver;
  3126. }
  3127. catch (err) {
  3128. error(`Error while running migration function for format version ${fmtVer}:`, err);
  3129. }
  3130. }
  3131. }
  3132. parsed.formatVersion = curFmtVer;
  3133. parsed.data = newData;
  3134. }
  3135. else if (parsed.formatVersion !== formatVersion)
  3136. return alert(t("import_error_wrong_format_version", formatVersion, parsed.formatVersion));
  3137. yield setFeatures(Object.assign(Object.assign({}, getFeatures()), parsed.data));
  3138. if (confirm(t("import_success_confirm_reload"))) {
  3139. disableBeforeUnload();
  3140. return location.reload();
  3141. }
  3142. exImDlg.unmount();
  3143. emitSiteEvent("rebuildCfgMenu", parsed.data);
  3144. }
  3145. catch (err) {
  3146. warn("Couldn't import configuration:", err);
  3147. alert(t("import_error_invalid"));
  3148. }
  3149. });
  3150. },
  3151. title: () => t("bytm_config_export_import_title"),
  3152. descImport: () => t("bytm_config_import_desc"),
  3153. descExport: () => t("bytm_config_export_desc"),
  3154. });
  3155. const exportImportBtn = document.createElement("button");
  3156. exportImportBtn.classList.add("bytm-btn");
  3157. exportImportBtn.textContent = exportImportBtn.ariaLabel = exportImportBtn.title = t("export_import");
  3158. onInteraction(exportImportBtn, () => __awaiter(this, void 0, void 0, function* () { return yield exImDlg.open(); }));
  3159. const buttonsCont = document.createElement("div");
  3160. buttonsCont.classList.add("bytm-menu-footer-buttons-cont");
  3161. buttonsCont.appendChild(exportImportBtn);
  3162. footerCont.appendChild(reloadFooterCont);
  3163. footerCont.appendChild(buttonsCont);
  3164. //#region feature list
  3165. const featuresCont = document.createElement("div");
  3166. featuresCont.id = "bytm-menu-opts";
  3167. const onCfgChange = (key, initialVal, newVal) => __awaiter(this, void 0, void 0, function* () {
  3168. var _a, _b;
  3169. const fmt = (val) => typeof val === "object" ? JSON.stringify(val) : String(val);
  3170. info(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`);
  3171. const featConf = JSON.parse(JSON.stringify(getFeatures()));
  3172. featConf[key] = newVal;
  3173. const changedKeys = initConfig$1 ? Object.keys(featConf).filter((k) => typeof featConf[k] !== "object"
  3174. && featConf[k] !== initConfig$1[k]) : [];
  3175. const requiresReload =
  3176. // @ts-ignore
  3177. changedKeys.some((k) => { var _a; return ((_a = featInfo[k]) === null || _a === void 0 ? void 0 : _a.reloadRequired) !== false; });
  3178. yield setFeatures(featConf);
  3179. // @ts-ignore
  3180. (_b = (_a = featInfo[key]) === null || _a === void 0 ? void 0 : _a.change) === null || _b === void 0 ? void 0 : _b.call(_a, key, initialVal, newVal);
  3181. if (requiresReload) {
  3182. reloadFooterEl.classList.remove("hidden");
  3183. reloadFooterEl.setAttribute("aria-hidden", "false");
  3184. }
  3185. else if (!requiresReload) {
  3186. reloadFooterEl.classList.add("hidden");
  3187. reloadFooterEl.setAttribute("aria-hidden", "true");
  3188. }
  3189. if (initLocale !== featConf.locale) {
  3190. yield initTranslations(featConf.locale);
  3191. setLocale(featConf.locale);
  3192. const newText = t("lang_changed_prompt_reload");
  3193. const confirmText = newText !== initLangReloadText ? `${newText}\n\n────────────────────────────────\n\n${initLangReloadText}` : newText;
  3194. if (confirm(confirmText)) {
  3195. closeCfgMenu();
  3196. disableBeforeUnload();
  3197. location.reload();
  3198. }
  3199. }
  3200. else if (getLocale() !== featConf.locale)
  3201. setLocale(featConf.locale);
  3202. emitSiteEvent("configOptionChanged", key, initialVal, newVal);
  3203. });
  3204. /** Call whenever the feature config is changed */
  3205. const confChanged = UserUtils.debounce(onCfgChange, 333, "falling");
  3206. const featureCfg = getFeatures();
  3207. const featureCfgWithCategories = Object.entries(featInfo)
  3208. .reduce((acc, [key, { category }]) => {
  3209. if (!acc[category])
  3210. acc[category] = {};
  3211. acc[category][key] = featureCfg[key];
  3212. return acc;
  3213. }, {});
  3214. /**
  3215. * Formats the value `v` based on the provided `key` using the `featInfo` object.
  3216. * If a custom `renderValue` function is defined for the `key`, it will be used to format the value.
  3217. * If no custom `renderValue` function is defined, the value will be converted to a string and trimmed.
  3218. * If the value is an object, it will be converted to a JSON string representation.
  3219. * If an error occurs during formatting (like when passing objects with circular references), the original value will be returned as a string (trimmed).
  3220. */
  3221. const fmtVal = (v, key) => {
  3222. var _a;
  3223. try {
  3224. // @ts-ignore
  3225. const renderValue = typeof ((_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.renderValue) === "function" ? featInfo[key].renderValue : undefined;
  3226. const retVal = (typeof v === "object" ? JSON.stringify(v) : String(v)).trim();
  3227. return renderValue ? renderValue(retVal) : retVal;
  3228. }
  3229. catch (_b) {
  3230. // absolute last resort fallback because stringify throws on circular refs
  3231. return String(v).trim();
  3232. }
  3233. };
  3234. for (const category in featureCfgWithCategories) {
  3235. const featObj = featureCfgWithCategories[category];
  3236. const catHeaderElem = document.createElement("h3");
  3237. catHeaderElem.classList.add("bytm-ftconf-category-header");
  3238. catHeaderElem.role = "heading";
  3239. catHeaderElem.ariaLevel = "2";
  3240. catHeaderElem.tabIndex = 0;
  3241. catHeaderElem.textContent = `${t(`feature_category_${category}`)}:`;
  3242. featuresCont.appendChild(catHeaderElem);
  3243. for (const featKey in featObj) {
  3244. const ftInfo = featInfo[featKey];
  3245. if (!ftInfo || ("hidden" in ftInfo && ftInfo.hidden === true))
  3246. continue;
  3247. if (ftInfo.advanced && !featureCfg.advancedMode)
  3248. continue;
  3249. const { type, default: ftDefault } = ftInfo;
  3250. const step = "step" in ftInfo ? ftInfo.step : undefined;
  3251. const val = featureCfg[featKey];
  3252. const initialVal = (_a = val !== null && val !== void 0 ? val : ftDefault) !== null && _a !== void 0 ? _a : undefined;
  3253. const ftConfElem = document.createElement("div");
  3254. ftConfElem.classList.add("bytm-ftitem");
  3255. {
  3256. const featLeftSideElem = document.createElement("div");
  3257. featLeftSideElem.classList.add("bytm-ftitem-leftside");
  3258. if (getFeature("advancedMode")) {
  3259. const defVal = fmtVal(ftDefault, featKey);
  3260. const extraTxts = [
  3261. `default: ${defVal.length === 0 ? "(undefined)" : defVal}`,
  3262. ];
  3263. "min" in ftInfo && extraTxts.push(`min: ${ftInfo.min}`);
  3264. "max" in ftInfo && extraTxts.push(`max: ${ftInfo.max}`);
  3265. "step" in ftInfo && extraTxts.push(`step: ${ftInfo.step}`);
  3266. const rel = "reloadRequired" in ftInfo && ftInfo.reloadRequired !== false ? " (reload required)" : "";
  3267. const adv = ftInfo.advanced ? " (advanced feature)" : "";
  3268. featLeftSideElem.title = `${featKey}${rel}${adv}${extraTxts.length > 0 ? `\n${extraTxts.join(" - ")}` : ""}`;
  3269. }
  3270. const textElem = document.createElement("span");
  3271. textElem.textContent = t(`feature_desc_${featKey}`);
  3272. let adornmentElem;
  3273. const adornContent = (_b = ftInfo.textAdornment) === null || _b === void 0 ? void 0 : _b.call(ftInfo);
  3274. const adornContentAw = adornContent instanceof Promise ? yield adornContent : adornContent;
  3275. if ((typeof adornContent === "string" || adornContent instanceof Promise) && typeof adornContentAw !== "undefined") {
  3276. adornmentElem = document.createElement("span");
  3277. adornmentElem.id = `bytm-ftitem-${featKey}-adornment`;
  3278. adornmentElem.classList.add("bytm-ftitem-adornment");
  3279. adornmentElem.innerHTML = adornContentAw;
  3280. }
  3281. let helpElem;
  3282. // @ts-ignore
  3283. const hasHelpTextFunc = typeof ((_c = featInfo[featKey]) === null || _c === void 0 ? void 0 : _c.helpText) === "function";
  3284. // @ts-ignore
  3285. const helpTextVal = hasHelpTextFunc && featInfo[featKey].helpText();
  3286. if (hasKey(`feature_helptext_${featKey}`) || (helpTextVal && hasKey(helpTextVal))) {
  3287. const helpElemImgHtml = yield resourceAsString("icon-help");
  3288. if (helpElemImgHtml) {
  3289. helpElem = document.createElement("div");
  3290. helpElem.classList.add("bytm-ftitem-help-btn", "bytm-generic-btn");
  3291. helpElem.ariaLabel = helpElem.title = t("feature_help_button_tooltip", t(`feature_desc_${featKey}`));
  3292. helpElem.role = "button";
  3293. helpElem.tabIndex = 0;
  3294. helpElem.innerHTML = helpElemImgHtml;
  3295. onInteraction(helpElem, (e) => __awaiter(this, void 0, void 0, function* () {
  3296. e.preventDefault();
  3297. e.stopPropagation();
  3298. yield (yield getFeatHelpDialog({ featKey: featKey })).open();
  3299. }));
  3300. }
  3301. else {
  3302. error(`Couldn't create help button SVG element for feature '${featKey}'`);
  3303. }
  3304. }
  3305. adornmentElem && featLeftSideElem.appendChild(adornmentElem);
  3306. featLeftSideElem.appendChild(textElem);
  3307. helpElem && featLeftSideElem.appendChild(helpElem);
  3308. ftConfElem.appendChild(featLeftSideElem);
  3309. }
  3310. {
  3311. let inputType = "text";
  3312. let inputTag = "input";
  3313. switch (type) {
  3314. case "toggle":
  3315. inputTag = undefined;
  3316. inputType = undefined;
  3317. break;
  3318. case "slider":
  3319. inputType = "range";
  3320. break;
  3321. case "number":
  3322. inputType = "number";
  3323. break;
  3324. case "text":
  3325. inputType = "text";
  3326. break;
  3327. case "select":
  3328. inputTag = "select";
  3329. inputType = undefined;
  3330. break;
  3331. case "hotkey":
  3332. inputTag = undefined;
  3333. inputType = undefined;
  3334. break;
  3335. case "button":
  3336. inputTag = undefined;
  3337. inputType = undefined;
  3338. break;
  3339. }
  3340. const inputElemId = `bytm-ftconf-${featKey}-input`;
  3341. const ctrlElem = document.createElement("span");
  3342. ctrlElem.classList.add("bytm-ftconf-ctrl");
  3343. let advCopyHiddenCont;
  3344. if ((getFeature("advancedMode") || mode === "development") && ftInfo.valueHidden) {
  3345. const advCopyHintElem = document.createElement("span");
  3346. advCopyHintElem.classList.add("bytm-ftconf-adv-copy-hint");
  3347. advCopyHintElem.textContent = t("copied");
  3348. advCopyHintElem.role = "status";
  3349. advCopyHintElem.style.display = "none";
  3350. const advCopyHiddenBtn = document.createElement("button");
  3351. advCopyHiddenBtn.classList.add("bytm-ftconf-adv-copy-btn", "bytm-btn");
  3352. advCopyHiddenBtn.tabIndex = 0;
  3353. advCopyHiddenBtn.textContent = t("copy_hidden");
  3354. advCopyHiddenBtn.ariaLabel = advCopyHiddenBtn.title = t("copy_hidden_tooltip");
  3355. const copyHiddenInteraction = (e) => {
  3356. e.preventDefault();
  3357. e.stopPropagation();
  3358. copyToClipboard(getFeatures()[featKey]);
  3359. advCopyHintElem.style.display = "inline";
  3360. if (typeof hiddenCopiedTxtTimeout === "undefined") {
  3361. hiddenCopiedTxtTimeout = setTimeout(() => {
  3362. advCopyHintElem.style.display = "none";
  3363. hiddenCopiedTxtTimeout = undefined;
  3364. }, 3000);
  3365. }
  3366. };
  3367. onInteraction(advCopyHiddenBtn, copyHiddenInteraction);
  3368. advCopyHiddenCont = document.createElement("span");
  3369. advCopyHiddenCont.appendChild(advCopyHintElem);
  3370. advCopyHiddenCont.appendChild(advCopyHiddenBtn);
  3371. }
  3372. advCopyHiddenCont && ctrlElem.appendChild(advCopyHiddenCont);
  3373. if (inputTag) {
  3374. // standard input element:
  3375. const inputElem = document.createElement(inputTag);
  3376. inputElem.classList.add("bytm-ftconf-input");
  3377. inputElem.id = inputElemId;
  3378. inputElem.ariaLabel = t(`feature_desc_${featKey}`);
  3379. if (inputType)
  3380. inputElem.type = inputType;
  3381. if ("min" in ftInfo && typeof ftInfo.min !== "undefined")
  3382. inputElem.min = String(ftInfo.min);
  3383. if ("max" in ftInfo && typeof ftInfo.max !== "undefined")
  3384. inputElem.max = String(ftInfo.max);
  3385. if (typeof initialVal !== "undefined")
  3386. inputElem.value = String(initialVal);
  3387. if (type === "text" && ftInfo.valueHidden) {
  3388. inputElem.type = "password";
  3389. inputElem.autocomplete = "off";
  3390. }
  3391. if (type === "number" || type === "slider" && step)
  3392. inputElem.step = String(step);
  3393. if (type === "toggle" && typeof initialVal !== "undefined")
  3394. inputElem.checked = Boolean(initialVal);
  3395. const unitTxt = ("unit" in ftInfo && typeof ftInfo.unit === "string"
  3396. ? ftInfo.unit
  3397. : ("unit" in ftInfo && typeof ftInfo.unit === "function"
  3398. ? ftInfo.unit(Number(inputElem.value))
  3399. : ""));
  3400. let labelElem;
  3401. let lastDisplayedVal;
  3402. if (type === "slider") {
  3403. labelElem = document.createElement("label");
  3404. labelElem.classList.add("bytm-ftconf-label", "bytm-slider-label");
  3405. labelElem.textContent = `${fmtVal(initialVal, featKey)}${unitTxt}`;
  3406. inputElem.addEventListener("input", () => {
  3407. if (labelElem && lastDisplayedVal !== inputElem.value) {
  3408. labelElem.textContent = `${fmtVal(inputElem.value, featKey)}${unitTxt}`;
  3409. lastDisplayedVal = inputElem.value;
  3410. }
  3411. });
  3412. }
  3413. else if (type === "select") {
  3414. const ftOpts = typeof ftInfo.options === "function"
  3415. ? ftInfo.options()
  3416. : ftInfo.options;
  3417. for (const { value, label } of ftOpts) {
  3418. const optionElem = document.createElement("option");
  3419. optionElem.value = String(value);
  3420. optionElem.textContent = label;
  3421. if (value === initialVal)
  3422. optionElem.selected = true;
  3423. inputElem.appendChild(optionElem);
  3424. }
  3425. }
  3426. if (type === "text") {
  3427. let lastValue = inputElem.value && inputElem.value.length > 0 ? inputElem.value : ftInfo.default;
  3428. const textInputUpdate = () => {
  3429. let v = String(inputElem.value).trim();
  3430. if (type === "text" && ftInfo.normalize)
  3431. v = inputElem.value = ftInfo.normalize(String(v));
  3432. if (v === lastValue)
  3433. return;
  3434. lastValue = v;
  3435. if (v === "")
  3436. v = ftInfo.default;
  3437. if (typeof initialVal !== "undefined")
  3438. confChanged(featKey, initialVal, v);
  3439. };
  3440. const unsub = siteEvents.on("cfgMenuClosed", () => {
  3441. unsub();
  3442. textInputUpdate();
  3443. });
  3444. inputElem.addEventListener("blur", () => textInputUpdate());
  3445. inputElem.addEventListener("keydown", (e) => e.key === "Tab" && textInputUpdate());
  3446. }
  3447. else {
  3448. inputElem.addEventListener("input", () => {
  3449. let v = String(inputElem.value).trim();
  3450. if (["number", "slider"].includes(type) || v.match(/^-?\d+$/))
  3451. v = Number(v);
  3452. if (typeof initialVal !== "undefined")
  3453. confChanged(featKey, initialVal, (type !== "toggle" ? v : inputElem.checked));
  3454. });
  3455. }
  3456. if (labelElem) {
  3457. labelElem.id = `bytm-ftconf-${featKey}-label`;
  3458. labelElem.htmlFor = inputElemId;
  3459. ctrlElem.appendChild(labelElem);
  3460. }
  3461. ctrlElem.appendChild(inputElem);
  3462. }
  3463. else {
  3464. // custom input element:
  3465. let customInputEl;
  3466. switch (type) {
  3467. case "hotkey":
  3468. customInputEl = createHotkeyInput({
  3469. initialValue: typeof initialVal === "object" ? initialVal : undefined,
  3470. onChange: (hotkey) => confChanged(featKey, initialVal, hotkey),
  3471. createTitle: (value) => t("hotkey_input_click_to_change_tooltip", t(`feature_desc_${featKey}`), value),
  3472. });
  3473. break;
  3474. case "toggle":
  3475. customInputEl = yield createToggleInput({
  3476. initialValue: Boolean(initialVal),
  3477. onChange: (checked) => confChanged(featKey, initialVal, checked),
  3478. id: `ftconf-${featKey}`,
  3479. labelPos: "left",
  3480. });
  3481. break;
  3482. case "button":
  3483. customInputEl = document.createElement("button");
  3484. customInputEl.classList.add("bytm-btn");
  3485. customInputEl.tabIndex = 0;
  3486. customInputEl.textContent = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action");
  3487. customInputEl.ariaLabel = customInputEl.title = t(`feature_desc_${featKey}`);
  3488. onInteraction(customInputEl, () => __awaiter(this, void 0, void 0, function* () {
  3489. if (customInputEl.disabled)
  3490. return;
  3491. const startTs = Date.now();
  3492. const res = ftInfo.click();
  3493. customInputEl.disabled = true;
  3494. customInputEl.classList.add("bytm-busy");
  3495. customInputEl.textContent = customInputEl.ariaLabel = customInputEl.title = hasKey(`feature_btn_${featKey}_running`) ? t(`feature_btn_${featKey}_running`) : t("trigger_btn_action_running");
  3496. if (res instanceof Promise)
  3497. yield res;
  3498. const finalize = () => {
  3499. customInputEl.disabled = false;
  3500. customInputEl.classList.remove("bytm-busy");
  3501. customInputEl.textContent = customInputEl.ariaLabel = customInputEl.title = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action");
  3502. };
  3503. // artificial timeout ftw
  3504. if (Date.now() - startTs < 350)
  3505. setTimeout(finalize, 350 - (Date.now() - startTs));
  3506. else
  3507. finalize();
  3508. }));
  3509. break;
  3510. }
  3511. if (customInputEl && !customInputEl.hasAttribute("aria-label"))
  3512. customInputEl.ariaLabel = t(`feature_desc_${featKey}`);
  3513. ctrlElem.appendChild(customInputEl);
  3514. }
  3515. ftConfElem.appendChild(ctrlElem);
  3516. }
  3517. featuresCont.appendChild(ftConfElem);
  3518. }
  3519. }
  3520. //#region reset inputs on external change
  3521. siteEvents.on("rebuildCfgMenu", (newConfig) => {
  3522. for (const ftKey in featInfo) {
  3523. const ftElem = document.querySelector(`#bytm-ftconf-${ftKey}-input`);
  3524. const labelElem = document.querySelector(`#bytm-ftconf-${ftKey}-label`);
  3525. if (!ftElem)
  3526. continue;
  3527. const ftInfo = featInfo[ftKey];
  3528. const value = newConfig[ftKey];
  3529. if (ftInfo.type === "toggle")
  3530. ftElem.checked = Boolean(value);
  3531. else
  3532. ftElem.value = String(value);
  3533. if (!labelElem)
  3534. continue;
  3535. const unitTxt = ("unit" in ftInfo && typeof ftInfo.unit === "string"
  3536. ? ftInfo.unit
  3537. : ("unit" in ftInfo && typeof ftInfo.unit === "function"
  3538. ? ftInfo.unit(Number(ftElem.value))
  3539. : ""));
  3540. if (ftInfo.type === "slider")
  3541. labelElem.textContent = `${fmtVal(Number(value), ftKey)}${unitTxt}`;
  3542. }
  3543. info("Rebuilt config menu");
  3544. });
  3545. //#region scroll indicator
  3546. const scrollIndicator = document.createElement("img");
  3547. scrollIndicator.id = "bytm-menu-scroll-indicator";
  3548. scrollIndicator.src = yield getResourceUrl("icon-arrow_down");
  3549. scrollIndicator.role = "button";
  3550. scrollIndicator.ariaLabel = scrollIndicator.title = t("scroll_to_bottom");
  3551. featuresCont.appendChild(scrollIndicator);
  3552. scrollIndicator.addEventListener("click", () => {
  3553. const bottomAnchor = document.querySelector("#bytm-menu-bottom-anchor");
  3554. bottomAnchor === null || bottomAnchor === void 0 ? void 0 : bottomAnchor.scrollIntoView({
  3555. behavior: "smooth",
  3556. });
  3557. });
  3558. featuresCont.addEventListener("scroll", (evt) => {
  3559. var _a, _b;
  3560. const scrollPos = (_b = (_a = evt.target) === null || _a === void 0 ? void 0 : _a.scrollTop) !== null && _b !== void 0 ? _b : 0;
  3561. const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator");
  3562. if (!scrollIndicator)
  3563. return;
  3564. if (scrollIndicatorEnabled && scrollPos > scrollIndicatorOffsetThreshold && !scrollIndicator.classList.contains("bytm-hidden")) {
  3565. scrollIndicator.classList.add("bytm-hidden");
  3566. }
  3567. else if (scrollIndicatorEnabled && scrollPos <= scrollIndicatorOffsetThreshold && scrollIndicator.classList.contains("bytm-hidden")) {
  3568. scrollIndicator.classList.remove("bytm-hidden");
  3569. }
  3570. });
  3571. const bottomAnchor = document.createElement("div");
  3572. bottomAnchor.id = "bytm-menu-bottom-anchor";
  3573. featuresCont.appendChild(bottomAnchor);
  3574. //#region finalize
  3575. menuContainer.appendChild(headerElem);
  3576. menuContainer.appendChild(featuresCont);
  3577. const subtitleElemCont = document.createElement("div");
  3578. subtitleElemCont.id = "bytm-menu-subtitle-cont";
  3579. const versionEl = document.createElement("a");
  3580. versionEl.id = "bytm-menu-version-anchor";
  3581. versionEl.classList.add("bytm-link");
  3582. versionEl.role = "button";
  3583. versionEl.tabIndex = 0;
  3584. versionEl.ariaLabel = versionEl.title = t("version_tooltip", scriptInfo.version, buildNumber);
  3585. versionEl.textContent = `v${scriptInfo.version} (#${buildNumber})`;
  3586. onInteraction(versionEl, (e) => __awaiter(this, void 0, void 0, function* () {
  3587. e.preventDefault();
  3588. e.stopPropagation();
  3589. const dlg = yield getChangelogDialog();
  3590. dlg.on("close", openCfgMenu);
  3591. yield dlg.mount();
  3592. closeCfgMenu(undefined, false);
  3593. yield dlg.open();
  3594. }));
  3595. subtitleElemCont.appendChild(versionEl);
  3596. titleElem.appendChild(subtitleElemCont);
  3597. const modeItems = [];
  3598. mode === "development" && modeItems.push("dev_mode");
  3599. getFeature("advancedMode") && modeItems.push("advanced_mode");
  3600. if (modeItems.length > 0) {
  3601. const modeDisplayEl = document.createElement("span");
  3602. modeDisplayEl.id = "bytm-menu-mode-display";
  3603. modeDisplayEl.textContent = `[${t("active_mode_display", arrayWithSeparators(modeItems.map(v => t(`${v}_short`)), ", ", " & "))}]`;
  3604. modeDisplayEl.ariaLabel = modeDisplayEl.title = tp("active_mode_tooltip", modeItems, arrayWithSeparators(modeItems.map(t), ", ", " & "));
  3605. subtitleElemCont.appendChild(modeDisplayEl);
  3606. }
  3607. menuContainer.appendChild(footerCont);
  3608. backgroundElem.appendChild(menuContainer);
  3609. document.body.appendChild(backgroundElem);
  3610. window.addEventListener("resize", UserUtils.debounce(checkToggleScrollIndicator, 250, "rising"));
  3611. log("Added menu element");
  3612. // ensure stuff is reset if menu was opened before being added
  3613. isCfgMenuOpen = false;
  3614. document.body.classList.remove("bytm-disable-scroll");
  3615. (_d = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _d === void 0 ? void 0 : _d.removeAttribute("inert");
  3616. backgroundElem.style.visibility = "hidden";
  3617. backgroundElem.style.display = "none";
  3618. siteEvents.on("recreateCfgMenu", () => __awaiter(this, void 0, void 0, function* () {
  3619. const bgElem = document.querySelector("#bytm-cfg-menu-bg");
  3620. if (!bgElem)
  3621. return;
  3622. closeCfgMenu();
  3623. bgElem.remove();
  3624. isCfgMenuMounted = false;
  3625. yield mountCfgMenu();
  3626. yield openCfgMenu();
  3627. }));
  3628. });
  3629. }
  3630. //#region open & close
  3631. /** Closes the config menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  3632. function closeCfgMenu(evt, enableScroll = true) {
  3633. var _a, _b, _c;
  3634. if (!isCfgMenuOpen)
  3635. return;
  3636. isCfgMenuOpen = false;
  3637. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  3638. if (enableScroll) {
  3639. document.body.classList.remove("bytm-disable-scroll");
  3640. (_a = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert");
  3641. }
  3642. const menuBg = document.querySelector("#bytm-cfg-menu-bg");
  3643. clearTimeout(hiddenCopiedTxtTimeout);
  3644. openDialogs.splice(openDialogs.indexOf("cfg-menu"), 1);
  3645. setCurrentDialogId((_b = openDialogs === null || openDialogs === void 0 ? void 0 : openDialogs[0]) !== null && _b !== void 0 ? _b : null);
  3646. // since this menu doesn't have a BytmDialog instance, it's undefined here
  3647. emitInterface("bytm:dialogClosed", undefined);
  3648. emitInterface("bytm:dialogClosed:cfg-menu", undefined);
  3649. if (!menuBg)
  3650. return warn("Couldn't close config menu because background element couldn't be found. The config menu is considered closed but might still be open. In this case please reload the page. If the issue persists, please create an issue on GitHub.");
  3651. (_c = menuBg.querySelectorAll(".bytm-ftconf-adv-copy-hint")) === null || _c === void 0 ? void 0 : _c.forEach((el) => el.style.display = "none");
  3652. menuBg.style.visibility = "hidden";
  3653. menuBg.style.display = "none";
  3654. }
  3655. /** Opens the config menu if it is closed */
  3656. function openCfgMenu() {
  3657. return __awaiter(this, void 0, void 0, function* () {
  3658. var _a;
  3659. if (!isCfgMenuMounted)
  3660. yield mountCfgMenu();
  3661. if (isCfgMenuOpen)
  3662. return;
  3663. isCfgMenuOpen = true;
  3664. document.body.classList.add("bytm-disable-scroll");
  3665. (_a = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true");
  3666. const menuBg = document.querySelector("#bytm-cfg-menu-bg");
  3667. setCurrentDialogId("cfg-menu");
  3668. openDialogs.unshift("cfg-menu");
  3669. // since this menu doesn't have a BytmDialog instance, it's undefined here
  3670. emitInterface("bytm:dialogOpened", undefined);
  3671. emitInterface("bytm:dialogOpened:cfg-menu", undefined);
  3672. checkToggleScrollIndicator();
  3673. if (!menuBg)
  3674. return warn("Couldn't open config menu because background element couldn't be found. The config menu is considered open but might still be closed. In this case please reload the page. If the issue persists, please create an issue on GitHub.");
  3675. menuBg.style.visibility = "visible";
  3676. menuBg.style.display = "block";
  3677. });
  3678. }
  3679. //#region chk scroll indicator
  3680. /** Checks if the features container is scrollable and toggles the scroll indicator accordingly */
  3681. function checkToggleScrollIndicator() {
  3682. const featuresCont = document.querySelector("#bytm-menu-opts");
  3683. const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator");
  3684. // disable scroll indicator if container doesn't scroll
  3685. if (featuresCont && scrollIndicator) {
  3686. const verticalScroll = UserUtils.isScrollable(featuresCont).vertical;
  3687. /** If true, the indicator's threshold is under the available scrollable space and so it should be disabled */
  3688. const underThreshold = featuresCont.scrollHeight - featuresCont.clientHeight <= scrollIndicatorOffsetThreshold;
  3689. if (!underThreshold && verticalScroll && !scrollIndicatorEnabled) {
  3690. scrollIndicatorEnabled = true;
  3691. scrollIndicator.classList.remove("bytm-hidden");
  3692. }
  3693. if ((!verticalScroll && scrollIndicatorEnabled) || underThreshold) {
  3694. scrollIndicatorEnabled = false;
  3695. scrollIndicator.classList.add("bytm-hidden");
  3696. }
  3697. }
  3698. }var de_DE = {
  3699. name: "Deutsch (Deutschland)",
  3700. nameEnglish: "German",
  3701. emoji: "🇩🇪",
  3702. userscriptDesc: "Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™",
  3703. authors: [
  3704. "Sv443"
  3705. ]
  3706. };
  3707. var en_US = {
  3708. name: "English (United States)",
  3709. nameEnglish: "English (US)",
  3710. emoji: "🇺🇸",
  3711. userscriptDesc: "Configurable layout and user experience improvements for YouTube Music™ and YouTube™",
  3712. authors: [
  3713. "Sv443"
  3714. ]
  3715. };
  3716. var en_UK = {
  3717. name: "English (United Kingdom)",
  3718. nameEnglish: "English (UK)",
  3719. emoji: "🇬🇧",
  3720. userscriptDesc: "Configurable layout and user experience improvements for YouTube Music™ and YouTube™",
  3721. authors: [
  3722. "Sv443"
  3723. ]
  3724. };
  3725. var es_ES = {
  3726. name: "Español (España)",
  3727. nameEnglish: "Spanish",
  3728. emoji: "🇪🇸",
  3729. userscriptDesc: "Mejoras de diseño y experiencia de usuario configurables para YouTube Music™ y YouTube™",
  3730. authors: [
  3731. "Sv443"
  3732. ]
  3733. };
  3734. var fr_FR = {
  3735. name: "Français (France)",
  3736. nameEnglish: "French",
  3737. emoji: "🇫🇷",
  3738. userscriptDesc: "Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music™ et YouTube™",
  3739. authors: [
  3740. "Sv443"
  3741. ]
  3742. };
  3743. var hi_IN = {
  3744. name: "हिंदी (भारत)",
  3745. nameEnglish: "Hindi",
  3746. emoji: "🇮🇳",
  3747. userscriptDesc: "YouTube Music™ और YouTube™ के लिए कॉन्फ़िगर करने योग्य लेआउट और उपयोगकर्ता अनुभव में सुधार",
  3748. authors: [
  3749. "Sv443"
  3750. ]
  3751. };
  3752. var ja_JA = {
  3753. name: "日本語 (日本)",
  3754. nameEnglish: "Japanese",
  3755. emoji: "🇯🇵",
  3756. userscriptDesc: "YouTube Music™ と YouTube™ の構成可能なレイアウトとユーザー エクスペリエンスの向上",
  3757. authors: [
  3758. "Sv443"
  3759. ]
  3760. };
  3761. var pt_BR = {
  3762. name: "Português (Brasil)",
  3763. nameEnglish: "Portuguese",
  3764. emoji: "🇵🇹",
  3765. userscriptDesc: "Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music™ e o YouTube™",
  3766. authors: [
  3767. "Sv443"
  3768. ]
  3769. };
  3770. var zh_CN = {
  3771. name: "中文(简化,中国)",
  3772. nameEnglish: "Chinese (simpl.)",
  3773. emoji: "🇨🇳",
  3774. userscriptDesc: "YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进",
  3775. authors: [
  3776. "Sv443"
  3777. ]
  3778. };
  3779. var langMapping = {
  3780. de_DE: de_DE,
  3781. en_US: en_US,
  3782. en_UK: en_UK,
  3783. es_ES: es_ES,
  3784. fr_FR: fr_FR,
  3785. hi_IN: hi_IN,
  3786. ja_JA: ja_JA,
  3787. pt_BR: pt_BR,
  3788. zh_CN: zh_CN
  3789. };let welcomeDialog = null;
  3790. /** Creates and/or returns the import dialog */
  3791. function getWelcomeDialog() {
  3792. return __awaiter(this, void 0, void 0, function* () {
  3793. if (!welcomeDialog) {
  3794. welcomeDialog = new BytmDialog({
  3795. id: "welcome",
  3796. width: 700,
  3797. height: 500,
  3798. closeBtnEnabled: true,
  3799. closeOnBgClick: true,
  3800. closeOnEscPress: true,
  3801. destroyOnClose: true,
  3802. renderHeader,
  3803. renderBody,
  3804. renderFooter,
  3805. });
  3806. welcomeDialog.on("render", retranslateWelcomeMenu);
  3807. }
  3808. return welcomeDialog;
  3809. });
  3810. }
  3811. function renderHeader() {
  3812. return __awaiter(this, void 0, void 0, function* () {
  3813. const titleWrapperElem = document.createElement("div");
  3814. titleWrapperElem.id = "bytm-welcome-menu-title-wrapper";
  3815. const titleLogoElem = document.createElement("img");
  3816. titleLogoElem.id = "bytm-welcome-menu-title-logo";
  3817. titleLogoElem.classList.add("bytm-no-select");
  3818. titleLogoElem.src = yield getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  3819. const titleElem = document.createElement("h2");
  3820. titleElem.id = "bytm-welcome-menu-title";
  3821. titleElem.classList.add("bytm-dialog-title");
  3822. titleElem.role = "heading";
  3823. titleElem.ariaLevel = "1";
  3824. titleElem.tabIndex = 0;
  3825. titleWrapperElem.appendChild(titleLogoElem);
  3826. titleWrapperElem.appendChild(titleElem);
  3827. return titleWrapperElem;
  3828. });
  3829. }
  3830. function renderBody() {
  3831. return __awaiter(this, void 0, void 0, function* () {
  3832. const contentWrapper = document.createElement("div");
  3833. contentWrapper.id = "bytm-welcome-menu-content-wrapper";
  3834. // locale switcher
  3835. const localeCont = document.createElement("div");
  3836. localeCont.id = "bytm-welcome-menu-locale-cont";
  3837. const localeImg = document.createElement("img");
  3838. localeImg.id = "bytm-welcome-menu-locale-img";
  3839. localeImg.classList.add("bytm-no-select");
  3840. localeImg.src = yield getResourceUrl("icon-globe");
  3841. const localeSelectElem = document.createElement("select");
  3842. localeSelectElem.id = "bytm-welcome-menu-locale-select";
  3843. for (const [locale, { name }] of Object.entries(langMapping)) {
  3844. const localeOptionElem = document.createElement("option");
  3845. localeOptionElem.value = locale;
  3846. localeOptionElem.textContent = name;
  3847. localeSelectElem.appendChild(localeOptionElem);
  3848. }
  3849. localeSelectElem.value = getFeature("locale");
  3850. localeSelectElem.addEventListener("change", () => __awaiter(this, void 0, void 0, function* () {
  3851. const selectedLocale = localeSelectElem.value;
  3852. const feats = Object.assign({}, getFeatures());
  3853. feats.locale = selectedLocale;
  3854. setFeatures(feats);
  3855. yield initTranslations(selectedLocale);
  3856. setLocale(selectedLocale);
  3857. retranslateWelcomeMenu();
  3858. }));
  3859. localeCont.appendChild(localeImg);
  3860. localeCont.appendChild(localeSelectElem);
  3861. contentWrapper.appendChild(localeCont);
  3862. // text
  3863. const textCont = document.createElement("div");
  3864. textCont.id = "bytm-welcome-menu-text-cont";
  3865. const textElem = document.createElement("p");
  3866. textElem.id = "bytm-welcome-menu-text";
  3867. const textElems = [];
  3868. const line1Elem = document.createElement("span");
  3869. line1Elem.id = "bytm-welcome-text-line1";
  3870. line1Elem.tabIndex = 0;
  3871. textElems.push(line1Elem);
  3872. const br1Elem = document.createElement("br");
  3873. textElems.push(br1Elem);
  3874. const line2Elem = document.createElement("span");
  3875. line2Elem.id = "bytm-welcome-text-line2";
  3876. line2Elem.tabIndex = 0;
  3877. textElems.push(line2Elem);
  3878. const br2Elem = document.createElement("br");
  3879. textElems.push(br2Elem);
  3880. const br3Elem = document.createElement("br");
  3881. textElems.push(br3Elem);
  3882. const line3Elem = document.createElement("span");
  3883. line3Elem.id = "bytm-welcome-text-line3";
  3884. line3Elem.tabIndex = 0;
  3885. textElems.push(line3Elem);
  3886. const br4Elem = document.createElement("br");
  3887. textElems.push(br4Elem);
  3888. const line4Elem = document.createElement("span");
  3889. line4Elem.id = "bytm-welcome-text-line4";
  3890. line4Elem.tabIndex = 0;
  3891. textElems.push(line4Elem);
  3892. const br5Elem = document.createElement("br");
  3893. textElems.push(br5Elem);
  3894. const br6Elem = document.createElement("br");
  3895. textElems.push(br6Elem);
  3896. const line5Elem = document.createElement("span");
  3897. line5Elem.id = "bytm-welcome-text-line5";
  3898. line5Elem.tabIndex = 0;
  3899. textElems.push(line5Elem);
  3900. textElems.forEach((elem) => textElem.appendChild(elem));
  3901. textCont.appendChild(textElem);
  3902. contentWrapper.appendChild(textCont);
  3903. return contentWrapper;
  3904. });
  3905. }
  3906. /** Retranslates all elements inside the welcome menu */
  3907. function retranslateWelcomeMenu() {
  3908. const getLink = (href) => {
  3909. return [`<a href="${href}" class="bytm-link" target="_blank" rel="noopener noreferrer">`, "</a>"];
  3910. };
  3911. const changes = {
  3912. "#bytm-welcome-menu-title": (e) => e.textContent = e.ariaLabel = t("welcome_menu_title", scriptInfo.name),
  3913. "#bytm-welcome-menu-title-close": (e) => e.ariaLabel = e.title = t("close_menu_tooltip"),
  3914. "#bytm-welcome-menu-open-cfg": (e) => {
  3915. e.textContent = e.ariaLabel = t("config_menu");
  3916. e.ariaLabel = e.title = t("open_config_menu_tooltip");
  3917. },
  3918. "#bytm-welcome-menu-open-changelog": (e) => {
  3919. e.textContent = e.ariaLabel = t("open_changelog");
  3920. e.ariaLabel = e.title = t("open_changelog_tooltip");
  3921. },
  3922. "#bytm-welcome-menu-footer-close": (e) => {
  3923. e.textContent = e.ariaLabel = t("close");
  3924. e.ariaLabel = e.title = t("close_menu_tooltip");
  3925. },
  3926. "#bytm-welcome-text-line1": (e) => e.innerHTML = e.ariaLabel = t("welcome_text_line_1"),
  3927. "#bytm-welcome-text-line2": (e) => e.innerHTML = e.ariaLabel = t("welcome_text_line_2", scriptInfo.name),
  3928. "#bytm-welcome-text-line3": (e) => e.innerHTML = e.ariaLabel = t("welcome_text_line_3", scriptInfo.name, ...getLink(`${pkg.hosts.greasyfork}/feedback`), ...getLink(pkg.hosts.openuserjs)),
  3929. "#bytm-welcome-text-line4": (e) => e.innerHTML = e.ariaLabel = t("welcome_text_line_4", ...getLink(pkg.funding.url)),
  3930. "#bytm-welcome-text-line5": (e) => e.innerHTML = e.ariaLabel = t("welcome_text_line_5", ...getLink(pkg.bugs.url)),
  3931. };
  3932. for (const [selector, fn] of Object.entries(changes)) {
  3933. const el = document.querySelector(selector);
  3934. if (!el) {
  3935. warn(`Couldn't find element in welcome menu with selector '${selector}'`);
  3936. continue;
  3937. }
  3938. fn(el);
  3939. }
  3940. }
  3941. function renderFooter() {
  3942. return __awaiter(this, void 0, void 0, function* () {
  3943. const footerCont = document.createElement("div");
  3944. footerCont.id = "bytm-welcome-menu-footer-cont";
  3945. const openCfgElem = document.createElement("button");
  3946. openCfgElem.id = "bytm-welcome-menu-open-cfg";
  3947. openCfgElem.classList.add("bytm-btn");
  3948. openCfgElem.addEventListener("click", () => {
  3949. welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close();
  3950. openCfgMenu();
  3951. });
  3952. const openChangelogElem = document.createElement("button");
  3953. openChangelogElem.id = "bytm-welcome-menu-open-changelog";
  3954. openChangelogElem.classList.add("bytm-btn");
  3955. openChangelogElem.addEventListener("click", () => __awaiter(this, void 0, void 0, function* () {
  3956. const dlg = yield getChangelogDialog();
  3957. yield dlg.mount();
  3958. welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close();
  3959. yield dlg.open();
  3960. }));
  3961. const closeBtnElem = document.createElement("button");
  3962. closeBtnElem.id = "bytm-welcome-menu-footer-close";
  3963. closeBtnElem.classList.add("bytm-btn");
  3964. closeBtnElem.addEventListener("click", () => __awaiter(this, void 0, void 0, function* () {
  3965. welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close();
  3966. }));
  3967. const leftButtonsCont = document.createElement("div");
  3968. leftButtonsCont.id = "bytm-menu-footer-left-buttons-cont";
  3969. leftButtonsCont.appendChild(openCfgElem);
  3970. leftButtonsCont.appendChild(openChangelogElem);
  3971. footerCont.appendChild(leftButtonsCont);
  3972. footerCont.appendChild(closeBtnElem);
  3973. return footerCont;
  3974. });
  3975. }const releaseURL = "https://github.com/Sv443/BetterYTM/releases/latest";
  3976. /** Initializes the version check feature */
  3977. function initVersionCheck() {
  3978. return __awaiter(this, void 0, void 0, function* () {
  3979. try {
  3980. if (getFeature("versionCheck") === false)
  3981. return info("Version check is disabled");
  3982. const lastCheck = yield GM.getValue("bytm-version-check", 0);
  3983. if (Date.now() - lastCheck < 1000 * 60 * 60 * 24)
  3984. return;
  3985. yield doVersionCheck(false);
  3986. }
  3987. catch (err) {
  3988. error("Version check failed:", err);
  3989. }
  3990. });
  3991. }
  3992. /**
  3993. * Checks for a new version of the script and shows a dialog.
  3994. * If {@linkcode notifyNoUpdatesFound} is set to true, a dialog is also shown if no updates were found.
  3995. */
  3996. function doVersionCheck() {
  3997. return __awaiter(this, arguments, void 0, function* (notifyNoUpdatesFound = false) {
  3998. var _a;
  3999. yield GM.setValue("bytm-version-check", Date.now());
  4000. const res = yield sendRequest({
  4001. method: "GET",
  4002. url: releaseURL,
  4003. });
  4004. // TODO: small dialog for "no update found" message?
  4005. const noUpdateFound = () => notifyNoUpdatesFound ? alert(t("no_updates_found")) : undefined;
  4006. const latestTag = (_a = res.finalUrl.split("/").pop()) === null || _a === void 0 ? void 0 : _a.replace(/[a-zA-Z]/g, "");
  4007. if (!latestTag)
  4008. return noUpdateFound();
  4009. info("Version check - current version:", scriptInfo.version, "- latest version:", latestTag, LogLevel.Info);
  4010. if (compareVersions.compare(scriptInfo.version, latestTag, "<")) {
  4011. const dialog = yield getVersionNotifDialog({ latestTag });
  4012. yield dialog.open();
  4013. return;
  4014. }
  4015. return noUpdateFound();
  4016. });
  4017. }//#region cfg menu btns
  4018. let logoExchanged = false, improveLogoCalled = false;
  4019. /** Adds a watermark beneath the logo */
  4020. function addWatermark() {
  4021. return __awaiter(this, void 0, void 0, function* () {
  4022. const watermark = document.createElement("a");
  4023. watermark.role = "button";
  4024. watermark.id = "bytm-watermark";
  4025. watermark.classList.add("style-scope", "ytmusic-nav-bar", "bytm-no-select");
  4026. watermark.textContent = scriptInfo.name;
  4027. watermark.ariaLabel = watermark.title = t("open_menu_tooltip", scriptInfo.name);
  4028. watermark.tabIndex = 0;
  4029. improveLogo();
  4030. const watermarkOpenMenu = (e) => {
  4031. e.stopPropagation();
  4032. if ((!e.shiftKey && !e.ctrlKey) || logoExchanged)
  4033. openCfgMenu();
  4034. if (!logoExchanged && (e.shiftKey || e.ctrlKey))
  4035. exchangeLogo();
  4036. };
  4037. onInteraction(watermark, watermarkOpenMenu);
  4038. addSelectorListener("navBar", "ytmusic-nav-bar #left-content", {
  4039. listener: (logoElem) => logoElem.insertAdjacentElement("afterend", watermark),
  4040. });
  4041. log("Added watermark element");
  4042. });
  4043. }
  4044. /** Turns the regular `<img>`-based logo into inline SVG to be able to animate and modify parts of it */
  4045. function improveLogo() {
  4046. return __awaiter(this, void 0, void 0, function* () {
  4047. try {
  4048. if (improveLogoCalled)
  4049. return;
  4050. improveLogoCalled = true;
  4051. const res = yield UserUtils.fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg");
  4052. const svg = yield res.text();
  4053. addSelectorListener("navBar", "ytmusic-logo a", {
  4054. listener: (logoElem) => {
  4055. var _a;
  4056. logoElem.classList.add("bytm-mod-logo", "bytm-no-select");
  4057. logoElem.innerHTML = svg;
  4058. logoElem.querySelectorAll("ellipse").forEach((e) => {
  4059. e.classList.add("bytm-mod-logo-ellipse");
  4060. });
  4061. (_a = logoElem.querySelector("path")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-mod-logo-path");
  4062. log("Swapped logo to inline SVG");
  4063. },
  4064. });
  4065. }
  4066. catch (err) {
  4067. error("Couldn't improve logo due to an error:", err);
  4068. }
  4069. });
  4070. }
  4071. /** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */
  4072. function exchangeLogo() {
  4073. addSelectorListener("navBar", ".bytm-mod-logo", {
  4074. listener: (logoElem) => __awaiter(this, void 0, void 0, function* () {
  4075. if (logoElem.classList.contains("bytm-logo-exchanged"))
  4076. return;
  4077. logoExchanged = true;
  4078. logoElem.classList.add("bytm-logo-exchanged");
  4079. const iconUrl = yield getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  4080. const newLogo = document.createElement("img");
  4081. newLogo.classList.add("bytm-mod-logo-img");
  4082. newLogo.src = iconUrl;
  4083. logoElem.insertBefore(newLogo, logoElem.querySelector("svg"));
  4084. document.head.querySelectorAll("link[rel=\"icon\"]").forEach((e) => {
  4085. e.href = iconUrl;
  4086. });
  4087. setTimeout(() => {
  4088. logoElem.querySelectorAll(".bytm-mod-logo-ellipse").forEach(e => e.remove());
  4089. }, 1000);
  4090. }),
  4091. });
  4092. }
  4093. /** Called whenever the avatar popover menu exists on YTM to add a BYTM config menu button to the user menu popover */
  4094. function addConfigMenuOptionYTM(container) {
  4095. return __awaiter(this, void 0, void 0, function* () {
  4096. const cfgOptElem = document.createElement("div");
  4097. cfgOptElem.classList.add("bytm-cfg-menu-option");
  4098. const cfgOptItemElem = document.createElement("div");
  4099. cfgOptItemElem.classList.add("bytm-cfg-menu-option-item");
  4100. cfgOptItemElem.role = "button";
  4101. cfgOptItemElem.tabIndex = 0;
  4102. cfgOptItemElem.ariaLabel = cfgOptItemElem.title = t("open_menu_tooltip", scriptInfo.name);
  4103. onInteraction(cfgOptItemElem, (e) => __awaiter(this, void 0, void 0, function* () {
  4104. const settingsBtnElem = document.querySelector("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button");
  4105. settingsBtnElem === null || settingsBtnElem === void 0 ? void 0 : settingsBtnElem.click();
  4106. yield UserUtils.pauseFor(20);
  4107. if ((!e.shiftKey && !e.ctrlKey) || logoExchanged)
  4108. openCfgMenu();
  4109. if (!logoExchanged && (e.shiftKey || e.ctrlKey))
  4110. exchangeLogo();
  4111. }));
  4112. const cfgOptIconElem = document.createElement("img");
  4113. cfgOptIconElem.classList.add("bytm-cfg-menu-option-icon");
  4114. cfgOptIconElem.src = yield getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  4115. const cfgOptTextElem = document.createElement("div");
  4116. cfgOptTextElem.classList.add("bytm-cfg-menu-option-text");
  4117. cfgOptTextElem.textContent = t("config_menu_option", scriptInfo.name);
  4118. cfgOptItemElem.appendChild(cfgOptIconElem);
  4119. cfgOptItemElem.appendChild(cfgOptTextElem);
  4120. cfgOptElem.appendChild(cfgOptItemElem);
  4121. container.appendChild(cfgOptElem);
  4122. improveLogo();
  4123. log("Added BYTM-Configuration button to menu popover");
  4124. });
  4125. }
  4126. /** Called whenever the titlebar (masthead) exists on YT to add a BYTM config menu button */
  4127. function addConfigMenuOptionYT(container) {
  4128. return __awaiter(this, void 0, void 0, function* () {
  4129. const cfgOptWrapperElem = document.createElement("div");
  4130. cfgOptWrapperElem.classList.add("bytm-yt-cfg-menu-option", "darkreader-ignore");
  4131. cfgOptWrapperElem.role = "button";
  4132. cfgOptWrapperElem.tabIndex = 0;
  4133. cfgOptWrapperElem.ariaLabel = cfgOptWrapperElem.title = t("open_menu_tooltip", scriptInfo.name);
  4134. const cfgOptElem = document.createElement("div");
  4135. cfgOptElem.classList.add("bytm-yt-cfg-menu-option-inner");
  4136. const cfgOptImgElem = document.createElement("img");
  4137. cfgOptImgElem.classList.add("bytm-yt-cfg-menu-option-icon");
  4138. cfgOptImgElem.src = yield getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  4139. const cfgOptItemElem = document.createElement("div");
  4140. cfgOptItemElem.classList.add("bytm-yt-cfg-menu-option-item");
  4141. cfgOptItemElem.textContent = scriptInfo.name;
  4142. cfgOptElem.appendChild(cfgOptImgElem);
  4143. cfgOptElem.appendChild(cfgOptItemElem);
  4144. cfgOptWrapperElem.appendChild(cfgOptElem);
  4145. onInteraction(cfgOptWrapperElem, openCfgMenu);
  4146. const firstChild = container === null || container === void 0 ? void 0 : container.firstElementChild;
  4147. if (firstChild)
  4148. container.insertBefore(cfgOptWrapperElem, firstChild);
  4149. else
  4150. return error("Couldn't add config menu option to YT titlebar - couldn't find container element");
  4151. });
  4152. }
  4153. //#region anchor impr.
  4154. /** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
  4155. function addAnchorImprovements() {
  4156. return __awaiter(this, void 0, void 0, function* () {
  4157. try {
  4158. const css = yield fetchCss("css-anchor_improvements");
  4159. if (css)
  4160. addStyle(css, "anchor-improvements");
  4161. }
  4162. catch (err) {
  4163. error("Couldn't add anchor improvements CSS due to an error:", err);
  4164. }
  4165. //#region carousel shelves
  4166. try {
  4167. const preventDefault = (e) => e.preventDefault();
  4168. /** Adds anchor improvements to &lt;ytmusic-responsive-list-item-renderer&gt; */
  4169. const addListItemAnchors = (items) => {
  4170. var _a;
  4171. for (const item of items) {
  4172. if (item.classList.contains("bytm-anchor-improved"))
  4173. continue;
  4174. item.classList.add("bytm-anchor-improved");
  4175. const thumbnailElem = item.querySelector(".left-items");
  4176. const titleElem = item.querySelector(".title-column .title a");
  4177. if (!thumbnailElem || !titleElem)
  4178. continue;
  4179. const anchorElem = document.createElement("a");
  4180. anchorElem.classList.add("bytm-anchor", "bytm-carousel-shelf-anchor");
  4181. anchorElem.href = (_a = titleElem === null || titleElem === void 0 ? void 0 : titleElem.href) !== null && _a !== void 0 ? _a : "#";
  4182. anchorElem.target = "_self";
  4183. anchorElem.role = "button";
  4184. anchorElem.addEventListener("click", preventDefault);
  4185. UserUtils.addParent(thumbnailElem, anchorElem);
  4186. }
  4187. };
  4188. // home page
  4189. addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", {
  4190. continuous: true,
  4191. all: true,
  4192. listener: addListItemAnchors,
  4193. });
  4194. // related tab in /watch
  4195. addSelectorListener("body", "ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", {
  4196. continuous: true,
  4197. all: true,
  4198. listener: addListItemAnchors,
  4199. });
  4200. // playlists
  4201. addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", {
  4202. continuous: true,
  4203. all: true,
  4204. listener: addListItemAnchors,
  4205. });
  4206. // generic shelves
  4207. addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", {
  4208. continuous: true,
  4209. all: true,
  4210. listener: addListItemAnchors,
  4211. });
  4212. }
  4213. catch (err) {
  4214. error("Couldn't improve carousel shelf anchors due to an error:", err);
  4215. }
  4216. //#region sidebar
  4217. try {
  4218. const addSidebarAnchors = (sidebarCont) => {
  4219. const items = sidebarCont.parentNode.querySelectorAll("ytmusic-guide-entry-renderer tp-yt-paper-item");
  4220. improveSidebarAnchors(items);
  4221. return items.length;
  4222. };
  4223. addSelectorListener("sideBar", "#contentContainer #guide-content #items ytmusic-guide-entry-renderer", {
  4224. listener: (sidebarCont) => {
  4225. const itemsAmt = addSidebarAnchors(sidebarCont);
  4226. log(`Added anchors around ${itemsAmt} sidebar ${UserUtils.autoPlural("item", itemsAmt)}`);
  4227. },
  4228. });
  4229. addSelectorListener("sideBarMini", "ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", {
  4230. listener: (miniSidebarCont) => {
  4231. const itemsAmt = addSidebarAnchors(miniSidebarCont);
  4232. log(`Added anchors around ${itemsAmt} mini sidebar ${UserUtils.autoPlural("item", itemsAmt)}`);
  4233. },
  4234. });
  4235. }
  4236. catch (err) {
  4237. error("Couldn't add anchors to sidebar items due to an error:", err);
  4238. }
  4239. });
  4240. }
  4241. const sidebarPaths = [
  4242. "/",
  4243. "/explore",
  4244. "/library",
  4245. ];
  4246. /**
  4247. * Adds anchors to the sidebar items so they can be opened in a new tab
  4248. * @param sidebarItem
  4249. */
  4250. function improveSidebarAnchors(sidebarItems) {
  4251. sidebarItems.forEach((item, i) => {
  4252. var _a;
  4253. const anchorElem = document.createElement("a");
  4254. anchorElem.classList.add("bytm-anchor", "bytm-no-select");
  4255. anchorElem.role = "button";
  4256. anchorElem.target = "_self";
  4257. anchorElem.href = (_a = sidebarPaths[i]) !== null && _a !== void 0 ? _a : "#";
  4258. anchorElem.ariaLabel = anchorElem.title = t("middle_click_open_tab");
  4259. anchorElem.addEventListener("click", (e) => {
  4260. e.preventDefault();
  4261. });
  4262. UserUtils.addParent(item, anchorElem);
  4263. });
  4264. }
  4265. //#region share track par.
  4266. /** Removes the ?si tracking parameter from share URLs */
  4267. function initRemShareTrackParam() {
  4268. return __awaiter(this, void 0, void 0, function* () {
  4269. const removeSiParam = (inputElem) => {
  4270. try {
  4271. if (!inputElem.value.match(/(&|\?)si=/i))
  4272. return;
  4273. const url = new URL(inputElem.value);
  4274. url.searchParams.delete("si");
  4275. inputElem.value = String(url);
  4276. log(`Removed tracking parameter from share link -> ${url}`);
  4277. }
  4278. catch (err) {
  4279. warn("Couldn't remove tracking parameter from share link due to error:", err);
  4280. }
  4281. };
  4282. const [sharePanelSel, inputSel] = (() => {
  4283. switch (getDomain()) {
  4284. case "ytm": return ["tp-yt-paper-dialog ytmusic-unified-share-panel-renderer", "input#share-url"];
  4285. case "yt": return ["ytd-unified-share-panel-renderer", "input#share-url"];
  4286. }
  4287. })();
  4288. addSelectorListener("body", sharePanelSel, {
  4289. listener: (sharePanelEl) => {
  4290. const obs = new MutationObserver(() => {
  4291. dbg("># TODO:#DBG share panel changed");
  4292. const inputElem = sharePanelEl.querySelector(inputSel);
  4293. inputElem && removeSiParam(inputElem);
  4294. });
  4295. obs.observe(sharePanelEl, {
  4296. childList: true,
  4297. subtree: true,
  4298. characterData: true,
  4299. attributeFilter: ["aria-hidden", "aria-checked", "checked"],
  4300. });
  4301. },
  4302. });
  4303. });
  4304. }
  4305. //#region fix spacing
  4306. /** Applies global CSS to fix various spacings */
  4307. function fixSpacing() {
  4308. return __awaiter(this, void 0, void 0, function* () {
  4309. if (!(yield addStyleFromResource("css-fix_spacing")))
  4310. error("Couldn't fix spacing");
  4311. });
  4312. }
  4313. //#region ab.queue btns
  4314. function initAboveQueueBtns() {
  4315. return __awaiter(this, void 0, void 0, function* () {
  4316. const { scrollToActiveSongBtn, clearQueueBtn } = getFeatures();
  4317. const contBtns = [
  4318. {
  4319. condition: scrollToActiveSongBtn,
  4320. id: "scroll-to-active",
  4321. resourceName: "icon-skip_to",
  4322. titleKey: "scroll_to_playing",
  4323. interaction(evt) {
  4324. return __awaiter(this, void 0, void 0, function* () {
  4325. const activeItem = document.querySelector("#side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]");
  4326. if (!activeItem)
  4327. return;
  4328. activeItem.scrollIntoView({
  4329. behavior: evt.shiftKey ? "instant" : "smooth",
  4330. block: evt.ctrlKey || evt.altKey ? "end" : "center",
  4331. inline: "center",
  4332. });
  4333. });
  4334. },
  4335. },
  4336. {
  4337. condition: clearQueueBtn,
  4338. id: "clear-queue",
  4339. resourceName: "icon-clear_list",
  4340. titleKey: "clear_list",
  4341. interaction(evt) {
  4342. return __awaiter(this, void 0, void 0, function* () {
  4343. try {
  4344. // TODO: better confirmation dialog?
  4345. if (evt.shiftKey || confirm(t("clear_list_confirm"))) {
  4346. const url = new URL(location.href);
  4347. url.searchParams.delete("list");
  4348. url.searchParams.set("time_continue", String(yield getVideoTime(0)));
  4349. location.assign(url);
  4350. }
  4351. }
  4352. catch (err) {
  4353. error("Couldn't clear queue due to an error:", err);
  4354. }
  4355. });
  4356. },
  4357. },
  4358. ];
  4359. if (!contBtns.some(b => Boolean(b.condition)))
  4360. return;
  4361. addSelectorListener("sidePanel", "ytmusic-tab-renderer ytmusic-queue-header-renderer #buttons", {
  4362. listener(rightBtnsEl) {
  4363. return __awaiter(this, void 0, void 0, function* () {
  4364. try {
  4365. const aboveQueueBtnCont = document.createElement("div");
  4366. aboveQueueBtnCont.id = "bytm-above-queue-btn-cont";
  4367. UserUtils.addParent(rightBtnsEl, aboveQueueBtnCont);
  4368. const headerEl = rightBtnsEl.closest("ytmusic-queue-header-renderer");
  4369. if (!headerEl)
  4370. return error("Couldn't find queue header element while adding above queue buttons");
  4371. siteEvents.on("fullscreenToggled", (isFullscreen) => {
  4372. headerEl.classList[isFullscreen ? "add" : "remove"]("hidden");
  4373. });
  4374. if (!(yield addStyleFromResource("css-above_queue_btns")))
  4375. return error("Couldn't add CSS for above queue buttons");
  4376. const wrapperElem = document.createElement("div");
  4377. wrapperElem.id = "bytm-above-queue-btn-wrapper";
  4378. for (const item of contBtns) {
  4379. if (Boolean(item.condition) === false)
  4380. continue;
  4381. const btnElem = yield createCircularBtn({
  4382. resourceName: item.resourceName,
  4383. onClick: item.interaction,
  4384. title: t(item.titleKey),
  4385. });
  4386. btnElem.id = `bytm-${item.id}-btn`;
  4387. btnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-above-queue-btn");
  4388. wrapperElem.appendChild(btnElem);
  4389. }
  4390. rightBtnsEl.insertAdjacentElement("beforebegin", wrapperElem);
  4391. }
  4392. catch (err) {
  4393. error("Couldn't add above queue buttons due to an error:", err);
  4394. }
  4395. });
  4396. },
  4397. });
  4398. });
  4399. }
  4400. //#region thumb.overlay
  4401. /** To be changed when the toggle button is pressed - used to invert the state of "showOverlay" */
  4402. let invertOverlay = false;
  4403. function initThumbnailOverlay() {
  4404. return __awaiter(this, void 0, void 0, function* () {
  4405. const toggleBtnShown = getFeature("thumbnailOverlayToggleBtnShown");
  4406. if (getFeature("thumbnailOverlayBehavior") === "never" && !toggleBtnShown)
  4407. return;
  4408. // so the script init doesn't keep waiting until a /watch page is loaded
  4409. waitVideoElementReady().then(() => {
  4410. const playerSelector = "ytmusic-player#player";
  4411. const playerEl = document.querySelector(playerSelector);
  4412. if (!playerEl)
  4413. return error("Couldn't find video player element while adding thumbnail overlay");
  4414. /** Checks and updates the overlay and toggle button states based on the current song type (yt video or ytm song) */
  4415. const updateOverlayVisibility = () => __awaiter(this, void 0, void 0, function* () {
  4416. if (!domLoaded)
  4417. return;
  4418. const behavior = getFeature("thumbnailOverlayBehavior");
  4419. let showOverlay = behavior === "always";
  4420. const isVideo = currentMediaType() === "video";
  4421. if (behavior === "videosOnly" && isVideo)
  4422. showOverlay = true;
  4423. else if (behavior === "songsOnly" && !isVideo)
  4424. showOverlay = true;
  4425. showOverlay = invertOverlay ? !showOverlay : showOverlay;
  4426. const overlayElem = document.querySelector("#bytm-thumbnail-overlay");
  4427. const thumbElem = document.querySelector("#bytm-thumbnail-overlay-img");
  4428. const indicatorElem = document.querySelector("#bytm-thumbnail-overlay-indicator");
  4429. if (overlayElem)
  4430. overlayElem.style.display = showOverlay ? "block" : "none";
  4431. if (thumbElem)
  4432. thumbElem.ariaHidden = String(!showOverlay);
  4433. if (indicatorElem) {
  4434. indicatorElem.style.display = showOverlay ? "block" : "none";
  4435. indicatorElem.ariaHidden = String(!showOverlay);
  4436. }
  4437. if (getFeature("thumbnailOverlayToggleBtnShown")) {
  4438. addSelectorListener("playerBarMiddleButtons", "#bytm-thumbnail-overlay-toggle", {
  4439. listener(toggleBtnElem) {
  4440. return __awaiter(this, void 0, void 0, function* () {
  4441. const toggleBtnImgElem = toggleBtnElem.querySelector("img");
  4442. if (toggleBtnImgElem)
  4443. toggleBtnImgElem.src = yield getResourceUrl(`icon-image${showOverlay ? "_filled" : ""}`);
  4444. if (toggleBtnElem)
  4445. toggleBtnElem.ariaLabel = toggleBtnElem.title = t(`thumbnail_overlay_toggle_btn_tooltip${showOverlay ? "_hide" : "_show"}`);
  4446. });
  4447. },
  4448. });
  4449. }
  4450. });
  4451. const applyThumbUrl = (watchId) => __awaiter(this, void 0, void 0, function* () {
  4452. const thumbUrl = yield getBestThumbnailUrl(watchId);
  4453. if (thumbUrl) {
  4454. const toggleBtnElem = document.querySelector("#bytm-thumbnail-overlay-toggle");
  4455. const thumbImgElem = document.querySelector("#bytm-thumbnail-overlay-img");
  4456. if (toggleBtnElem)
  4457. toggleBtnElem.href = thumbUrl;
  4458. if (thumbImgElem)
  4459. thumbImgElem.src = thumbUrl;
  4460. log("Applied thumbnail URL to overlay:", thumbUrl);
  4461. }
  4462. else
  4463. error("Couldn't get thumbnail URL for watch ID", watchId);
  4464. });
  4465. const unsubWatchIdChanged = siteEvents.on("watchIdChanged", (watchId) => {
  4466. unsubWatchIdChanged();
  4467. addSelectorListener("body", "#bytm-thumbnail-overlay", {
  4468. listener: () => {
  4469. applyThumbUrl(watchId);
  4470. updateOverlayVisibility();
  4471. },
  4472. });
  4473. });
  4474. const createElements = () => __awaiter(this, void 0, void 0, function* () {
  4475. // overlay
  4476. const overlayElem = document.createElement("div");
  4477. overlayElem.id = "bytm-thumbnail-overlay";
  4478. overlayElem.title = ""; // prevent child titles from propagating
  4479. overlayElem.classList.add("bytm-no-select");
  4480. overlayElem.style.display = "none";
  4481. let indicatorElem;
  4482. if (getFeature("thumbnailOverlayShowIndicator")) {
  4483. indicatorElem = document.createElement("img");
  4484. indicatorElem.id = "bytm-thumbnail-overlay-indicator";
  4485. indicatorElem.src = yield getResourceUrl("icon-image");
  4486. indicatorElem.role = "presentation";
  4487. indicatorElem.title = indicatorElem.ariaLabel = t("thumbnail_overlay_indicator_tooltip");
  4488. indicatorElem.ariaHidden = "true";
  4489. indicatorElem.style.display = "none";
  4490. indicatorElem.style.opacity = String(getFeature("thumbnailOverlayIndicatorOpacity") / 100);
  4491. }
  4492. const thumbImgElem = document.createElement("img");
  4493. thumbImgElem.id = "bytm-thumbnail-overlay-img";
  4494. thumbImgElem.role = "presentation";
  4495. thumbImgElem.ariaHidden = "true";
  4496. thumbImgElem.style.objectFit = getFeature("thumbnailOverlayImageFit");
  4497. overlayElem.appendChild(thumbImgElem);
  4498. playerEl.appendChild(overlayElem);
  4499. indicatorElem && playerEl.appendChild(indicatorElem);
  4500. siteEvents.on("watchIdChanged", (watchId) => __awaiter(this, void 0, void 0, function* () {
  4501. invertOverlay = false;
  4502. applyThumbUrl(watchId);
  4503. updateOverlayVisibility();
  4504. }));
  4505. const params = new URL(location.href).searchParams;
  4506. if (params.has("v")) {
  4507. applyThumbUrl(params.get("v"));
  4508. updateOverlayVisibility();
  4509. }
  4510. // toggle button
  4511. if (toggleBtnShown) {
  4512. const toggleBtnElem = createRipple(document.createElement("a"));
  4513. toggleBtnElem.id = "bytm-thumbnail-overlay-toggle";
  4514. toggleBtnElem.role = "button";
  4515. toggleBtnElem.tabIndex = 0;
  4516. toggleBtnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-no-select");
  4517. onInteraction(toggleBtnElem, (e) => {
  4518. if (e.shiftKey)
  4519. return openInTab(toggleBtnElem.href, false);
  4520. invertOverlay = !invertOverlay;
  4521. updateOverlayVisibility();
  4522. });
  4523. const imgElem = document.createElement("img");
  4524. imgElem.classList.add("bytm-generic-btn-img");
  4525. toggleBtnElem.appendChild(imgElem);
  4526. addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", {
  4527. listener: (likeContainer) => likeContainer.insertAdjacentElement("afterend", toggleBtnElem),
  4528. });
  4529. }
  4530. log("Added thumbnail overlay");
  4531. });
  4532. addSelectorListener("mainPanel", playerSelector, {
  4533. listener(playerEl) {
  4534. if (playerEl.getAttribute("player-ui-state") === "INACTIVE") {
  4535. const obs = new MutationObserver(() => {
  4536. if (playerEl.getAttribute("player-ui-state") === "INACTIVE")
  4537. return;
  4538. createElements();
  4539. obs.disconnect();
  4540. });
  4541. obs.observe(playerEl, {
  4542. attributes: true,
  4543. attributeFilter: ["player-ui-state"],
  4544. });
  4545. }
  4546. else
  4547. createElements();
  4548. },
  4549. });
  4550. });
  4551. });
  4552. }
  4553. //#region idle hide cursor
  4554. function initHideCursorOnIdle() {
  4555. return __awaiter(this, void 0, void 0, function* () {
  4556. addSelectorListener("mainPanel", "ytmusic-player#player", {
  4557. listener(vidContainer) {
  4558. const overlaySelector = "ytmusic-player #song-media-window";
  4559. const overlayElem = document.querySelector(overlaySelector);
  4560. if (!overlayElem)
  4561. return warn("Couldn't find overlay element while initializing cursor hiding");
  4562. /** Timer after which the cursor is hidden */
  4563. let cursorHideTimer;
  4564. /** Timer for the opacity transition while switching to the hidden state */
  4565. let hideTransTimer;
  4566. const hide = () => {
  4567. if (!getFeature("hideCursorOnIdle"))
  4568. return;
  4569. if (vidContainer.classList.contains("bytm-cursor-hidden"))
  4570. return;
  4571. overlayElem.style.opacity = ".000001 !important";
  4572. hideTransTimer = setTimeout(() => {
  4573. overlayElem.style.display = "none";
  4574. vidContainer.style.cursor = "none";
  4575. vidContainer.classList.add("bytm-cursor-hidden");
  4576. hideTransTimer = undefined;
  4577. }, 200);
  4578. };
  4579. const show = () => {
  4580. hideTransTimer && clearTimeout(hideTransTimer);
  4581. if (!vidContainer.classList.contains("bytm-cursor-hidden"))
  4582. return;
  4583. vidContainer.classList.remove("bytm-cursor-hidden");
  4584. vidContainer.style.cursor = "initial";
  4585. overlayElem.style.display = "initial";
  4586. overlayElem.style.opacity = "1 !important";
  4587. };
  4588. const cursorHideTimerCb = () => cursorHideTimer = setTimeout(hide, getFeature("hideCursorOnIdleDelay") * 1000);
  4589. const onMove = () => {
  4590. cursorHideTimer && clearTimeout(cursorHideTimer);
  4591. show();
  4592. cursorHideTimerCb();
  4593. };
  4594. vidContainer.addEventListener("mouseenter", onMove);
  4595. vidContainer.addEventListener("mousemove", UserUtils.debounce(onMove, 200, "rising"));
  4596. vidContainer.addEventListener("mouseleave", () => {
  4597. cursorHideTimer && clearTimeout(cursorHideTimer);
  4598. hideTransTimer && clearTimeout(hideTransTimer);
  4599. hide();
  4600. });
  4601. vidContainer.addEventListener("click", () => {
  4602. show();
  4603. cursorHideTimerCb();
  4604. setTimeout(hide, 3000);
  4605. });
  4606. log("Initialized cursor hiding on idle");
  4607. },
  4608. });
  4609. });
  4610. }
  4611. //#region fix HDR
  4612. /** Prevents visual issues when using HDR */
  4613. function fixHdrIssues() {
  4614. return __awaiter(this, void 0, void 0, function* () {
  4615. if (!(yield addStyleFromResource("css-fix_hdr")))
  4616. error("Couldn't load stylesheet to fix HDR issues");
  4617. else
  4618. log("Fixed HDR issues");
  4619. });
  4620. }
  4621. //#region show vote nums
  4622. /** Shows the amount of likes and dislikes on the current song */
  4623. function initShowVotes() {
  4624. return __awaiter(this, void 0, void 0, function* () {
  4625. addSelectorListener("playerBar", ".middle-controls-buttons ytmusic-like-button-renderer", {
  4626. listener(voteCont) {
  4627. return __awaiter(this, void 0, void 0, function* () {
  4628. try {
  4629. const watchId = getWatchId();
  4630. if (!watchId) {
  4631. yield siteEvents.once("watchIdChanged");
  4632. return initShowVotes();
  4633. }
  4634. const voteObj = yield fetchVideoVotes(watchId);
  4635. if (!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj))
  4636. return error("Couldn't fetch votes from the Return YouTube Dislike API");
  4637. if (getFeature("showVotes")) {
  4638. addVoteNumbers(voteCont, voteObj);
  4639. siteEvents.on("watchIdChanged", (watchId) => __awaiter(this, void 0, void 0, function* () {
  4640. var _a, _b;
  4641. const labelLikes = document.querySelector("ytmusic-like-button-renderer .bytm-vote-label.likes");
  4642. const labelDislikes = document.querySelector("ytmusic-like-button-renderer .bytm-vote-label.dislikes");
  4643. if (!labelLikes || !labelDislikes)
  4644. return error("Couldn't find vote label elements while updating like and dislike counts");
  4645. if (labelLikes.dataset.watchId === watchId && labelDislikes.dataset.watchId === watchId)
  4646. return log("Vote labels already updated for this video");
  4647. const voteObj = yield fetchVideoVotes(watchId);
  4648. if (!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj))
  4649. return error("Couldn't fetch votes from the Return YouTube Dislike API");
  4650. labelLikes.dataset.watchId = (_a = getWatchId()) !== null && _a !== void 0 ? _a : "";
  4651. labelLikes.textContent = formatVoteNumber(voteObj.likes);
  4652. labelLikes.title = labelLikes.ariaLabel = tp("vote_label_likes", voteObj.likes, formatVoteNumber(voteObj.likes, "long"));
  4653. labelDislikes.textContent = formatVoteNumber(voteObj.dislikes);
  4654. labelDislikes.title = labelDislikes.ariaLabel = tp("vote_label_dislikes", voteObj.dislikes, formatVoteNumber(voteObj.dislikes, "long"));
  4655. labelDislikes.dataset.watchId = (_b = getWatchId()) !== null && _b !== void 0 ? _b : "";
  4656. }));
  4657. }
  4658. }
  4659. catch (err) {
  4660. error("Couldn't initialize show votes feature due to an error:", err);
  4661. }
  4662. });
  4663. }
  4664. });
  4665. });
  4666. }
  4667. function addVoteNumbers(voteCont, voteObj) {
  4668. const likeBtn = voteCont.querySelector("#button-shape-like");
  4669. const dislikeBtn = voteCont.querySelector("#button-shape-dislike");
  4670. if (!likeBtn || !dislikeBtn)
  4671. return error("Couldn't find like or dislike button while adding vote numbers");
  4672. const createLabel = (amount, type) => {
  4673. var _a;
  4674. const label = document.createElement("span");
  4675. label.classList.add("bytm-vote-label", "bytm-no-select", type);
  4676. label.textContent = String(formatVoteNumber(amount));
  4677. label.title = label.ariaLabel = tp(`vote_label_${type}`, amount, formatVoteNumber(amount, "long"));
  4678. label.dataset.watchId = (_a = getWatchId()) !== null && _a !== void 0 ? _a : "";
  4679. label.addEventListener("click", (e) => {
  4680. var _a;
  4681. e.preventDefault();
  4682. e.stopPropagation();
  4683. (_a = (type === "likes" ? likeBtn : dislikeBtn).querySelector("button")) === null || _a === void 0 ? void 0 : _a.click();
  4684. });
  4685. return label;
  4686. };
  4687. addStyleFromResource("css-show_votes").catch((e) => error("Couldn't add CSS for show votes feature due to an error:", e));
  4688. const likeLblEl = createLabel(voteObj.likes, "likes");
  4689. likeBtn.insertAdjacentElement("afterend", likeLblEl);
  4690. const dislikeLblEl = createLabel(voteObj.dislikes, "dislikes");
  4691. dislikeBtn.insertAdjacentElement("afterend", dislikeLblEl);
  4692. }
  4693. /** Formats a number formatted based on the config or the passed {@linkcode notation} */
  4694. function formatVoteNumber(num, notation) {
  4695. return num.toLocaleString(getLocale().replace(/_/g, "-"), (notation !== null && notation !== void 0 ? notation : getFeature("showVotesFormat")) === "short"
  4696. ? {
  4697. notation: "compact",
  4698. compactDisplay: "short",
  4699. maximumFractionDigits: 1,
  4700. }
  4701. : {
  4702. style: "decimal",
  4703. maximumFractionDigits: 0,
  4704. });
  4705. }//#region beforeunload popup
  4706. let beforeUnloadEnabled = true;
  4707. /** Disables the popup before leaving the site */
  4708. function disableBeforeUnload() {
  4709. beforeUnloadEnabled = false;
  4710. info("Disabled popup before leaving the site");
  4711. }
  4712. /** Adds a spy function into `window.__proto__.addEventListener` to selectively discard `beforeunload` event listeners before they can be called by the site */
  4713. function initBeforeUnloadHook() {
  4714. return __awaiter(this, void 0, void 0, function* () {
  4715. UserUtils.interceptWindowEvent("beforeunload", () => !beforeUnloadEnabled);
  4716. });
  4717. }
  4718. //#region auto close toasts
  4719. /** Closes toasts after a set amount of time */
  4720. function initAutoCloseToasts() {
  4721. return __awaiter(this, void 0, void 0, function* () {
  4722. const animTimeout = 300;
  4723. addSelectorListener("popupContainer", "ytmusic-notification-action-renderer", {
  4724. all: true,
  4725. continuous: true,
  4726. listener: (toastContElems) => __awaiter(this, void 0, void 0, function* () {
  4727. try {
  4728. for (const toastContElem of toastContElems) {
  4729. const toastElem = toastContElem.querySelector("tp-yt-paper-toast#toast");
  4730. if (!toastElem || !toastElem.hasAttribute("allow-click-through"))
  4731. continue;
  4732. if (toastElem.classList.contains("bytm-closing"))
  4733. continue;
  4734. toastElem.classList.add("bytm-closing");
  4735. const closeTimeout = Math.max(getFeature("closeToastsTimeout") * 1000 + animTimeout, animTimeout);
  4736. yield UserUtils.pauseFor(closeTimeout);
  4737. toastElem.classList.remove("paper-toast-open");
  4738. toastElem.addEventListener("transitionend", () => {
  4739. toastElem.classList.remove("bytm-closing");
  4740. toastElem.style.display = "none";
  4741. clearNode(toastElem);
  4742. log(`Automatically closed toast after ${getFeature("closeToastsTimeout") * 1000}ms`);
  4743. }, { once: true });
  4744. }
  4745. }
  4746. catch (err) {
  4747. error("Error in automatic toast closing:", err);
  4748. }
  4749. }),
  4750. });
  4751. log("Initialized automatic toast closing");
  4752. });
  4753. }
  4754. let remVidsCache = [];
  4755. /**
  4756. * Remembers the time of the last played video and resumes playback from that time.
  4757. * **Needs to be called *before* DOM is ready!**
  4758. */
  4759. function initRememberSongTime() {
  4760. return __awaiter(this, void 0, void 0, function* () {
  4761. if (getFeature("rememberSongTimeSites") !== "all" && getFeature("rememberSongTimeSites") !== getDomain())
  4762. return;
  4763. const storedDataRaw = yield GM.getValue("bytm-rem-songs");
  4764. if (!storedDataRaw)
  4765. yield GM.setValue("bytm-rem-songs", "[]");
  4766. try {
  4767. remVidsCache = JSON.parse(String(storedDataRaw !== null && storedDataRaw !== void 0 ? storedDataRaw : "[]"));
  4768. }
  4769. catch (err) {
  4770. error("Error parsing stored video time data, defaulting to empty cache:", err);
  4771. yield GM.setValue("bytm-rem-songs", "[]");
  4772. remVidsCache = [];
  4773. }
  4774. log(`Initialized video time restoring with ${remVidsCache.length} initial entr${remVidsCache.length === 1 ? "y" : "ies"}`);
  4775. yield restVidRestoreTime();
  4776. if (!domLoaded)
  4777. document.addEventListener("DOMContentLoaded", restVidStartUpdateLoop);
  4778. else
  4779. restVidStartUpdateLoop();
  4780. });
  4781. }
  4782. /** Tries to restore the time of the currently playing video */
  4783. function restVidRestoreTime() {
  4784. return __awaiter(this, void 0, void 0, function* () {
  4785. if (location.pathname.startsWith("/watch")) {
  4786. const watchID = new URL(location.href).searchParams.get("v");
  4787. if (!watchID)
  4788. return;
  4789. if (initialParams.has("t"))
  4790. return info("Not restoring song time because the URL has the '&t' parameter", LogLevel.Info);
  4791. const entry = remVidsCache.find(entry => entry.watchID === watchID);
  4792. if (entry) {
  4793. if (Date.now() - entry.updateTimestamp > getFeature("rememberSongTimeDuration") * 1000) {
  4794. yield restVidDeleteEntry(entry.watchID);
  4795. return;
  4796. }
  4797. else if (isNaN(Number(entry.songTime)))
  4798. return;
  4799. else {
  4800. let vidElem;
  4801. const doRestoreTime = () => __awaiter(this, void 0, void 0, function* () {
  4802. var _a;
  4803. if (!vidElem)
  4804. vidElem = yield waitVideoElementReady();
  4805. const vidRestoreTime = entry.songTime - ((_a = getFeature("rememberSongTimeReduction")) !== null && _a !== void 0 ? _a : 0);
  4806. vidElem.currentTime = UserUtils.clamp(Math.max(vidRestoreTime, 0), 0, vidElem.duration);
  4807. yield restVidDeleteEntry(entry.watchID);
  4808. info(`Restored ${currentMediaType()} time to ${Math.floor(vidRestoreTime / 60)}m, ${(vidRestoreTime % 60).toFixed(1)}s`, LogLevel.Info);
  4809. });
  4810. if (!domLoaded)
  4811. document.addEventListener("DOMContentLoaded", doRestoreTime);
  4812. else
  4813. doRestoreTime();
  4814. }
  4815. }
  4816. }
  4817. });
  4818. }
  4819. let lastSongTime = -1;
  4820. let remVidCheckTimeout;
  4821. /** Only call once as this calls itself after a timeout! - Updates the currently playing video's entry in GM storage */
  4822. function restVidStartUpdateLoop() {
  4823. return __awaiter(this, void 0, void 0, function* () {
  4824. var _a, _b, _c;
  4825. if (location.pathname.startsWith("/watch")) {
  4826. const songTime = (_a = yield getVideoTime()) !== null && _a !== void 0 ? _a : 0;
  4827. if (songTime === lastSongTime)
  4828. return;
  4829. lastSongTime = songTime;
  4830. const watchID = getWatchId();
  4831. if (!watchID)
  4832. return;
  4833. const paused = (_c = (_b = document.querySelector(getVideoSelector())) === null || _b === void 0 ? void 0 : _b.paused) !== null && _c !== void 0 ? _c : false;
  4834. // don't immediately update to reduce race conditions and only update if the video is playing
  4835. // also it just sounds better if the song starts at the beginning if only a couple seconds have passed
  4836. if (songTime > getFeature("rememberSongTimeMinPlayTime") && !paused) {
  4837. const entry = {
  4838. watchID,
  4839. songTime,
  4840. updateTimestamp: Date.now(),
  4841. };
  4842. yield restVidSetEntry(entry);
  4843. }
  4844. // if the song is rewound to the beginning, update the entry accordingly
  4845. else {
  4846. const entry = remVidsCache.find(entry => entry.watchID === watchID);
  4847. if (entry && songTime <= entry.songTime)
  4848. yield restVidSetEntry(Object.assign(Object.assign({}, entry), { songTime, updateTimestamp: Date.now() }));
  4849. }
  4850. }
  4851. const expiredEntries = remVidsCache.filter(entry => Date.now() - entry.updateTimestamp > getFeature("rememberSongTimeDuration") * 1000);
  4852. for (const entry of expiredEntries)
  4853. yield restVidDeleteEntry(entry.watchID);
  4854. // for no overlapping calls and better error handling:
  4855. if (remVidCheckTimeout)
  4856. clearTimeout(remVidCheckTimeout);
  4857. remVidCheckTimeout = setTimeout(restVidStartUpdateLoop, 1000);
  4858. });
  4859. }
  4860. /** Updates an existing or creates a new entry */
  4861. function restVidSetEntry(data) {
  4862. return __awaiter(this, void 0, void 0, function* () {
  4863. const foundIdx = remVidsCache.findIndex(entry => entry.watchID === data.watchID);
  4864. if (foundIdx >= 0)
  4865. remVidsCache[foundIdx] = data;
  4866. else
  4867. remVidsCache.push(data);
  4868. yield GM.setValue("bytm-rem-songs", JSON.stringify(remVidsCache));
  4869. });
  4870. }
  4871. /** Deletes an entry */
  4872. function restVidDeleteEntry(watchID) {
  4873. return __awaiter(this, void 0, void 0, function* () {
  4874. remVidsCache = [...remVidsCache.filter(entry => entry.watchID !== watchID)];
  4875. yield GM.setValue("bytm-rem-songs", JSON.stringify(remVidsCache));
  4876. });
  4877. }
  4878. //#region disable darkreader
  4879. /** Disables Dark Reader if it is present */
  4880. function disableDarkReader() {
  4881. if (getFeature("disableDarkReaderSites") !== getDomain() && getFeature("disableDarkReaderSites") !== "all")
  4882. return;
  4883. const metaElem = document.createElement("meta");
  4884. metaElem.name = "darkreader-lock";
  4885. metaElem.classList.add("bytm-disable-darkreader");
  4886. document.head.appendChild(metaElem);
  4887. info("Disabled Dark Reader");
  4888. }const inputIgnoreTagNames = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A", "TP-YT-PAPER-SLIDER"];
  4889. //#region arrow key skip
  4890. function initArrowKeySkip() {
  4891. return __awaiter(this, void 0, void 0, function* () {
  4892. document.addEventListener("keydown", (evt) => {
  4893. var _a, _b, _c, _d, _e, _f;
  4894. if (!getFeature("arrowKeySupport"))
  4895. return;
  4896. if (!["ArrowLeft", "ArrowRight"].includes(evt.code))
  4897. return;
  4898. const allowedClasses = ["bytm-generic-btn", "yt-spec-button-shape-next"];
  4899. // discard the event when a (text) input is currently active, like when editing a playlist
  4900. if ((inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "") || ["volume-slider"].includes((_d = (_c = document.activeElement) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : ""))
  4901. && !allowedClasses.some((cls) => { var _a; return (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.classList.contains(cls); }))
  4902. return info(`Captured valid key to skip forward or backward but the current active element is <${(_e = document.activeElement) === null || _e === void 0 ? void 0 : _e.tagName.toLowerCase()}>, so the keypress is ignored`);
  4903. evt.preventDefault();
  4904. evt.stopImmediatePropagation();
  4905. let skipBy = (_f = getFeature("arrowKeySkipBy")) !== null && _f !== void 0 ? _f : featInfo.arrowKeySkipBy.default;
  4906. if (evt.code === "ArrowLeft")
  4907. skipBy *= -1;
  4908. log(`Captured arrow key '${evt.code}' - skipping by ${skipBy} seconds`);
  4909. const vidElem = document.querySelector(getVideoSelector());
  4910. if (vidElem)
  4911. vidElem.currentTime = UserUtils.clamp(vidElem.currentTime + skipBy, 0, vidElem.duration);
  4912. });
  4913. log("Added arrow key press listener");
  4914. });
  4915. }
  4916. //#region site switch
  4917. /** switch sites only if current video time is greater than this value */
  4918. const videoTimeThreshold = 3;
  4919. let siteSwitchEnabled = true;
  4920. /** Initializes the site switch feature */
  4921. function initSiteSwitch(domain) {
  4922. return __awaiter(this, void 0, void 0, function* () {
  4923. document.addEventListener("keydown", (e) => {
  4924. var _a, _b;
  4925. if (!getFeature("switchBetweenSites"))
  4926. return;
  4927. if (inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : ""))
  4928. return;
  4929. const hk = getFeature("switchSitesHotkey");
  4930. if (siteSwitchEnabled && e.code === hk.code && e.shiftKey === hk.shift && e.ctrlKey === hk.ctrl && e.altKey === hk.alt)
  4931. switchSite(domain === "yt" ? "ytm" : "yt");
  4932. });
  4933. siteEvents.on("hotkeyInputActive", (state) => {
  4934. if (!getFeature("switchBetweenSites"))
  4935. return;
  4936. siteSwitchEnabled = !state;
  4937. });
  4938. log("Initialized site switch listener");
  4939. });
  4940. }
  4941. /** Switches to the other site (between YT and YTM) */
  4942. function switchSite(newDomain) {
  4943. return __awaiter(this, void 0, void 0, function* () {
  4944. try {
  4945. if (!(["/watch", "/playlist"].some(v => location.pathname.startsWith(v))))
  4946. return warn("Not on a supported page, so the site switch is ignored");
  4947. let subdomain;
  4948. if (newDomain === "ytm")
  4949. subdomain = "music";
  4950. else if (newDomain === "yt")
  4951. subdomain = "www";
  4952. if (!subdomain)
  4953. throw new Error(`Unrecognized domain '${newDomain}'`);
  4954. disableBeforeUnload();
  4955. const { pathname, search, hash } = new URL(location.href);
  4956. const vt = yield getVideoTime(0);
  4957. log(`Found video time of ${vt} seconds`);
  4958. const cleanSearch = search.split("&")
  4959. .filter((param) => !param.match(/^\??(t|time_continue)=/))
  4960. .join("&");
  4961. const newSearch = typeof vt === "number" && vt > videoTimeThreshold ?
  4962. cleanSearch.includes("?")
  4963. ? `${cleanSearch.startsWith("?")
  4964. ? cleanSearch
  4965. : "?" + cleanSearch}&time_continue=${vt}`
  4966. : `?time_continue=${vt}`
  4967. : cleanSearch;
  4968. const newUrl = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`;
  4969. info(`Switching to domain '${newDomain}' at ${newUrl}`);
  4970. location.assign(newUrl);
  4971. }
  4972. catch (err) {
  4973. error("Error while switching site:", err);
  4974. }
  4975. });
  4976. }
  4977. //#region num keys skip
  4978. const numKeysIgnoreTagNames = [...inputIgnoreTagNames];
  4979. /** Adds the ability to skip to a certain time in the video by pressing a number key (0-9) */
  4980. function initNumKeysSkip() {
  4981. return __awaiter(this, void 0, void 0, function* () {
  4982. document.addEventListener("keydown", (e) => {
  4983. var _a, _b;
  4984. if (!getFeature("numKeysSkipToTime"))
  4985. return;
  4986. if (!e.key.trim().match(/^[0-9]$/))
  4987. return;
  4988. // discard the event when an unexpected element is currently active or in focus, like when editing a playlist or when the search bar is focused
  4989. const ignoreElement = numKeysIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "");
  4990. if ((document.activeElement !== document.body && ignoreElement) || ignoreElement)
  4991. return info("Captured valid key to skip video to, but ignored it since this element is currently active:", document.activeElement);
  4992. const vidElem = document.querySelector(getVideoSelector());
  4993. if (!vidElem)
  4994. return warn("Could not find video element, so the keypress is ignored");
  4995. const newVidTime = vidElem.duration / (10 / Number(e.key));
  4996. if (!isNaN(newVidTime)) {
  4997. log(`Captured number key [${e.key}], skipping to ${Math.floor(newVidTime / 60)}m ${(newVidTime % 60).toFixed(1)}s`);
  4998. vidElem.currentTime = newVidTime;
  4999. }
  5000. });
  5001. log("Added number key press listener");
  5002. });
  5003. }
  5004. //#region auto-like vids
  5005. let canCompress$1 = false;
  5006. /** DataStore instance for all auto-liked channels */
  5007. const autoLikeStore = new UserUtils.DataStore({
  5008. id: "bytm-auto-like-channels",
  5009. formatVersion: 2,
  5010. defaultData: {
  5011. channels: [],
  5012. },
  5013. encodeData: (data) => canCompress$1 ? UserUtils.compress(data, compressionFormat, "string") : data,
  5014. decodeData: (data) => canCompress$1 ? UserUtils.decompress(data, compressionFormat, "string") : data,
  5015. migrations: {
  5016. // 1 -> 2 (v2.1-pre) - add @ prefix to channel IDs if missing
  5017. 2: (oldData) => ({
  5018. channels: oldData.channels.map((ch) => (Object.assign(Object.assign({}, ch), { id: ch.id.trim().match(/^(UC|@).+$/)
  5019. ? ch.id.trim()
  5020. : `@${ch.id.trim()}` }))),
  5021. }),
  5022. },
  5023. });
  5024. let autoLikeStoreLoaded = false;
  5025. /** Inits the auto-like DataStore instance */
  5026. function initAutoLikeStore() {
  5027. if (autoLikeStoreLoaded)
  5028. return;
  5029. autoLikeStoreLoaded = true;
  5030. return autoLikeStore.loadData();
  5031. }
  5032. /** Initializes the auto-like feature */
  5033. function initAutoLike() {
  5034. return __awaiter(this, void 0, void 0, function* () {
  5035. try {
  5036. canCompress$1 = yield compressionSupported();
  5037. yield initAutoLikeStore();
  5038. if (getDomain() === "ytm") {
  5039. let timeout;
  5040. siteEvents.on("songTitleChanged", () => {
  5041. var _a;
  5042. const autoLikeTimeoutMs = ((_a = getFeature("autoLikeTimeout")) !== null && _a !== void 0 ? _a : 5) * 1000;
  5043. timeout && clearTimeout(timeout);
  5044. const ytmTryAutoLike = () => {
  5045. const artistEls = document.querySelectorAll("ytmusic-player-bar .content-info-wrapper .subtitle a.yt-formatted-string[href]");
  5046. const channelIds = [...artistEls].map(a => a.href.split("/").pop()).filter(a => typeof a === "string");
  5047. const likeChan = autoLikeStore.getData().channels.find((ch) => channelIds.includes(ch.id));
  5048. if (!likeChan || !likeChan.enabled)
  5049. return;
  5050. if (artistEls.length === 0)
  5051. return error("Couldn't auto-like channel because the artist element couldn't be found");
  5052. const likeRendererEl = document.querySelector(".middle-controls-buttons ytmusic-like-button-renderer");
  5053. const likeBtnEl = likeRendererEl === null || likeRendererEl === void 0 ? void 0 : likeRendererEl.querySelector("#button-shape-like button");
  5054. if (!likeRendererEl || !likeBtnEl)
  5055. return error("Couldn't auto-like channel because the like button couldn't be found");
  5056. if (likeRendererEl.getAttribute("like-status") !== "LIKE") {
  5057. likeBtnEl.click();
  5058. getFeature("autoLikeShowToast") && showIconToast({
  5059. message: t(`auto_liked_a_channels_${currentMediaType()}`, likeChan.name),
  5060. icon: "icon-auto_like",
  5061. });
  5062. log(`Auto-liked ${currentMediaType()} from channel '${likeChan.name}' (${likeChan.id})`);
  5063. }
  5064. };
  5065. timeout = setTimeout(ytmTryAutoLike, autoLikeTimeoutMs);
  5066. siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytmTryAutoLike, autoLikeTimeoutMs));
  5067. });
  5068. siteEvents.on("pathChanged", (path) => {
  5069. if (getFeature("autoLikeChannelToggleBtn") && path.match(/\/channel\/.+/)) {
  5070. const chanId = getCurrentChannelId();
  5071. if (!chanId)
  5072. return error("Couldn't extract channel ID from URL");
  5073. document.querySelectorAll(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
  5074. addSelectorListener("browseResponse", "ytmusic-browse-response #header.ytmusic-browse-response", {
  5075. listener(headerCont) {
  5076. var _a, _b, _c, _d;
  5077. const buttonsCont = headerCont.querySelector(".buttons");
  5078. if (buttonsCont) {
  5079. const lastBtn = buttonsCont.querySelector("ytmusic-subscribe-button-renderer");
  5080. const chanName = (_b = (_a = document.querySelector("ytmusic-immersive-header-renderer .content-container yt-formatted-string[role=\"heading\"]")) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : null;
  5081. lastBtn && addAutoLikeToggleBtn(lastBtn, chanId, chanName);
  5082. }
  5083. else {
  5084. // some channels don't have a subscribe button and instead only have a "share" button for some bullshit reason
  5085. const shareBtnEl = headerCont.querySelector("ytmusic-menu-renderer #top-level-buttons yt-button-renderer:last-of-type");
  5086. const chanName = (_d = (_c = headerCont.querySelector("ytmusic-visual-header-renderer .content-container h2 yt-formatted-string")) === null || _c === void 0 ? void 0 : _c.textContent) !== null && _d !== void 0 ? _d : null;
  5087. shareBtnEl && chanName && addAutoLikeToggleBtn(shareBtnEl, chanId, chanName);
  5088. }
  5089. }
  5090. });
  5091. }
  5092. });
  5093. }
  5094. else if (getDomain() === "yt") {
  5095. let timeout;
  5096. siteEvents.on("watchIdChanged", () => {
  5097. var _a;
  5098. const autoLikeTimeoutMs = ((_a = getFeature("autoLikeTimeout")) !== null && _a !== void 0 ? _a : 5) * 1000;
  5099. timeout && clearTimeout(timeout);
  5100. if (!location.pathname.startsWith("/watch"))
  5101. return;
  5102. const ytTryAutoLike = () => {
  5103. addSelectorListener("ytWatchMetadata", "#owner ytd-channel-name yt-formatted-string a", {
  5104. listener(chanElem) {
  5105. var _a, _b;
  5106. const chanElemId = (_b = (_a = chanElem.href.split("/").pop()) === null || _a === void 0 ? void 0 : _a.split("/")[0]) !== null && _b !== void 0 ? _b : null;
  5107. const likeChan = autoLikeStore.getData().channels.find((ch) => ch.id === chanElemId);
  5108. if (!likeChan || !likeChan.enabled)
  5109. return;
  5110. addSelectorListener("ytWatchMetadata", "#actions ytd-menu-renderer like-button-view-model button", {
  5111. listener(likeBtn) {
  5112. if (likeBtn.getAttribute("aria-pressed") !== "true") {
  5113. likeBtn.click();
  5114. getFeature("autoLikeShowToast") && showIconToast({
  5115. message: t(`auto_liked_a_channels_${currentMediaType()}`, likeChan.name),
  5116. icon: "icon-auto_like",
  5117. });
  5118. log(`Auto-liked ${currentMediaType()} from channel '${likeChan.name}' (${likeChan.id})`);
  5119. }
  5120. }
  5121. });
  5122. }
  5123. });
  5124. };
  5125. siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytTryAutoLike, autoLikeTimeoutMs));
  5126. timeout = setTimeout(ytTryAutoLike, autoLikeTimeoutMs);
  5127. });
  5128. siteEvents.on("pathChanged", (path) => {
  5129. if (path.match(/(\/?@|\/?channel\/)\S+/)) {
  5130. const chanId = getCurrentChannelId();
  5131. if (!chanId)
  5132. return error("Couldn't extract channel ID from URL");
  5133. document.querySelectorAll(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
  5134. addSelectorListener("ytAppHeader", "#channel-header-container, #page-header", {
  5135. listener(headerCont) {
  5136. var _a, _b;
  5137. const titleCont = headerCont.querySelector("ytd-channel-name #container");
  5138. if (!titleCont)
  5139. return;
  5140. const chanName = (_b = (_a = titleCont.querySelector("yt-formatted-string")) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : null;
  5141. const buttonsCont = headerCont.querySelector("#inner-header-container #buttons");
  5142. if (buttonsCont) {
  5143. addSelectorListener("ytAppHeader", "#channel-header-container #other-buttons, yt-subscribe-button-view-model", {
  5144. listener: (otherBtns) => addAutoLikeToggleBtn(otherBtns, chanId, chanName, ["left-margin"]),
  5145. });
  5146. }
  5147. else if (titleCont)
  5148. addAutoLikeToggleBtn(titleCont, chanId, chanName);
  5149. }
  5150. });
  5151. }
  5152. });
  5153. }
  5154. log("Initialized auto-like channels feature");
  5155. }
  5156. catch (err) {
  5157. error("Error while auto-liking channel:", err);
  5158. }
  5159. });
  5160. }
  5161. function addAutoLikeToggleBtn(siblingEl, channelId, channelName, extraClasses) {
  5162. return __awaiter(this, void 0, void 0, function* () {
  5163. var _a;
  5164. const chan = autoLikeStore.getData().channels.find((ch) => ch.id === channelId);
  5165. siteEvents.on("autoLikeChannelsUpdated", () => {
  5166. var _a, _b;
  5167. const buttonEl = document.querySelector(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`);
  5168. if (!buttonEl)
  5169. return warn("Couldn't find auto-like toggle button for channel ID:", channelId);
  5170. const enabled = (_b = (_a = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : false;
  5171. if (enabled)
  5172. buttonEl.classList.add("toggled");
  5173. else
  5174. buttonEl.classList.remove("toggled");
  5175. });
  5176. const buttonEl = yield createLongBtn({
  5177. resourceName: `icon-auto_like${(chan === null || chan === void 0 ? void 0 : chan.enabled) ? "_enabled" : ""}`,
  5178. text: t("auto_like"),
  5179. title: t(`auto_like_button_tooltip${(chan === null || chan === void 0 ? void 0 : chan.enabled) ? "_enabled" : "_disabled"}`),
  5180. toggle: true,
  5181. toggleInitialState: (_a = chan === null || chan === void 0 ? void 0 : chan.enabled) !== null && _a !== void 0 ? _a : false,
  5182. togglePredicate(e) {
  5183. e.shiftKey && getAutoLikeDialog().then((dlg) => dlg.open());
  5184. return !e.shiftKey;
  5185. },
  5186. onToggle(toggled) {
  5187. return __awaiter(this, void 0, void 0, function* () {
  5188. var _a;
  5189. try {
  5190. yield autoLikeStore.loadData();
  5191. buttonEl.title = buttonEl.ariaLabel = t(`auto_like_button_tooltip${toggled ? "_enabled" : "_disabled"}`);
  5192. const chanId = (_a = buttonEl.dataset.channelId) !== null && _a !== void 0 ? _a : channelId;
  5193. const imgEl = buttonEl.querySelector(".bytm-generic-btn-img");
  5194. const imgHtml = yield resourceAsString(`icon-auto_like${toggled ? "_enabled" : ""}`);
  5195. if (imgEl && imgHtml)
  5196. imgEl.innerHTML = imgHtml;
  5197. if (autoLikeStore.getData().channels.find((ch) => ch.id === chanId) === undefined) {
  5198. yield autoLikeStore.setData({
  5199. channels: [
  5200. ...autoLikeStore.getData().channels,
  5201. { id: chanId, name: channelName !== null && channelName !== void 0 ? channelName : "", enabled: toggled },
  5202. ],
  5203. });
  5204. }
  5205. else {
  5206. yield autoLikeStore.setData({
  5207. channels: autoLikeStore.getData().channels
  5208. .map((ch) => ch.id === chanId ? Object.assign(Object.assign({}, ch), { enabled: toggled }) : ch),
  5209. });
  5210. }
  5211. emitSiteEvent("autoLikeChannelsUpdated");
  5212. showIconToast({
  5213. message: toggled ? t("auto_like_enabled_toast") : t("auto_like_disabled_toast"),
  5214. icon: `icon-auto_like${toggled ? "_enabled" : ""}`,
  5215. });
  5216. log(`Toggled auto-like for channel '${channelName}' (ID: '${chanId}') to ${toggled ? "enabled" : "disabled"}`);
  5217. }
  5218. catch (err) {
  5219. error("Error while toggling auto-like channel:", err);
  5220. }
  5221. });
  5222. }
  5223. });
  5224. buttonEl.classList.add(...["bytm-auto-like-toggle-btn", ...(extraClasses !== null && extraClasses !== void 0 ? extraClasses : [])]);
  5225. buttonEl.dataset.channelId = channelId;
  5226. siblingEl.insertAdjacentElement("afterend", createRipple(buttonEl));
  5227. });
  5228. }/** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */
  5229. const geniUrlRatelimitTimeframe = 30;
  5230. //#region media control bar
  5231. let currentSongTitle = "";
  5232. /** Adds a lyrics button to the player bar */
  5233. function addPlayerBarLyricsBtn() {
  5234. return __awaiter(this, void 0, void 0, function* () {
  5235. addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", { listener: addActualLyricsBtn });
  5236. });
  5237. }
  5238. /** Actually adds the lyrics button after the like button renderer has been verified to exist */
  5239. function addActualLyricsBtn(likeContainer) {
  5240. return __awaiter(this, void 0, void 0, function* () {
  5241. const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
  5242. if (!songTitleElem)
  5243. return warn("Couldn't find song title element");
  5244. currentSongTitle = songTitleElem.title;
  5245. const spinnerIconUrl = yield getResourceUrl("icon-spinner");
  5246. const lyricsIconUrl = yield getResourceUrl("icon-lyrics");
  5247. const errorIconUrl = yield getResourceUrl("icon-error");
  5248. const onMutation = (mutations) => __awaiter(this, void 0, void 0, function* () {
  5249. var _a, mutations_1, mutations_1_1;
  5250. var _b, e_1, _c, _d;
  5251. try {
  5252. for (_a = true, mutations_1 = __asyncValues(mutations); mutations_1_1 = yield mutations_1.next(), _b = mutations_1_1.done, !_b; _a = true) {
  5253. _d = mutations_1_1.value;
  5254. _a = false;
  5255. const mut = _d;
  5256. const newTitle = mut.target.title;
  5257. if (newTitle !== currentSongTitle && newTitle.length > 0) {
  5258. const lyricsBtn = document.querySelector("#bytm-player-bar-lyrics-btn");
  5259. if (!lyricsBtn)
  5260. continue;
  5261. lyricsBtn.style.cursor = "wait";
  5262. lyricsBtn.style.pointerEvents = "none";
  5263. const imgElem = lyricsBtn.querySelector("img");
  5264. imgElem.src = spinnerIconUrl;
  5265. imgElem.classList.add("bytm-spinner");
  5266. currentSongTitle = newTitle;
  5267. const url = yield getCurrentLyricsUrl(); // can take a second or two
  5268. imgElem.src = lyricsIconUrl;
  5269. imgElem.classList.remove("bytm-spinner");
  5270. if (!url) {
  5271. let artist, song;
  5272. if ("mediaSession" in navigator && navigator.mediaSession.metadata) {
  5273. artist = navigator.mediaSession.metadata.artist;
  5274. song = navigator.mediaSession.metadata.title;
  5275. }
  5276. const query = artist && song ? "?q=" + encodeURIComponent(sanitizeArtists(artist) + " - " + sanitizeSong(song)) : "";
  5277. imgElem.src = errorIconUrl;
  5278. lyricsBtn.ariaLabel = lyricsBtn.title = t("lyrics_not_found_click_open_search");
  5279. lyricsBtn.style.cursor = "pointer";
  5280. lyricsBtn.style.pointerEvents = "all";
  5281. lyricsBtn.style.display = "inline-flex";
  5282. lyricsBtn.style.visibility = "visible";
  5283. lyricsBtn.href = `https://genius.com/search${query}`;
  5284. continue;
  5285. }
  5286. lyricsBtn.href = url;
  5287. lyricsBtn.ariaLabel = lyricsBtn.title = t("open_current_lyrics");
  5288. lyricsBtn.style.cursor = "pointer";
  5289. lyricsBtn.style.visibility = "visible";
  5290. lyricsBtn.style.display = "inline-flex";
  5291. lyricsBtn.style.pointerEvents = "initial";
  5292. }
  5293. }
  5294. }
  5295. catch (e_1_1) { e_1 = { error: e_1_1 }; }
  5296. finally {
  5297. try {
  5298. if (!_a && !_b && (_c = mutations_1.return)) yield _c.call(mutations_1);
  5299. }
  5300. finally { if (e_1) throw e_1.error; }
  5301. }
  5302. });
  5303. // since YT and YTM don't reload the page on video change, MutationObserver needs to be used to watch for changes in the video title
  5304. const obs = new MutationObserver(onMutation);
  5305. obs.observe(songTitleElem, { attributes: true, attributeFilter: ["title"] });
  5306. const lyricsBtnElem = yield createLyricsBtn(undefined);
  5307. lyricsBtnElem.id = "bytm-player-bar-lyrics-btn";
  5308. // run parallel so the element is inserted as soon as possible
  5309. getCurrentLyricsUrl().then(url => {
  5310. url && addGeniusUrlToLyricsBtn(lyricsBtnElem, url);
  5311. });
  5312. log("Inserted lyrics button into media controls bar");
  5313. const thumbToggleElem = document.querySelector("#bytm-thumbnail-overlay-toggle");
  5314. if (thumbToggleElem)
  5315. thumbToggleElem.insertAdjacentElement("afterend", lyricsBtnElem);
  5316. else
  5317. likeContainer.insertAdjacentElement("afterend", lyricsBtnElem);
  5318. });
  5319. }
  5320. //#region lyrics utils
  5321. /** Removes everything in parentheses from the passed song name */
  5322. function sanitizeSong(songName) {
  5323. if (typeof songName !== "string")
  5324. return songName;
  5325. const parensRegex = /\(.+\)/gmi;
  5326. const squareParensRegex = /\[.+\]/gmi;
  5327. // trim right after the song name:
  5328. const sanitized = songName
  5329. .replace(parensRegex, "")
  5330. .replace(squareParensRegex, "");
  5331. return sanitized.trim();
  5332. }
  5333. /** Removes the secondary artist (if it exists) from the passed artists string */
  5334. function sanitizeArtists(artists) {
  5335. artists = artists.split(/\s*\u2022\s*/gmiu)[0]; // split at &bull; [•] character
  5336. if (artists.match(/&/))
  5337. artists = artists.split(/\s*&\s*/gm)[0];
  5338. if (artists.match(/,/))
  5339. artists = artists.split(/,\s*/gm)[0];
  5340. if (artists.match(/(f(ea)?t\.?|Remix|Edit|Flip|Cover|Night\s?Core|Bass\s?Boost|pro?d\.?)/i)) {
  5341. const parensRegex = /\(.+\)/gmi;
  5342. const squareParensRegex = /\[.+\]/gmi;
  5343. artists = artists
  5344. .replace(parensRegex, "")
  5345. .replace(squareParensRegex, "");
  5346. }
  5347. return artists.trim();
  5348. }
  5349. /** Returns the lyrics URL from genius for the currently selected song */
  5350. function getCurrentLyricsUrl() {
  5351. return __awaiter(this, void 0, void 0, function* () {
  5352. try {
  5353. // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
  5354. const isVideo = currentMediaType() === "video";
  5355. const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
  5356. const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string :first-child");
  5357. if (!songTitleElem || !songMetaElem)
  5358. return undefined;
  5359. const songNameRaw = songTitleElem.title;
  5360. let songName = songNameRaw;
  5361. let artistName = songMetaElem.textContent;
  5362. if (isVideo) {
  5363. // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
  5364. if (songName.includes("-")) {
  5365. const split = splitVideoTitle(songName);
  5366. songName = split.song;
  5367. artistName = split.artist;
  5368. }
  5369. }
  5370. if (!artistName)
  5371. return undefined;
  5372. const url = yield fetchLyricsUrlTop(sanitizeArtists(artistName), sanitizeSong(songName));
  5373. if (url) {
  5374. emitInterface("bytm:lyricsLoaded", {
  5375. type: "current",
  5376. artists: artistName,
  5377. title: songName,
  5378. url,
  5379. });
  5380. }
  5381. return url;
  5382. }
  5383. catch (err) {
  5384. error("Couldn't resolve lyrics URL:", err);
  5385. return undefined;
  5386. }
  5387. });
  5388. }
  5389. /** Fetches the top lyrics URL result from geniURL - **the passed parameters need to be sanitized first!** */
  5390. function fetchLyricsUrlTop(artist, song) {
  5391. return __awaiter(this, void 0, void 0, function* () {
  5392. var _a, _b;
  5393. try {
  5394. return (_b = (_a = (yield fetchLyricsUrls(artist, song))) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.url;
  5395. }
  5396. catch (err) {
  5397. error("Couldn't get lyrics URL due to error:", err);
  5398. return undefined;
  5399. }
  5400. });
  5401. }
  5402. /**
  5403. * Fetches the 5 best matching lyrics URLs from geniURL using a combo exact-ish and fuzzy search
  5404. * **the passed parameters need to be sanitized first!**
  5405. */
  5406. function fetchLyricsUrls(artist, song) {
  5407. return __awaiter(this, void 0, void 0, function* () {
  5408. var _a, _b, _c;
  5409. try {
  5410. const cacheEntry = getLyricsCacheEntry(artist, song);
  5411. if (cacheEntry) {
  5412. info(`Found lyrics URL in cache: ${cacheEntry.url}`);
  5413. return [cacheEntry];
  5414. }
  5415. const startTs = Date.now();
  5416. const fetchUrl = constructUrl(`${getFeature("geniUrlBase")}/search`, {
  5417. disableFuzzy: null,
  5418. utm_source: `${scriptInfo.name} v${scriptInfo.version}${mode === "development" ? "-pre" : ""}`,
  5419. q: `${artist} ${song}`,
  5420. });
  5421. log("Requesting lyrics from geniURL:", fetchUrl);
  5422. const token = getFeature("geniUrlToken");
  5423. const fetchRes = yield UserUtils.fetchAdvanced(fetchUrl, Object.assign({}, (token ? {
  5424. headers: {
  5425. Authorization: `Bearer ${token}`,
  5426. },
  5427. } : {})));
  5428. if (fetchRes.status === 429) {
  5429. const waitSeconds = Number((_a = fetchRes.headers.get("retry-after")) !== null && _a !== void 0 ? _a : geniUrlRatelimitTimeframe);
  5430. alert(tp("lyrics_rate_limited", waitSeconds, waitSeconds));
  5431. return undefined;
  5432. }
  5433. else if (fetchRes.status < 200 || fetchRes.status >= 300) {
  5434. error(`Couldn't fetch lyrics URLs from geniURL - status: ${fetchRes.status} - response: ${(_c = (_b = (yield fetchRes.json()).message) !== null && _b !== void 0 ? _b : yield fetchRes.text()) !== null && _c !== void 0 ? _c : "(none)"}`);
  5435. return undefined;
  5436. }
  5437. const result = yield fetchRes.json();
  5438. if (typeof result === "object" && result.error || !result || !result.all) {
  5439. error("Couldn't fetch lyrics URL:", result.message);
  5440. return undefined;
  5441. }
  5442. const allResults = result.all;
  5443. if (allResults.length === 0) {
  5444. warn("No lyrics URL found for the provided song");
  5445. return undefined;
  5446. }
  5447. const allResultsSan = allResults
  5448. .filter(({ meta, url }) => (meta.title || meta.fullTitle) && meta.artists && url)
  5449. .map(({ meta, url }) => {
  5450. var _a;
  5451. return ({
  5452. meta: Object.assign(Object.assign({}, meta), { title: sanitizeSong(String((_a = meta.title) !== null && _a !== void 0 ? _a : meta.fullTitle)), artists: sanitizeArtists(String(meta.artists)) }),
  5453. url,
  5454. });
  5455. });
  5456. if (!getFeature("advancedLyricsFilter")) {
  5457. const topRes = allResultsSan[0];
  5458. topRes && addLyricsCacheEntryBest(topRes.meta.artists, topRes.meta.title, topRes.url);
  5459. return allResultsSan.map(r => ({
  5460. artist: r.meta.primaryArtist.name,
  5461. song: r.meta.title,
  5462. url: r.url,
  5463. }));
  5464. }
  5465. const exactish = (input) => input.toLowerCase()
  5466. .replace(/[\s\-_&,.()[\]]+/gm, "");
  5467. // exact-ish matches, best matching one first
  5468. const exactishResults = [...allResultsSan].sort((a, b) => {
  5469. const aTitleScore = exactish(a.meta.title).localeCompare(exactish(song));
  5470. const bTitleScore = exactish(b.meta.title).localeCompare(exactish(song));
  5471. const aArtistScore = exactish(a.meta.primaryArtist.name).localeCompare(exactish(artist));
  5472. const bArtistScore = exactish(b.meta.primaryArtist.name).localeCompare(exactish(artist));
  5473. return aTitleScore + aArtistScore - bTitleScore - bArtistScore;
  5474. });
  5475. // use fuse.js for fuzzy match
  5476. // search song title and artist separately, then combine the scores
  5477. const titleFuse = new Fuse([...allResultsSan], {
  5478. keys: ["title"],
  5479. includeScore: true,
  5480. threshold: 0.4,
  5481. });
  5482. const artistFuse = new Fuse([...allResultsSan], {
  5483. keys: ["primaryArtist.name"],
  5484. includeScore: true,
  5485. threshold: 0.4,
  5486. });
  5487. let fuzzyResults = allResultsSan.map(r => {
  5488. var _a, _b, _c, _d;
  5489. const titleRes = titleFuse.search(r.meta.title);
  5490. const artistRes = artistFuse.search(r.meta.primaryArtist.name);
  5491. const titleScore = (_b = (_a = titleRes[0]) === null || _a === void 0 ? void 0 : _a.score) !== null && _b !== void 0 ? _b : 0;
  5492. const artistScore = (_d = (_c = artistRes[0]) === null || _c === void 0 ? void 0 : _c.score) !== null && _d !== void 0 ? _d : 0;
  5493. return Object.assign(Object.assign({}, r), { score: titleScore + artistScore });
  5494. });
  5495. // I love TS
  5496. fuzzyResults = fuzzyResults
  5497. .map((_a) => {
  5498. var { score } = _a, rest = __rest(_a, ["score"]);
  5499. return rest;
  5500. });
  5501. const hasExactMatch = exactishResults.slice(0, 3).find(r => exactish(r.meta.title) === exactish(fuzzyResults[0].meta.title) && exactish(r.meta.primaryArtist.name) === exactish(fuzzyResults[0].meta.primaryArtist.name));
  5502. const finalResults = [
  5503. ...(hasExactMatch
  5504. ? [fuzzyResults[0], ...allResultsSan.filter(r => r.url !== fuzzyResults[0].url)]
  5505. : [...allResultsSan]),
  5506. ].slice(0, 5);
  5507. // add top 3 results to the cache with a penalty to their time to live
  5508. // so every entry is deleted faster if it's not considered as relevant
  5509. finalResults.slice(0, 3).forEach(({ meta: { artists, title }, url }, i) => {
  5510. const penaltyFraction = hasExactMatch
  5511. // if there's an exact match, give it 0 penalty and penalize all other results with the full value
  5512. ? i === 0 ? 0 : 1
  5513. // if there's no exact match, penalize all results with a fraction of the full penalty since they're more likely to be unrelated
  5514. : 0.6;
  5515. addLyricsCacheEntryPenalized(sanitizeArtists(artists), sanitizeSong(title), url, penaltyFraction);
  5516. });
  5517. finalResults.length > 0 && log("Found", finalResults.length, "lyrics", UserUtils.autoPlural("URL", finalResults), "in", Date.now() - startTs, "ms:", finalResults);
  5518. // returns search results sorted by relevance
  5519. return finalResults.map(r => ({
  5520. artist: r.meta.primaryArtist.name,
  5521. song: r.meta.title,
  5522. url: r.url,
  5523. }));
  5524. }
  5525. catch (err) {
  5526. error("Couldn't get lyrics URL due to error:", err);
  5527. return undefined;
  5528. }
  5529. });
  5530. }
  5531. /** Adds the genius URL to the passed lyrics button element if it was previously instantiated with an undefined URL */
  5532. function addGeniusUrlToLyricsBtn(btnElem, geniusUrl) {
  5533. return __awaiter(this, void 0, void 0, function* () {
  5534. btnElem.href = geniusUrl;
  5535. btnElem.ariaLabel = btnElem.title = t("open_lyrics");
  5536. btnElem.style.visibility = "visible";
  5537. btnElem.style.display = "inline-flex";
  5538. });
  5539. }
  5540. /** Creates the base lyrics button element */
  5541. function createLyricsBtn(geniusUrl_1) {
  5542. return __awaiter(this, arguments, void 0, function* (geniusUrl, hideIfLoading = true) {
  5543. const linkElem = document.createElement("a");
  5544. linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn");
  5545. linkElem.ariaLabel = linkElem.title = geniusUrl ? t("open_lyrics") : t("lyrics_loading");
  5546. if (geniusUrl)
  5547. linkElem.href = geniusUrl;
  5548. linkElem.role = "button";
  5549. linkElem.target = "_blank";
  5550. linkElem.rel = "noopener noreferrer";
  5551. linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden";
  5552. linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none";
  5553. const imgElem = document.createElement("img");
  5554. imgElem.classList.add("bytm-generic-btn-img");
  5555. imgElem.src = yield getResourceUrl("icon-lyrics");
  5556. onInteraction(linkElem, (e) => {
  5557. var _a;
  5558. const url = (_a = linkElem.href) !== null && _a !== void 0 ? _a : geniusUrl;
  5559. if (!url || e instanceof MouseEvent)
  5560. return;
  5561. openInTab(url);
  5562. }, {
  5563. preventDefault: false,
  5564. stopPropagation: false,
  5565. });
  5566. linkElem.appendChild(imgElem);
  5567. onInteraction(linkElem, (e) => __awaiter(this, void 0, void 0, function* () {
  5568. if (e.ctrlKey || e.altKey) {
  5569. e.preventDefault();
  5570. e.stopPropagation();
  5571. const search = prompt(t("open_lyrics_search_prompt"));
  5572. if (search)
  5573. openInTab(`https://genius.com/search?q=${encodeURIComponent(search)}`);
  5574. }
  5575. }), {
  5576. preventDefault: false,
  5577. stopPropagation: false,
  5578. });
  5579. return linkElem;
  5580. });
  5581. }
  5582. /** Splits a video title that contains a hyphen into an artist and song */
  5583. function splitVideoTitle(title) {
  5584. const [artist, ...rest] = title.split("-").map((v, i) => i < 2 ? v.trim() : v);
  5585. return { artist, song: rest.join("-") };
  5586. }//#region init queue btns
  5587. /** Initializes the queue buttons */
  5588. function initQueueButtons() {
  5589. return __awaiter(this, void 0, void 0, function* () {
  5590. const addCurrentQueueBtns = (evt) => {
  5591. let amt = 0;
  5592. for (const queueItm of evt.childNodes) {
  5593. if (!queueItm.classList.contains("bytm-has-queue-btns")) {
  5594. addQueueButtons(queueItm, undefined, "currentQueue");
  5595. amt++;
  5596. }
  5597. }
  5598. if (amt > 0)
  5599. log(`Added buttons to ${amt} new queue ${UserUtils.autoPlural("item", amt)}`);
  5600. };
  5601. // current queue
  5602. siteEvents.on("queueChanged", addCurrentQueueBtns);
  5603. siteEvents.on("autoplayQueueChanged", addCurrentQueueBtns);
  5604. const queueItems = document.querySelectorAll("#contents.ytmusic-player-queue > ytmusic-player-queue-item");
  5605. if (queueItems.length > 0) {
  5606. queueItems.forEach(itm => addQueueButtons(itm, undefined, "currentQueue"));
  5607. log(`Added buttons to ${queueItems.length} existing "current song queue" ${UserUtils.autoPlural("item", queueItems)}`);
  5608. }
  5609. // generic lists
  5610. const addGenericListQueueBtns = (listElem) => {
  5611. const queueItems = listElem.querySelectorAll("ytmusic-responsive-list-item-renderer");
  5612. if (queueItems.length === 0)
  5613. return;
  5614. queueItems.forEach(itm => {
  5615. if (itm.classList.contains("bytm-has-btns"))
  5616. return;
  5617. itm.classList.add("bytm-has-btns");
  5618. addQueueButtons(itm, ".flex-columns", "genericList", ["bytm-generic-list-queue-btn-container"], "afterParent");
  5619. });
  5620. log(`Added buttons to ${queueItems.length} new "generic song list" ${UserUtils.autoPlural("item", queueItems)}`);
  5621. };
  5622. const listSelector = `\
  5623. ytmusic-playlist-shelf-renderer #contents,
  5624. ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_ALBUM"] ytmusic-shelf-renderer #contents,
  5625. ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_ARTIST"] ytmusic-shelf-renderer #contents,
  5626. ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_PLAYLIST"] ytmusic-shelf-renderer #contents\
  5627. `;
  5628. if (getFeature("listButtonsPlacement") === "everywhere") {
  5629. const checkAddGenericBtns = (songLists) => {
  5630. for (const list of songLists)
  5631. addGenericListQueueBtns(list);
  5632. };
  5633. addSelectorListener("body", listSelector, {
  5634. all: true,
  5635. continuous: true,
  5636. debounce: 100,
  5637. // TODO: switch to longer debounce time and edge type "risingIdle" after UserUtils update
  5638. debounceEdge: "falling",
  5639. listener: checkAddGenericBtns,
  5640. });
  5641. siteEvents.on("pathChanged", () => {
  5642. const songLists = document.querySelectorAll(listSelector);
  5643. if (songLists.length > 0)
  5644. checkAddGenericBtns(songLists);
  5645. });
  5646. }
  5647. });
  5648. }
  5649. //#region add queue btns
  5650. /**
  5651. * Adds the buttons to each item in the current song queue.
  5652. * Also observes for changes to add new buttons to new items in the queue.
  5653. * @param queueItem The element with tagname `ytmusic-player-queue-item` or `ytmusic-responsive-list-item-renderer` to add queue buttons to
  5654. * @param listType The type of list the queue item is in
  5655. * @param classes Extra CSS classes to apply to the container
  5656. * @param insertPosition Where to insert the button container in relation to the parent element
  5657. */
  5658. function addQueueButtons(queueItem_1) {
  5659. return __awaiter(this, arguments, void 0, function* (queueItem, containerParentSelector = ".song-info", listType = "currentQueue", classes = [], insertPosition = "child") {
  5660. const queueBtnsCont = document.createElement("div");
  5661. queueBtnsCont.classList.add(...["bytm-queue-btn-container", ...classes]);
  5662. const lyricsIconUrl = yield getResourceUrl("icon-lyrics");
  5663. const deleteIconUrl = yield getResourceUrl("icon-delete");
  5664. //#region lyrics btn
  5665. let lyricsBtnElem;
  5666. if (getFeature("lyricsQueueButton")) {
  5667. lyricsBtnElem = yield createLyricsBtn(undefined, false);
  5668. lyricsBtnElem.ariaLabel = lyricsBtnElem.title = t("open_lyrics");
  5669. lyricsBtnElem.style.display = "inline-flex";
  5670. lyricsBtnElem.style.visibility = "initial";
  5671. lyricsBtnElem.style.pointerEvents = "initial";
  5672. lyricsBtnElem.role = "link";
  5673. lyricsBtnElem.tabIndex = 0;
  5674. onInteraction(lyricsBtnElem, (e) => __awaiter(this, void 0, void 0, function* () {
  5675. var _a;
  5676. e.preventDefault();
  5677. e.stopImmediatePropagation();
  5678. let song, artist;
  5679. if (listType === "currentQueue") {
  5680. const songInfo = queueItem.querySelector(".song-info");
  5681. if (!songInfo)
  5682. return;
  5683. const [songEl, artistEl] = songInfo.querySelectorAll("yt-formatted-string");
  5684. song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent;
  5685. artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
  5686. }
  5687. else if (listType === "genericList") {
  5688. const songEl = queueItem.querySelector(".title-column yt-formatted-string a");
  5689. let artistEl = null;
  5690. if (location.pathname.startsWith("/playlist"))
  5691. artistEl = document.querySelector("ytmusic-detail-header-renderer .metadata .subtitle-container yt-formatted-string a");
  5692. if (!artistEl || !artistEl.textContent)
  5693. artistEl = queueItem.querySelector(".secondary-flex-columns yt-formatted-string:first-child a");
  5694. song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent;
  5695. artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
  5696. }
  5697. else
  5698. return;
  5699. if (!song || !artist)
  5700. return error("Couldn't get song or artist name from queue item - song:", song, "- artist:", artist);
  5701. let lyricsUrl;
  5702. const artistsSan = sanitizeArtists(artist);
  5703. const songSan = sanitizeSong(song);
  5704. const splitTitle = splitVideoTitle(songSan);
  5705. const cachedLyricsEntry = songSan.includes("-")
  5706. ? getLyricsCacheEntry(splitTitle.artist, splitTitle.song)
  5707. : getLyricsCacheEntry(artistsSan, songSan);
  5708. if (cachedLyricsEntry)
  5709. lyricsUrl = cachedLyricsEntry.url;
  5710. else if (!queueItem.hasAttribute("data-bytm-loading")) {
  5711. const imgEl = lyricsBtnElem === null || lyricsBtnElem === void 0 ? void 0 : lyricsBtnElem.querySelector("img");
  5712. if (!imgEl)
  5713. return;
  5714. if (!cachedLyricsEntry) {
  5715. queueItem.setAttribute("data-bytm-loading", "");
  5716. imgEl.src = yield getResourceUrl("icon-spinner");
  5717. imgEl.classList.add("bytm-spinner");
  5718. }
  5719. lyricsUrl = (_a = cachedLyricsEntry === null || cachedLyricsEntry === void 0 ? void 0 : cachedLyricsEntry.url) !== null && _a !== void 0 ? _a : yield fetchLyricsUrlTop(artistsSan, songSan);
  5720. if (lyricsUrl) {
  5721. emitInterface("bytm:lyricsLoaded", {
  5722. type: "queue",
  5723. artists: artist,
  5724. title: song,
  5725. url: lyricsUrl,
  5726. });
  5727. }
  5728. const resetImgElem = () => {
  5729. imgEl.src = lyricsIconUrl;
  5730. imgEl.classList.remove("bytm-spinner");
  5731. };
  5732. if (!cachedLyricsEntry) {
  5733. queueItem.removeAttribute("data-bytm-loading");
  5734. // so the new image doesn't "blink"
  5735. setTimeout(resetImgElem, 100);
  5736. }
  5737. if (!lyricsUrl) {
  5738. resetImgElem();
  5739. if (confirm(t("lyrics_not_found_confirm_open_search")))
  5740. openInTab(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} - ${songSan}`)}`);
  5741. return;
  5742. }
  5743. }
  5744. lyricsUrl && openInTab(lyricsUrl);
  5745. }));
  5746. }
  5747. //#region delete btn
  5748. let deleteBtnElem;
  5749. if (getFeature("deleteFromQueueButton")) {
  5750. deleteBtnElem = document.createElement("a");
  5751. deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("remove_from_queue") : t("delete_from_list"));
  5752. deleteBtnElem.classList.add("ytmusic-player-bar", "bytm-delete-from-queue", "bytm-generic-btn");
  5753. deleteBtnElem.role = "button";
  5754. deleteBtnElem.tabIndex = 0;
  5755. deleteBtnElem.style.visibility = "initial";
  5756. const imgElem = document.createElement("img");
  5757. imgElem.classList.add("bytm-generic-btn-img");
  5758. imgElem.src = deleteIconUrl;
  5759. onInteraction(deleteBtnElem, (e) => __awaiter(this, void 0, void 0, function* () {
  5760. e.preventDefault();
  5761. e.stopImmediatePropagation();
  5762. // container of the queue item popup menu - element gets reused for every queue item
  5763. let queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
  5764. try {
  5765. // three dots button to open the popup menu of a queue item
  5766. const dotsBtnElem = queueItem.querySelector("ytmusic-menu-renderer yt-button-shape[id=\"button-shape\"] button");
  5767. if (dotsBtnElem) {
  5768. if (queuePopupCont)
  5769. queuePopupCont.setAttribute("data-bytm-hidden", "true");
  5770. dotsBtnElem.click();
  5771. }
  5772. else {
  5773. warn("Couldn't find three dots button in queue item, trying to open the context menu manually");
  5774. queueItem.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: false }));
  5775. }
  5776. queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
  5777. queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.setAttribute("data-bytm-hidden", "true");
  5778. yield UserUtils.pauseFor(15);
  5779. const removeFromQueueBtn = queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.querySelector("tp-yt-paper-listbox ytmusic-menu-service-item-renderer:nth-of-type(3)");
  5780. removeFromQueueBtn === null || removeFromQueueBtn === void 0 ? void 0 : removeFromQueueBtn.click();
  5781. // queue items aren't removed automatically outside of the current queue
  5782. if (removeFromQueueBtn && listType === "genericList") {
  5783. yield UserUtils.pauseFor(200);
  5784. clearInner(queueItem);
  5785. queueItem.remove();
  5786. }
  5787. if (!removeFromQueueBtn) {
  5788. error("Couldn't find 'remove from queue' button in queue item three dots menu.\nPlease make sure all autoplay restrictions on your browser's side are disabled for this page.");
  5789. dotsBtnElem === null || dotsBtnElem === void 0 ? void 0 : dotsBtnElem.click();
  5790. imgElem.src = yield getResourceUrl("icon-error");
  5791. if (deleteBtnElem)
  5792. deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("couldnt_remove_from_queue") : t("couldnt_delete_from_list"));
  5793. }
  5794. }
  5795. catch (err) {
  5796. error("Couldn't remove song from queue due to error:", err);
  5797. }
  5798. finally {
  5799. queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.removeAttribute("data-bytm-hidden");
  5800. }
  5801. }));
  5802. deleteBtnElem.appendChild(imgElem);
  5803. }
  5804. lyricsBtnElem && queueBtnsCont.appendChild(createRipple(lyricsBtnElem));
  5805. deleteBtnElem && queueBtnsCont.appendChild(createRipple(deleteBtnElem));
  5806. const parentEl = queueItem.querySelector(containerParentSelector);
  5807. if (insertPosition === "child")
  5808. parentEl === null || parentEl === void 0 ? void 0 : parentEl.appendChild(queueBtnsCont);
  5809. else if (insertPosition === "beforeParent")
  5810. parentEl === null || parentEl === void 0 ? void 0 : parentEl.before(queueBtnsCont);
  5811. else if (insertPosition === "afterParent")
  5812. parentEl === null || parentEl === void 0 ? void 0 : parentEl.after(queueBtnsCont);
  5813. queueItem.classList.add("bytm-has-queue-btns");
  5814. });
  5815. }//#region init vol features
  5816. /** Initializes all volume-related features */
  5817. function initVolumeFeatures() {
  5818. return __awaiter(this, void 0, void 0, function* () {
  5819. // not technically an input element but behaves pretty much the same
  5820. addSelectorListener("playerBarRightControls", "tp-yt-paper-slider#volume-slider", {
  5821. listener: (sliderElem) => __awaiter(this, void 0, void 0, function* () {
  5822. const volSliderCont = document.createElement("div");
  5823. volSliderCont.id = "bytm-vol-slider-cont";
  5824. if (getFeature("volumeSliderScrollStep") !== featInfo.volumeSliderScrollStep.default)
  5825. initScrollStep(volSliderCont, sliderElem);
  5826. UserUtils.addParent(sliderElem, volSliderCont);
  5827. if (typeof getFeature("volumeSliderSize") === "number")
  5828. setVolSliderSize();
  5829. if (getFeature("volumeSliderLabel"))
  5830. yield addVolumeSliderLabel(sliderElem, volSliderCont);
  5831. setVolSliderStep(sliderElem);
  5832. if (getFeature("volumeSharedBetweenTabs")) {
  5833. sliderElem.addEventListener("change", () => sharedVolumeChanged(Number(sliderElem.value)));
  5834. checkSharedVolume();
  5835. }
  5836. if (getFeature("setInitialTabVolume"))
  5837. setInitialTabVolume(sliderElem);
  5838. }),
  5839. });
  5840. });
  5841. }
  5842. //#region scroll step
  5843. /** Initializes the volume slider scroll step features */
  5844. function initScrollStep(volSliderCont, sliderElem) {
  5845. for (const evtName of ["wheel", "scroll", "mousewheel", "DOMMouseScroll"]) {
  5846. volSliderCont.addEventListener(evtName, (e) => {
  5847. var _a, _b;
  5848. e.preventDefault();
  5849. // cancels all the other events that would be fired
  5850. e.stopImmediatePropagation();
  5851. const delta = (_b = (_a = e.deltaY) !== null && _a !== void 0 ? _a : e.detail) !== null && _b !== void 0 ? _b : 1;
  5852. const volumeDir = -Math.sign(delta);
  5853. const newVolume = String(Number(sliderElem.value) + (getFeature("volumeSliderScrollStep") * volumeDir));
  5854. sliderElem.value = newVolume;
  5855. sliderElem.setAttribute("aria-valuenow", newVolume);
  5856. // make the site actually change the volume
  5857. sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
  5858. }, {
  5859. // takes precedence over the slider's own event listener
  5860. capture: true,
  5861. });
  5862. }
  5863. }
  5864. //#region volume slider label
  5865. /** Adds a percentage label to the volume slider and tooltip */
  5866. function addVolumeSliderLabel(sliderElem, sliderContainer) {
  5867. return __awaiter(this, void 0, void 0, function* () {
  5868. const labelContElem = document.createElement("div");
  5869. labelContElem.id = "bytm-vol-slider-label";
  5870. const volShared = getFeature("volumeSharedBetweenTabs");
  5871. if (volShared) {
  5872. const linkIconHtml = yield resourceAsString("icon-link");
  5873. if (linkIconHtml) {
  5874. const linkIconElem = document.createElement("div");
  5875. linkIconElem.id = "bytm-vol-slider-shared";
  5876. linkIconElem.innerHTML = linkIconHtml;
  5877. linkIconElem.role = "alert";
  5878. linkIconElem.ariaLive = "polite";
  5879. linkIconElem.title = linkIconElem.ariaLabel = t("volume_shared_tooltip");
  5880. labelContElem.classList.add("has-icon");
  5881. labelContElem.appendChild(linkIconElem);
  5882. }
  5883. }
  5884. const getLabel = (value) => `${value}%`;
  5885. const labelElem = document.createElement("div");
  5886. labelElem.classList.add("label");
  5887. labelElem.textContent = getLabel(sliderElem.value);
  5888. labelContElem.appendChild(labelElem);
  5889. // prevent video from minimizing
  5890. labelContElem.addEventListener("click", (e) => e.stopPropagation());
  5891. labelContElem.addEventListener("keydown", (e) => ["Enter", "Space", " "].includes(e.key) && e.stopPropagation());
  5892. const getLabelText = (slider) => { var _a; return t("volume_tooltip", slider.value, (_a = getFeature("volumeSliderStep")) !== null && _a !== void 0 ? _a : slider.step); };
  5893. const labelFull = getLabelText(sliderElem);
  5894. sliderContainer.setAttribute("title", labelFull);
  5895. sliderElem.setAttribute("title", labelFull);
  5896. sliderElem.setAttribute("aria-valuetext", labelFull);
  5897. const updateLabel = () => {
  5898. const labelFull = getLabelText(sliderElem);
  5899. sliderContainer.setAttribute("title", labelFull);
  5900. sliderElem.setAttribute("title", labelFull);
  5901. sliderElem.setAttribute("aria-valuetext", labelFull);
  5902. const labelElem2 = document.querySelector("#bytm-vol-slider-label div.label");
  5903. if (labelElem2)
  5904. labelElem2.textContent = getLabel(sliderElem.value);
  5905. };
  5906. sliderElem.addEventListener("change", () => updateLabel());
  5907. siteEvents.on("configChanged", () => {
  5908. updateLabel();
  5909. });
  5910. addSelectorListener("playerBarRightControls", "#bytm-vol-slider-cont", {
  5911. listener: (volumeCont) => volumeCont.appendChild(labelContElem),
  5912. });
  5913. let lastSliderVal = Number(sliderElem.value);
  5914. // show label if hovering over slider or slider is focused
  5915. const sliderHoverObserver = new MutationObserver(() => {
  5916. if (sliderElem.classList.contains("on-hover") || document.activeElement === sliderElem)
  5917. labelContElem.classList.add("bytm-visible");
  5918. else if (labelContElem.classList.contains("bytm-visible") || document.activeElement !== sliderElem)
  5919. labelContElem.classList.remove("bytm-visible");
  5920. if (Number(sliderElem.value) !== lastSliderVal) {
  5921. lastSliderVal = Number(sliderElem.value);
  5922. updateLabel();
  5923. }
  5924. });
  5925. sliderHoverObserver.observe(sliderElem, {
  5926. attributes: true,
  5927. });
  5928. });
  5929. }
  5930. //#region volume slider size
  5931. /** Sets the volume slider to a set size */
  5932. function setVolSliderSize() {
  5933. const size = getFeature("volumeSliderSize");
  5934. if (typeof size !== "number" || isNaN(Number(size)))
  5935. return error("Invalid volume slider size:", size);
  5936. setGlobalCssVar("vol-slider-size", `${size}px`);
  5937. addStyleFromResource("css-vol_slider_size");
  5938. }
  5939. //#region volume slider step
  5940. /** Sets the `step` attribute of the volume slider */
  5941. function setVolSliderStep(sliderElem) {
  5942. sliderElem.setAttribute("step", String(getFeature("volumeSliderStep")));
  5943. }
  5944. //#region shared volume
  5945. /** Saves the shared volume level to persistent storage */
  5946. function sharedVolumeChanged(vol) {
  5947. return __awaiter(this, void 0, void 0, function* () {
  5948. try {
  5949. yield GM.setValue("bytm-shared-volume", String(lastCheckedSharedVolume = ignoreVal = vol));
  5950. }
  5951. catch (err) {
  5952. error("Couldn't save shared volume level due to an error:", err);
  5953. }
  5954. });
  5955. }
  5956. let ignoreVal = -1;
  5957. let lastCheckedSharedVolume = -1;
  5958. /** Only call once as this calls itself after a timeout! - Checks if the shared volume has changed and updates the volume slider accordingly */
  5959. function checkSharedVolume() {
  5960. return __awaiter(this, void 0, void 0, function* () {
  5961. try {
  5962. const vol = yield GM.getValue("bytm-shared-volume");
  5963. if (vol && lastCheckedSharedVolume !== Number(vol)) {
  5964. if (ignoreVal === Number(vol))
  5965. return;
  5966. lastCheckedSharedVolume = Number(vol);
  5967. const sliderElem = document.querySelector("tp-yt-paper-slider#volume-slider");
  5968. if (sliderElem) {
  5969. sliderElem.value = String(vol);
  5970. sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
  5971. }
  5972. }
  5973. setTimeout(checkSharedVolume, 333);
  5974. }
  5975. catch (err) {
  5976. error("Couldn't check for shared volume level due to an error:", err);
  5977. }
  5978. });
  5979. }
  5980. //#region initial volume
  5981. /** Sets the volume slider to a set volume level when the session starts */
  5982. function setInitialTabVolume(sliderElem) {
  5983. return __awaiter(this, void 0, void 0, function* () {
  5984. yield waitVideoElementReady();
  5985. const initialVol = getFeature("initialTabVolumeLevel");
  5986. if (getFeature("volumeSharedBetweenTabs")) {
  5987. lastCheckedSharedVolume = ignoreVal = initialVol;
  5988. if (getFeature("volumeSharedBetweenTabs"))
  5989. GM.setValue("bytm-shared-volume", String(initialVol));
  5990. }
  5991. sliderElem.value = String(initialVol);
  5992. sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
  5993. log(`Set initial tab volume to ${initialVol}%`);
  5994. });
  5995. }//#region dependencies
  5996. /** Creates an HTML string for the given adornment properties */
  5997. const getAdornHtml = (className, title, resource, extraParams) => __awaiter(void 0, void 0, void 0, function* () { var _a; return `<span class="${className} bytm-adorn-icon" title="${title}" aria-label="${title}"${extraParams ? " " + extraParams : ""}>${(_a = yield resourceAsString(resource)) !== null && _a !== void 0 ? _a : ""}</span>`; });
  5998. /** Combines multiple async functions or promises that resolve with an adornment HTML string into a single string */
  5999. const combineAdornments = (adornments) => new Promise((resolve) => __awaiter(void 0, void 0, void 0, function* () {
  6000. const html = [];
  6001. for (const adornment of adornments) {
  6002. const val = typeof adornment === "function" ? yield adornment() : yield adornment;
  6003. val && html.push(val);
  6004. }
  6005. resolve(html.join(""));
  6006. }));
  6007. /** Decoration elements that can be added next to the label */
  6008. const adornments = {
  6009. advanced: () => __awaiter(void 0, void 0, void 0, function* () { return getAdornHtml("bytm-advanced-mode-icon", t("advanced_mode"), "icon-advanced_mode"); }),
  6010. experimental: () => __awaiter(void 0, void 0, void 0, function* () { return getAdornHtml("bytm-experimental-icon", t("experimental_feature"), "icon-experimental"); }),
  6011. globe: () => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = yield resourceAsString("icon-globe_small")) !== null && _a !== void 0 ? _a : ""; }),
  6012. alert: (title) => __awaiter(void 0, void 0, void 0, function* () { return getAdornHtml("bytm-warning-icon", title, "icon-error", "role=\"alert\""); }),
  6013. reloadRequired: () => __awaiter(void 0, void 0, void 0, function* () { return getFeature("advancedMode") ? getAdornHtml("bytm-reload-icon", t("feature_requires_reload"), "icon-reload") : undefined; }),
  6014. };
  6015. /** Common options for config items of type "select" */
  6016. const options = {
  6017. siteSelection: () => [
  6018. { value: "all", label: t("site_selection_both_sites") },
  6019. { value: "yt", label: t("site_selection_only_yt") },
  6020. { value: "ytm", label: t("site_selection_only_ytm") },
  6021. ],
  6022. siteSelectionOrNone: () => [
  6023. { value: "all", label: t("site_selection_both_sites") },
  6024. { value: "yt", label: t("site_selection_only_yt") },
  6025. { value: "ytm", label: t("site_selection_only_ytm") },
  6026. { value: "none", label: t("site_selection_none") },
  6027. ],
  6028. locale: () => Object.entries(langMapping)
  6029. .reduce((a, [locale, { name }]) => {
  6030. return [...a, {
  6031. value: locale,
  6032. label: name,
  6033. }];
  6034. }, [])
  6035. .sort((a, b) => a.label.localeCompare(b.label)),
  6036. };
  6037. //#region features
  6038. /**
  6039. * Contains all possible features with their default values and other configuration.
  6040. *
  6041. * **Required props:**
  6042. * <!-------------------------------------------------------------------------------------------------------------------------------------------------------->
  6043. * | Property | Description |
  6044. * | :------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
  6045. * | `type` | type of the feature configuration element - use autocomplete or check `FeatureTypeProps` in `src/types.ts` |
  6046. * | `category` | category of the feature - use autocomplete or check `FeatureCategory` in `src/types.ts` |
  6047. * | `default` | default value of the feature - type of the value depends on the given `type` |
  6048. * | `enable(value: any)` | (required if reloadRequired = false) - function that will be called when the feature is enabled / initialized for the first time |
  6049. *
  6050. *
  6051. * **Optional props:**
  6052. * <!-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------->
  6053. * | Property | Description |
  6054. * | :------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- |
  6055. * | `disable: (newValue: any) => void` | for type `toggle` only - function that will be called when the feature is disabled - can be a synchronous or asynchronous function |
  6056. * | `change: (key: string, prevValue: any, newValue: any)` => void | for types `number`, `select`, `slider` and `hotkey` only - function that will be called when the value is changed |
  6057. * | `click: () => void` | for type `button` only - function that will be called when the button is clicked |
  6058. * | `helpText: string / () => string` | function that returns an HTML string or the literal string itself that will be the help text for this feature - writing as function is useful for pluralizing or inserting values into the translation at runtime - if not set, translation with key `feature_helptext_featureKey` will be used instead, if available |
  6059. * | `textAdornment: () => string / Promise<string>` | function that returns an HTML string that will be appended to the text in the config menu as an adornment element |
  6060. * | `unit: string / (val: number) => string` | Only if type is `number` or `slider` - The unit text that is displayed next to the input element, i.e. " px" - a leading space need to be added by hand! |
  6061. * | `min: number` | Only if type is `number` or `slider` - Overwrites the default of the `min` property of the HTML input element |
  6062. * | `max: number` | Only if type is `number` or `slider` - Overwrites the default of the `max` property of the HTML input element |
  6063. * | `step: number` | Only if type is `number` or `slider` - Overwrites the default of the `step` property of the HTML input element |
  6064. * | `options: SelectOption[] / () => SelectOption[]` | Only if type is `select` - function that returns an array of objects with `value` and `label` properties |
  6065. * | `reloadRequired: boolean` | if true (default), the page needs to be reloaded for the changes to take effect - if false, `enable()` needs to be provided |
  6066. * | `advanced: boolean` | if true, the feature will only be shown if the advanced mode feature has been turned on |
  6067. * | `hidden: boolean` | if true, the feature will not be shown in the settings - default is undefined (false) |
  6068. * | `valueHidden: boolean` | If true, the value of the feature will be hidden in the settings and via the plugin interface - default is undefined (false) |
  6069. * | `normalize: (val: any) => any` | Function that will be called to normalize the value before it is saved - useful for trimming strings or other simple operations |
  6070. * | `renderValue: (val: any) => string` | If provided, is called before rendering the value's label in the config menu |
  6071. *
  6072. * TODO: go through all features and set as many as possible to reloadRequired = false
  6073. */
  6074. const featInfo = {
  6075. //#region layout
  6076. watermarkEnabled: {
  6077. type: "toggle",
  6078. category: "layout",
  6079. default: true,
  6080. textAdornment: adornments.reloadRequired,
  6081. },
  6082. removeShareTrackingParam: {
  6083. type: "toggle",
  6084. category: "layout",
  6085. default: true,
  6086. textAdornment: adornments.reloadRequired,
  6087. },
  6088. removeShareTrackingParamSites: {
  6089. type: "select",
  6090. category: "layout",
  6091. options: options.siteSelection,
  6092. default: "all",
  6093. advanced: true,
  6094. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  6095. },
  6096. fixSpacing: {
  6097. type: "toggle",
  6098. category: "layout",
  6099. default: true,
  6100. advanced: true,
  6101. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  6102. },
  6103. thumbnailOverlayBehavior: {
  6104. type: "select",
  6105. category: "layout",
  6106. options: () => [
  6107. { value: "songsOnly", label: t("thumbnail_overlay_behavior_songs_only") },
  6108. { value: "videosOnly", label: t("thumbnail_overlay_behavior_videos_only") },
  6109. { value: "always", label: t("thumbnail_overlay_behavior_always") },
  6110. { value: "never", label: t("thumbnail_overlay_behavior_never") },
  6111. ],
  6112. default: "songsOnly",
  6113. reloadRequired: false,
  6114. enable: noop,
  6115. },
  6116. thumbnailOverlayToggleBtnShown: {
  6117. type: "toggle",
  6118. category: "layout",
  6119. default: true,
  6120. textAdornment: adornments.reloadRequired,
  6121. },
  6122. thumbnailOverlayShowIndicator: {
  6123. type: "toggle",
  6124. category: "layout",
  6125. default: true,
  6126. textAdornment: adornments.reloadRequired,
  6127. },
  6128. thumbnailOverlayIndicatorOpacity: {
  6129. type: "slider",
  6130. category: "layout",
  6131. min: 5,
  6132. max: 100,
  6133. step: 5,
  6134. default: 40,
  6135. unit: "%",
  6136. advanced: true,
  6137. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  6138. },
  6139. thumbnailOverlayImageFit: {
  6140. type: "select",
  6141. category: "layout",
  6142. options: () => [
  6143. { value: "cover", label: t("thumbnail_overlay_image_fit_crop") },
  6144. { value: "contain", label: t("thumbnail_overlay_image_fit_full") },
  6145. { value: "fill", label: t("thumbnail_overlay_image_fit_stretch") },
  6146. ],
  6147. default: "cover",
  6148. advanced: true,
  6149. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  6150. },
  6151. hideCursorOnIdle: {
  6152. type: "toggle",
  6153. category: "layout",
  6154. default: true,
  6155. reloadRequired: false,
  6156. enable: noop,
  6157. },
  6158. hideCursorOnIdleDelay: {
  6159. type: "slider",
  6160. category: "layout",
  6161. min: 0.5,
  6162. max: 10,
  6163. step: 0.25,
  6164. default: 2,
  6165. unit: "s",
  6166. advanced: true,
  6167. textAdornment: adornments.advanced,
  6168. reloadRequired: false,
  6169. enable: noop,
  6170. },
  6171. fixHdrIssues: {
  6172. type: "toggle",
  6173. category: "layout",
  6174. default: true,
  6175. advanced: true,
  6176. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  6177. },
  6178. disableDarkReaderSites: {
  6179. type: "select",
  6180. category: "layout",
  6181. options: options.siteSelectionOrNone,
  6182. default: "all",
  6183. advanced: true,
  6184. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  6185. },
  6186. showVotes: {
  6187. type: "toggle",
  6188. category: "layout",
  6189. default: true,
  6190. textAdornment: adornments.reloadRequired,
  6191. },
  6192. showVotesFormat: {
  6193. type: "select",
  6194. category: "layout",
  6195. options: () => [
  6196. { value: "long", label: t("votes_format_full") },
  6197. { value: "short", label: t("votes_format_short") },
  6198. ],
  6199. default: "short",
  6200. reloadRequired: false,
  6201. enable: noop,
  6202. },
  6203. // archived idea for future version:
  6204. // showVoteRatio: {
  6205. // type: "select",
  6206. // category: "layout",
  6207. // options: () => [
  6208. // { value: "disabled", label: t("vote_ratio_disabled") },
  6209. // { value: "greenRed", label: t("vote_ratio_green_red") },
  6210. // { value: "blueGray", label: t("vote_ratio_blue_gray") },
  6211. // ],
  6212. // default: "disabled",
  6213. // textAdornment: adornments.reloadRequired,
  6214. // },
  6215. //#region volume
  6216. volumeSliderLabel: {
  6217. type: "toggle",
  6218. category: "volume",
  6219. default: true,
  6220. textAdornment: adornments.reloadRequired,
  6221. },
  6222. volumeSliderSize: {
  6223. type: "number",
  6224. category: "volume",
  6225. min: 50,
  6226. max: 500,
  6227. step: 5,
  6228. default: 150,
  6229. unit: "px",
  6230. textAdornment: adornments.reloadRequired,
  6231. },
  6232. volumeSliderStep: {
  6233. type: "slider",
  6234. category: "volume",
  6235. min: 1,
  6236. max: 25,
  6237. default: 2,
  6238. unit: "%",
  6239. textAdornment: adornments.reloadRequired,
  6240. },
  6241. volumeSliderScrollStep: {
  6242. type: "slider",
  6243. category: "volume",
  6244. min: 1,
  6245. max: 25,
  6246. default: 4,
  6247. unit: "%",
  6248. textAdornment: adornments.reloadRequired,
  6249. },
  6250. volumeSharedBetweenTabs: {
  6251. type: "toggle",
  6252. category: "volume",
  6253. default: false,
  6254. textAdornment: adornments.reloadRequired,
  6255. },
  6256. setInitialTabVolume: {
  6257. type: "toggle",
  6258. category: "volume",
  6259. default: false,
  6260. textAdornment: () => getFeature("volumeSharedBetweenTabs")
  6261. ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reloadRequired])
  6262. : adornments.reloadRequired(),
  6263. },
  6264. initialTabVolumeLevel: {
  6265. type: "slider",
  6266. category: "volume",
  6267. min: 0,
  6268. max: 100,
  6269. step: 1,
  6270. default: 100,
  6271. unit: "%",
  6272. textAdornment: () => getFeature("volumeSharedBetweenTabs")
  6273. ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reloadRequired])
  6274. : adornments.reloadRequired(),
  6275. reloadRequired: false,
  6276. enable: noop,
  6277. },
  6278. //#region song lists
  6279. lyricsQueueButton: {
  6280. type: "toggle",
  6281. category: "songLists",
  6282. default: true,
  6283. textAdornment: adornments.reloadRequired,
  6284. },
  6285. deleteFromQueueButton: {
  6286. type: "toggle",
  6287. category: "songLists",
  6288. default: true,
  6289. textAdornment: adornments.reloadRequired,
  6290. },
  6291. listButtonsPlacement: {
  6292. type: "select",
  6293. category: "songLists",
  6294. options: () => [
  6295. { value: "queueOnly", label: t("list_button_placement_queue_only") },
  6296. { value: "everywhere", label: t("list_button_placement_everywhere") },
  6297. ],
  6298. default: "everywhere",
  6299. advanced: true,
  6300. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  6301. },
  6302. scrollToActiveSongBtn: {
  6303. type: "toggle",
  6304. category: "songLists",
  6305. default: true,
  6306. textAdornment: adornments.reloadRequired,
  6307. },
  6308. clearQueueBtn: {
  6309. type: "toggle",
  6310. category: "songLists",
  6311. default: true,
  6312. textAdornment: adornments.reloadRequired,
  6313. },
  6314. //#region behavior
  6315. disableBeforeUnloadPopup: {
  6316. type: "toggle",
  6317. category: "behavior",
  6318. default: false,
  6319. textAdornment: adornments.reloadRequired,
  6320. },
  6321. closeToastsTimeout: {
  6322. type: "number",
  6323. category: "behavior",
  6324. min: 0,
  6325. max: 30,
  6326. step: 0.5,
  6327. default: 3,
  6328. unit: "s",
  6329. reloadRequired: false,
  6330. enable: noop,
  6331. },
  6332. rememberSongTime: {
  6333. type: "toggle",
  6334. category: "behavior",
  6335. default: true,
  6336. helpText: () => tp("feature_helptext_rememberSongTime", getFeature("rememberSongTimeMinPlayTime"), getFeature("rememberSongTimeMinPlayTime")),
  6337. textAdornment: adornments.reloadRequired,
  6338. },
  6339. rememberSongTimeSites: {
  6340. type: "select",
  6341. category: "behavior",
  6342. options: options.siteSelection,
  6343. default: "all",
  6344. textAdornment: adornments.reloadRequired,
  6345. },
  6346. rememberSongTimeDuration: {
  6347. type: "number",
  6348. category: "behavior",
  6349. min: 1,
  6350. max: 60 * 60 * 24 * 7,
  6351. step: 1,
  6352. default: 60,
  6353. unit: "s",
  6354. advanced: true,
  6355. textAdornment: adornments.advanced,
  6356. reloadRequired: false,
  6357. enable: noop,
  6358. },
  6359. rememberSongTimeReduction: {
  6360. type: "number",
  6361. category: "behavior",
  6362. min: 0,
  6363. max: 30,
  6364. step: 0.05,
  6365. default: 0.2,
  6366. unit: "s",
  6367. advanced: true,
  6368. textAdornment: adornments.advanced,
  6369. reloadRequired: false,
  6370. enable: noop,
  6371. },
  6372. rememberSongTimeMinPlayTime: {
  6373. type: "slider",
  6374. category: "behavior",
  6375. min: 0.5,
  6376. max: 30,
  6377. step: 0.5,
  6378. default: 10,
  6379. unit: "s",
  6380. advanced: true,
  6381. textAdornment: adornments.advanced,
  6382. reloadRequired: false,
  6383. enable: noop,
  6384. },
  6385. //#region input
  6386. arrowKeySupport: {
  6387. type: "toggle",
  6388. category: "input",
  6389. default: true,
  6390. reloadRequired: false,
  6391. enable: noop,
  6392. },
  6393. arrowKeySkipBy: {
  6394. type: "number",
  6395. category: "input",
  6396. min: 0.5,
  6397. max: 60,
  6398. step: 0.5,
  6399. default: 5,
  6400. reloadRequired: false,
  6401. enable: noop,
  6402. },
  6403. switchBetweenSites: {
  6404. type: "toggle",
  6405. category: "input",
  6406. default: true,
  6407. reloadRequired: false,
  6408. enable: noop,
  6409. },
  6410. switchSitesHotkey: {
  6411. type: "hotkey",
  6412. category: "input",
  6413. default: {
  6414. code: "F9",
  6415. shift: false,
  6416. ctrl: false,
  6417. alt: false,
  6418. },
  6419. reloadRequired: false,
  6420. enable: noop,
  6421. },
  6422. anchorImprovements: {
  6423. type: "toggle",
  6424. category: "input",
  6425. default: true,
  6426. textAdornment: adornments.reloadRequired,
  6427. },
  6428. numKeysSkipToTime: {
  6429. type: "toggle",
  6430. category: "input",
  6431. default: true,
  6432. reloadRequired: false,
  6433. enable: noop,
  6434. },
  6435. autoLikeChannels: {
  6436. type: "toggle",
  6437. category: "input",
  6438. default: true,
  6439. textAdornment: adornments.reloadRequired,
  6440. },
  6441. autoLikeChannelToggleBtn: {
  6442. type: "toggle",
  6443. category: "input",
  6444. default: true,
  6445. reloadRequired: false,
  6446. enable: noop,
  6447. },
  6448. // TODO(v2.2):
  6449. // autoLikePlayerBarToggleBtn: {
  6450. // type: "toggle",
  6451. // category: "input",
  6452. // default: false,
  6453. // textAdornment: adornments.reloadRequired,
  6454. // },
  6455. autoLikeTimeout: {
  6456. type: "slider",
  6457. category: "input",
  6458. min: 3,
  6459. max: 30,
  6460. step: 0.5,
  6461. default: 5,
  6462. unit: "s",
  6463. advanced: true,
  6464. reloadRequired: false,
  6465. enable: noop,
  6466. textAdornment: adornments.advanced,
  6467. },
  6468. autoLikeShowToast: {
  6469. type: "toggle",
  6470. category: "input",
  6471. default: true,
  6472. reloadRequired: false,
  6473. advanced: true,
  6474. enable: noop,
  6475. textAdornment: adornments.advanced,
  6476. },
  6477. autoLikeOpenMgmtDialog: {
  6478. type: "button",
  6479. category: "input",
  6480. click: () => getAutoLikeDialog().then(d => d.open()),
  6481. },
  6482. //#region lyrics
  6483. geniusLyrics: {
  6484. type: "toggle",
  6485. category: "lyrics",
  6486. default: true,
  6487. },
  6488. geniUrlBase: {
  6489. type: "text",
  6490. category: "lyrics",
  6491. default: "https://api.sv443.net/geniurl",
  6492. normalize: (val) => val.trim().replace(/\/+$/, ""),
  6493. advanced: true,
  6494. textAdornment: adornments.advanced,
  6495. reloadRequired: false,
  6496. enable: noop,
  6497. },
  6498. geniUrlToken: {
  6499. type: "text",
  6500. valueHidden: true,
  6501. category: "lyrics",
  6502. default: "",
  6503. normalize: (val) => val.trim(),
  6504. advanced: true,
  6505. textAdornment: adornments.advanced,
  6506. reloadRequired: false,
  6507. enable: noop,
  6508. },
  6509. lyricsCacheMaxSize: {
  6510. type: "slider",
  6511. category: "lyrics",
  6512. default: 1000,
  6513. min: 100,
  6514. max: 5000,
  6515. step: 100,
  6516. unit: (val) => " " + tp("unit_entries", val),
  6517. advanced: true,
  6518. textAdornment: adornments.advanced,
  6519. reloadRequired: false,
  6520. enable: noop,
  6521. },
  6522. lyricsCacheTTL: {
  6523. type: "slider",
  6524. category: "lyrics",
  6525. default: 21,
  6526. min: 1,
  6527. max: 100,
  6528. step: 1,
  6529. unit: (val) => " " + tp("unit_days", val),
  6530. advanced: true,
  6531. textAdornment: adornments.advanced,
  6532. reloadRequired: false,
  6533. enable: noop,
  6534. },
  6535. clearLyricsCache: {
  6536. type: "button",
  6537. category: "lyrics",
  6538. click() {
  6539. return __awaiter(this, void 0, void 0, function* () {
  6540. const entries = getLyricsCache().length;
  6541. if (confirm(tp("lyrics_clear_cache_confirm_prompt", entries, entries))) {
  6542. yield clearLyricsCache();
  6543. alert(t("lyrics_clear_cache_success"));
  6544. }
  6545. });
  6546. },
  6547. advanced: true,
  6548. textAdornment: adornments.advanced,
  6549. },
  6550. advancedLyricsFilter: {
  6551. type: "toggle",
  6552. category: "lyrics",
  6553. default: false,
  6554. change: () => setTimeout(() => confirm(t("lyrics_cache_changed_clear_confirm")) && clearLyricsCache(), 200),
  6555. advanced: true,
  6556. textAdornment: adornments.experimental,
  6557. reloadRequired: false,
  6558. enable: noop,
  6559. },
  6560. //#region general
  6561. locale: {
  6562. type: "select",
  6563. category: "general",
  6564. options: options.locale,
  6565. default: getPreferredLocale(),
  6566. textAdornment: () => combineAdornments([adornments.globe, adornments.reloadRequired]),
  6567. },
  6568. localeFallback: {
  6569. type: "toggle",
  6570. category: "general",
  6571. default: true,
  6572. advanced: true,
  6573. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  6574. },
  6575. versionCheck: {
  6576. type: "toggle",
  6577. category: "general",
  6578. default: true,
  6579. textAdornment: adornments.reloadRequired,
  6580. },
  6581. checkVersionNow: {
  6582. type: "button",
  6583. category: "general",
  6584. click: () => doVersionCheck(true),
  6585. },
  6586. logLevel: {
  6587. type: "select",
  6588. category: "general",
  6589. options: () => [
  6590. { value: 0, label: t("log_level_debug") },
  6591. { value: 1, label: t("log_level_info") },
  6592. ],
  6593. default: 1,
  6594. textAdornment: adornments.reloadRequired,
  6595. },
  6596. initTimeout: {
  6597. type: "number",
  6598. category: "general",
  6599. min: 3,
  6600. max: 30,
  6601. default: 8,
  6602. step: 0.1,
  6603. unit: "s",
  6604. advanced: true,
  6605. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  6606. },
  6607. toastDuration: {
  6608. type: "slider",
  6609. category: "general",
  6610. min: 0,
  6611. max: 15,
  6612. default: 3,
  6613. step: 0.5,
  6614. unit: "s",
  6615. reloadRequired: false,
  6616. advanced: true,
  6617. textAdornment: adornments.advanced,
  6618. enable: noop,
  6619. change: () => showIconToast({
  6620. duration: getFeature("toastDuration") * 1000,
  6621. message: "Example",
  6622. iconSrc: getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`),
  6623. }),
  6624. },
  6625. resetConfig: {
  6626. type: "button",
  6627. category: "general",
  6628. click: promptResetConfig,
  6629. textAdornment: adornments.reloadRequired,
  6630. },
  6631. advancedMode: {
  6632. type: "toggle",
  6633. category: "general",
  6634. default: false,
  6635. textAdornment: () => getFeature("advancedMode") ? adornments.advanced() : undefined,
  6636. change: (_key, prevValue, newValue) => prevValue !== newValue &&
  6637. emitSiteEvent("recreateCfgMenu"),
  6638. },
  6639. };
  6640. function noop() {
  6641. }/** If this number is incremented, the features object data will be migrated to the new format */
  6642. const formatVersion = 6;
  6643. const defaultData = Object.keys(featInfo)
  6644. // @ts-ignore
  6645. .filter((ftKey) => { var _a; return ((_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[ftKey]) === null || _a === void 0 ? void 0 : _a.default) !== undefined; })
  6646. .reduce((acc, key) => {
  6647. var _a;
  6648. // @ts-ignore
  6649. acc[key] = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default;
  6650. return acc;
  6651. }, {});
  6652. /** Config data format migration dictionary */
  6653. const migrations = {
  6654. // 1 -> 2 (<=v1.0)
  6655. 2: (oldData) => {
  6656. const queueBtnsEnabled = Boolean(oldData.queueButtons);
  6657. delete oldData.queueButtons;
  6658. return Object.assign(Object.assign({}, oldData), { deleteFromQueueButton: queueBtnsEnabled, lyricsQueueButton: queueBtnsEnabled });
  6659. },
  6660. // 2 -> 3 (v1.0)
  6661. 3: (oldData) => useDefaultConfig(oldData, [
  6662. "removeShareTrackingParam", "numKeysSkipToTime",
  6663. "fixSpacing", "scrollToActiveSongBtn", "logLevel",
  6664. ]),
  6665. // 3 -> 4 (v1.1)
  6666. 4: (oldData) => {
  6667. var _a, _b, _c, _d;
  6668. const oldSwitchSitesHotkey = oldData.switchSitesHotkey;
  6669. return Object.assign(Object.assign({}, useDefaultConfig(oldData, [
  6670. "rememberSongTime", "rememberSongTimeSites",
  6671. "volumeSliderScrollStep", "locale", "versionCheck",
  6672. ])), { arrowKeySkipBy: 10, switchSitesHotkey: {
  6673. code: (_a = oldSwitchSitesHotkey.key) !== null && _a !== void 0 ? _a : "F9",
  6674. shift: Boolean((_b = oldSwitchSitesHotkey.shift) !== null && _b !== void 0 ? _b : false),
  6675. ctrl: Boolean((_c = oldSwitchSitesHotkey.ctrl) !== null && _c !== void 0 ? _c : false),
  6676. alt: Boolean((_d = oldSwitchSitesHotkey.meta) !== null && _d !== void 0 ? _d : false),
  6677. }, listButtonsPlacement: "queueOnly" });
  6678. },
  6679. // 4 -> 5 (v2.0)
  6680. 5: (oldData) => useDefaultConfig(oldData, [
  6681. "localeFallback", "geniUrlBase", "geniUrlToken",
  6682. "lyricsCacheMaxSize", "lyricsCacheTTL",
  6683. "clearLyricsCache", "advancedMode",
  6684. "checkVersionNow", "advancedLyricsFilter",
  6685. "rememberSongTimeDuration", "rememberSongTimeReduction",
  6686. "rememberSongTimeMinPlayTime", "volumeSharedBetweenTabs",
  6687. "setInitialTabVolume", "initialTabVolumeLevel",
  6688. "thumbnailOverlayBehavior", "thumbnailOverlayToggleBtnShown",
  6689. "thumbnailOverlayShowIndicator", "thumbnailOverlayIndicatorOpacity",
  6690. "thumbnailOverlayImageFit", "removeShareTrackingParamSites",
  6691. "fixHdrIssues", "clearQueueBtn",
  6692. "closeToastsTimeout", "disableDarkReaderSites",
  6693. ]),
  6694. // 5 -> 6 (v2.1)
  6695. 6: (oldData) => {
  6696. const newData = useNewDefaultIfUnchanged(useDefaultConfig(oldData, [
  6697. "autoLikeChannels", "autoLikeChannelToggleBtn",
  6698. "autoLikeTimeout", "autoLikeShowToast",
  6699. "autoLikeOpenMgmtDialog", "showVotes",
  6700. "showVotesFormat", "toastDuration",
  6701. "initTimeout",
  6702. // forgot to add this to the migration when adding the feature way before so now will have to do:
  6703. "volumeSliderLabel",
  6704. ]), [
  6705. { key: "rememberSongTimeSites", oldDefault: "ytm" },
  6706. { key: "volumeSliderScrollStep", oldDefault: 10 },
  6707. ]);
  6708. "removeUpgradeTab" in newData && delete newData.removeUpgradeTab;
  6709. return newData;
  6710. },
  6711. // TODO(v2.2): use default for "autoLikePlayerBarToggleBtn"
  6712. // TODO: once advanced filtering is fully implemented, clear cache on migration to fv6
  6713. // 6 -> 7 (vX.X)
  6714. // 7: (oldData: FeatureConfig) =>
  6715. };
  6716. /** Uses the default config as the base, then overwrites all values with the passed {@linkcode baseData}, then sets all passed {@linkcode resetKeys} to their default values */
  6717. function useDefaultConfig(baseData, resetKeys) {
  6718. var _a;
  6719. const newData = Object.assign(Object.assign({}, defaultData), (baseData !== null && baseData !== void 0 ? baseData : {}));
  6720. for (const key of resetKeys) // @ts-ignore
  6721. newData[key] = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default; // typescript funny moments
  6722. return newData;
  6723. }
  6724. /**
  6725. * Uses {@linkcode oldData} as the base, then sets all keys provided in {@linkcode defaults} to their old default values, as long as their current value is equal to the provided old default.
  6726. * This essentially means if someone has changed a feature's value from its old default value, that decision will be respected. Only if it has been left on its old default value, it will be reset to the new default value.
  6727. * Returns a copy of the object.
  6728. */
  6729. function useNewDefaultIfUnchanged(oldData, defaults) {
  6730. var _a;
  6731. const newData = Object.assign({}, oldData);
  6732. for (const { key, oldDefault } of defaults) {
  6733. // @ts-ignore
  6734. const defaultVal = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default;
  6735. if (newData[key] === oldDefault)
  6736. newData[key] = defaultVal; // we love TS
  6737. }
  6738. return newData;
  6739. }
  6740. let canCompress = true;
  6741. const cfgDataStore = new UserUtils.DataStore({
  6742. id: "bytm-config",
  6743. formatVersion,
  6744. defaultData,
  6745. migrations,
  6746. encodeData: (data) => canCompress ? UserUtils.compress(data, compressionFormat, "string") : data,
  6747. decodeData: (data) => canCompress ? UserUtils.decompress(data, compressionFormat, "string") : data,
  6748. });
  6749. /** Initializes the DataStore instance and loads persistent data into memory. Returns a copy of the config object. */
  6750. function initConfig() {
  6751. return __awaiter(this, void 0, void 0, function* () {
  6752. canCompress = yield compressionSupported();
  6753. const oldFmtVer = Number(yield GM.getValue(`_uucfgver-${cfgDataStore.id}`, NaN));
  6754. let data = yield cfgDataStore.loadData();
  6755. // since the config changes so much in development keys need to be fixed in this special way
  6756. if (mode === "development") {
  6757. yield cfgDataStore.setData(fixCfgKeys(data));
  6758. data = cfgDataStore.getData();
  6759. }
  6760. log(`Initialized feature config DataStore (formatVersion = ${cfgDataStore.formatVersion})`);
  6761. if (isNaN(oldFmtVer))
  6762. info(" !- Config data was initialized with default values");
  6763. else if (oldFmtVer !== cfgDataStore.formatVersion) {
  6764. try {
  6765. yield cfgDataStore.setData(data = fixCfgKeys(data));
  6766. info(` !- Config data was migrated from version ${oldFmtVer} to ${cfgDataStore.formatVersion}`);
  6767. }
  6768. catch (err) {
  6769. error(" !- Config data migration failed, falling back to default data:", err);
  6770. yield cfgDataStore.setData(data = cfgDataStore.defaultData);
  6771. }
  6772. }
  6773. emitInterface("bytm:configReady");
  6774. return Object.assign({}, data);
  6775. });
  6776. }
  6777. /**
  6778. * Fixes missing keys in the passed config object with their default values or removes extraneous keys and returns a copy of the fixed object.
  6779. * Returns a copy of the originally passed object if nothing needs to be fixed.
  6780. */
  6781. function fixCfgKeys(cfg) {
  6782. const newCfg = Object.assign({}, cfg);
  6783. const passedKeys = Object.keys(cfg);
  6784. const defaultKeys = Object.keys(defaultData);
  6785. const missingKeys = defaultKeys.filter(k => !passedKeys.includes(k));
  6786. if (missingKeys.length > 0) {
  6787. for (const key of missingKeys)
  6788. newCfg[key] = defaultData[key];
  6789. }
  6790. const extraKeys = passedKeys.filter(k => !defaultKeys.includes(k));
  6791. if (extraKeys.length > 0) {
  6792. for (const key of extraKeys)
  6793. delete newCfg[key];
  6794. }
  6795. return newCfg;
  6796. }
  6797. /** Returns the current feature config from the in-memory cache as a copy */
  6798. function getFeatures() {
  6799. return cfgDataStore.getData();
  6800. }
  6801. /** Returns the value of the feature with the given key from the in-memory cache, as a copy */
  6802. function getFeature(key) {
  6803. return cfgDataStore.getData()[key];
  6804. }
  6805. /** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */
  6806. function setFeatures(featureConf) {
  6807. const res = cfgDataStore.setData(featureConf);
  6808. emitSiteEvent("configChanged", cfgDataStore.getData());
  6809. info("Saved new feature config:", featureConf);
  6810. return res;
  6811. }
  6812. /** Saves the default feature config synchronously to the in-memory cache and asynchronously to persistent storage */
  6813. function setDefaultFeatures() {
  6814. const res = cfgDataStore.saveDefaultData();
  6815. emitSiteEvent("configChanged", cfgDataStore.getData());
  6816. info("Reset feature config to its default values");
  6817. return res;
  6818. }
  6819. function promptResetConfig() {
  6820. return __awaiter(this, void 0, void 0, function* () {
  6821. if (confirm(t("reset_config_confirm"))) {
  6822. closeCfgMenu();
  6823. disableBeforeUnload();
  6824. yield setDefaultFeatures();
  6825. if (location.pathname.startsWith("/watch")) {
  6826. const videoTime = yield getVideoTime(0);
  6827. const url = new URL(location.href);
  6828. url.searchParams.delete("t");
  6829. if (videoTime)
  6830. url.searchParams.set("time_continue", String(videoTime));
  6831. location.replace(url.href);
  6832. }
  6833. else
  6834. location.reload();
  6835. }
  6836. });
  6837. }
  6838. /** Clears the feature config from the persistent storage - since the cache will be out of whack, this should only be run before a site re-/unload */
  6839. function clearConfig() {
  6840. return __awaiter(this, void 0, void 0, function* () {
  6841. yield cfgDataStore.deleteData();
  6842. info("Deleted config from persistent storage");
  6843. });
  6844. }const { getUnsafeWindow, randomId } = UserUtils__namespace;
  6845. /**
  6846. * All functions that can be called on the BYTM interface using `unsafeWindow.BYTM.functionName();` (or `const { functionName } = unsafeWindow.BYTM;`)
  6847. * If prefixed with /**\/, the function is authenticated and requires a token to be passed as the first argument.
  6848. */
  6849. const globalFuncs = {
  6850. // meta:
  6851. registerPlugin,
  6852. /**/ getPluginInfo,
  6853. // bytm-specific:
  6854. getResourceUrl,
  6855. getSessionId,
  6856. // dom:
  6857. addSelectorListener,
  6858. onInteraction,
  6859. getVideoTime,
  6860. getThumbnailUrl,
  6861. getBestThumbnailUrl,
  6862. // translations:
  6863. /**/ setLocale: setLocaleInterface,
  6864. getLocale,
  6865. hasKey,
  6866. hasKeyFor,
  6867. t,
  6868. tp,
  6869. // feature config:
  6870. /**/ getFeatures: getFeaturesInterface,
  6871. /**/ saveFeatures: saveFeaturesInterface,
  6872. // lyrics:
  6873. fetchLyricsUrlTop,
  6874. getLyricsCacheEntry,
  6875. sanitizeArtists,
  6876. sanitizeSong,
  6877. // auto-like:
  6878. /**/ getAutoLikeData: getAutoLikeDataInterface,
  6879. /**/ saveAutoLikeData: saveAutoLikeDataInterface,
  6880. fetchVideoVotes,
  6881. // components:
  6882. createHotkeyInput,
  6883. createToggleInput,
  6884. createCircularBtn,
  6885. createRipple,
  6886. showToast,
  6887. showIconToast,
  6888. };
  6889. /** Initializes the BYTM interface */
  6890. function initInterface() {
  6891. const props = Object.assign(Object.assign(Object.assign({
  6892. // meta / constants
  6893. mode,
  6894. branch,
  6895. host,
  6896. buildNumber,
  6897. compressionFormat }, scriptInfo), globalFuncs), {
  6898. // classes
  6899. NanoEmitter,
  6900. BytmDialog,
  6901. ExImDialog,
  6902. // libraries
  6903. UserUtils: UserUtils__namespace,
  6904. compareVersions: compareVersions__namespace });
  6905. for (const [key, value] of Object.entries(props))
  6906. setGlobalProp(key, value);
  6907. log("Initialized BYTM interface");
  6908. }
  6909. /** Sets a global property on the unsafeWindow.BYTM object */
  6910. function setGlobalProp(key, value) {
  6911. // use unsafeWindow so the properties are available to plugins outside of the userscript's scope
  6912. const win = getUnsafeWindow();
  6913. if (typeof win.BYTM !== "object")
  6914. win.BYTM = {};
  6915. win.BYTM[key] = value;
  6916. }
  6917. /** Emits an event on the BYTM interface */
  6918. function emitInterface(type, ...detail) {
  6919. var _a;
  6920. try {
  6921. getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: (_a = detail === null || detail === void 0 ? void 0 : detail[0]) !== null && _a !== void 0 ? _a : undefined }));
  6922. //@ts-ignore
  6923. emitOnPlugins(type, undefined, ...detail);
  6924. log(`Emitted interface event '${type}'${detail.length > 0 && (detail === null || detail === void 0 ? void 0 : detail[0]) ? " with data:" : ""}`, ...detail);
  6925. }
  6926. catch (err) {
  6927. error(`Couldn't emit interface event '${type}' due to an error:\n`, err);
  6928. }
  6929. }
  6930. //#region register plugins
  6931. /** Map of plugin ID and plugins that are queued up for registration */
  6932. const queuedPlugins = new Map();
  6933. /** Map of plugin ID and all registered plugins */
  6934. const registeredPlugins = new Map();
  6935. /** Map of plugin ID to auth token for plugins that have been registered */
  6936. const registeredPluginTokens = new Map();
  6937. /** Initializes plugins that have been registered already. Needs to be run after `bytm:ready`! */
  6938. function initPlugins() {
  6939. // TODO(v1.3): check perms and ask user for initial activation
  6940. for (const [key, { def, events }] of queuedPlugins) {
  6941. try {
  6942. registeredPlugins.set(key, { def, events });
  6943. queuedPlugins.delete(key);
  6944. emitOnPlugins("pluginRegistered", (d) => sameDef(d, def), pluginDefToInfo(def));
  6945. info(`Initialized plugin '${getPluginKey(def)}'`, LogLevel.Info);
  6946. }
  6947. catch (err) {
  6948. error(`Failed to initialize plugin '${getPluginKey(def)}':`, err);
  6949. }
  6950. }
  6951. emitInterface("bytm:pluginsRegistered");
  6952. }
  6953. /** Returns the key for a given plugin definition */
  6954. function getPluginKey(plugin) {
  6955. return `${plugin.plugin.namespace}/${plugin.plugin.name}`;
  6956. }
  6957. /** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) or undefined, if undefined is passed */
  6958. function pluginDefToInfo(plugin) {
  6959. return plugin
  6960. ? {
  6961. name: plugin.plugin.name,
  6962. namespace: plugin.plugin.namespace,
  6963. version: plugin.plugin.version,
  6964. }
  6965. : undefined;
  6966. }
  6967. /** Checks whether two plugins are the same, given their resolvable definition objects */
  6968. function sameDef(def1, def2) {
  6969. return getPluginKey(def1) === getPluginKey(def2);
  6970. }
  6971. /** Emits an event on all plugins that match the predicate (all plugins by default) */
  6972. function emitOnPlugins(event, predicate = true, ...data) {
  6973. for (const { def, events } of registeredPlugins.values())
  6974. if (typeof predicate === "boolean" ? predicate : predicate(def))
  6975. events.emit(event, ...data);
  6976. }
  6977. /**
  6978. * Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered.
  6979. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  6980. * @public Intended for general use in plugins.
  6981. */
  6982. function getPluginInfo(...args) {
  6983. var _a;
  6984. if (resolveToken(args[0]) === undefined)
  6985. return undefined;
  6986. return pluginDefToInfo((_a = registeredPlugins.get(typeof args[1] === "string" && typeof args[2] === "undefined"
  6987. ? args[1]
  6988. : args.length === 2
  6989. ? `${args[2]}/${args[1]}`
  6990. : getPluginKey(args[1]))) === null || _a === void 0 ? void 0 : _a.def);
  6991. }
  6992. /** Validates the passed PluginDef object and returns an array of errors - returns undefined if there were no errors - never returns an empty array */
  6993. function validatePluginDef(pluginDef) {
  6994. const errors = [];
  6995. const addNoPropErr = (jsonPath, type) => errors.push(t("plugin_validation_error_no_property", jsonPath, type));
  6996. const addInvalidPropErr = (jsonPath, value, examples) => errors.push(tp("plugin_validation_error_invalid_property", examples, jsonPath, value, `'${examples.join("', '")}'`));
  6997. // def.plugin and its properties:
  6998. typeof pluginDef.plugin !== "object" && addNoPropErr("plugin", "object");
  6999. const { plugin } = pluginDef;
  7000. !(plugin === null || plugin === void 0 ? void 0 : plugin.name) && addNoPropErr("plugin.name", "string");
  7001. !(plugin === null || plugin === void 0 ? void 0 : plugin.namespace) && addNoPropErr("plugin.namespace", "string");
  7002. if (typeof (plugin === null || plugin === void 0 ? void 0 : plugin.version) !== "string")
  7003. addNoPropErr("plugin.version", "MAJOR.MINOR.PATCH");
  7004. else if (!compareVersions__namespace.validateStrict(plugin.version))
  7005. addInvalidPropErr("plugin.version", plugin.version, ["0.0.1", "2.5.21-rc.1"]);
  7006. return errors.length > 0 ? errors : undefined;
  7007. }
  7008. /** Registers a plugin on the BYTM interface */
  7009. function registerPlugin(def) {
  7010. var _a, _b;
  7011. const validationErrors = validatePluginDef(def);
  7012. if (validationErrors)
  7013. throw new Error(`Failed to register plugin${((_a = def === null || def === void 0 ? void 0 : def.plugin) === null || _a === void 0 ? void 0 : _a.name) ? ` '${(_b = def === null || def === void 0 ? void 0 : def.plugin) === null || _b === void 0 ? void 0 : _b.name}'` : ""} with invalid definition:\n- ${validationErrors.join("\n- ")}`);
  7014. const events = new NanoEmitter({ publicEmit: true });
  7015. const token = randomId(32, 36);
  7016. const { plugin: { name } } = def;
  7017. queuedPlugins.set(getPluginKey(def), {
  7018. def: def,
  7019. events,
  7020. });
  7021. registeredPluginTokens.set(getPluginKey(def), token);
  7022. info(`Registered plugin: ${name}`, LogLevel.Info);
  7023. return {
  7024. info: getPluginInfo(token, def),
  7025. events,
  7026. token,
  7027. };
  7028. }
  7029. /** Checks whether the passed token is a valid auth token for any registered plugin and returns the plugin ID, else returns undefined */
  7030. function resolveToken(token) {
  7031. var _a, _b;
  7032. return typeof token === "string" && token.length > 0
  7033. ? (_b = (_a = [...registeredPluginTokens.entries()]
  7034. .find(([k, t]) => registeredPlugins.has(k) && token === t)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : undefined
  7035. : undefined;
  7036. }
  7037. //#region proxy funcs
  7038. /**
  7039. * Sets the new locale on the BYTM interface
  7040. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  7041. */
  7042. function setLocaleInterface(token, locale) {
  7043. const pluginId = resolveToken(token);
  7044. if (pluginId === undefined)
  7045. return;
  7046. setLocale(locale);
  7047. emitInterface("bytm:setLocale", { pluginId, locale });
  7048. }
  7049. /**
  7050. * Returns the current feature config, with sensitive values replaced by `undefined`
  7051. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  7052. */
  7053. function getFeaturesInterface(token) {
  7054. if (resolveToken(token) === undefined)
  7055. return undefined;
  7056. const features = getFeatures();
  7057. for (const ftKey of Object.keys(features)) {
  7058. const info = featInfo[ftKey];
  7059. if (info && info.valueHidden) // @ts-ignore
  7060. features[ftKey] = undefined;
  7061. }
  7062. return features;
  7063. }
  7064. /**
  7065. * Saves the passed feature config synchronously to the in-memory cache and asynchronously to the persistent storage.
  7066. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  7067. */
  7068. function saveFeaturesInterface(token, features) {
  7069. if (resolveToken(token) === undefined)
  7070. return;
  7071. setFeatures(features);
  7072. }
  7073. /**
  7074. * Returns the auto-like data.
  7075. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  7076. */
  7077. function getAutoLikeDataInterface(token) {
  7078. if (resolveToken(token) === undefined)
  7079. return;
  7080. return autoLikeStore.getData();
  7081. }
  7082. /**
  7083. * Saves new auto-like data, synchronously to the in-memory cache and asynchronously to the persistent storage.
  7084. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  7085. */
  7086. function saveAutoLikeDataInterface(token, data) {
  7087. if (resolveToken(token) === undefined)
  7088. return;
  7089. return autoLikeStore.setData(data);
  7090. }//#region globals
  7091. /** Options that are applied to every SelectorObserver instance */
  7092. const defaultObserverOptions = {
  7093. disableOnNoListeners: false,
  7094. enableOnAddListener: false,
  7095. defaultDebounce: 150,
  7096. defaultDebounceEdge: "rising",
  7097. };
  7098. /** Global SelectorObserver instances usable throughout the script for improved performance */
  7099. const globservers = {};
  7100. /** Whether all observers have been initialized */
  7101. let globserversReady = false;
  7102. //#region add listener func
  7103. /**
  7104. * Interface function for adding listeners to the {@linkcode globservers}
  7105. * If the observers haven't been initialized yet, the function will queue calls until the `bytm:observersReady` event is emitted
  7106. * @param selector Relative to the observer's root element, so the selector can only start at of the root element's children at the earliest!
  7107. * @param options Options for the listener
  7108. * @template TElem The type of the element that the listener will be attached to. If set to `0`, the default type `HTMLElement` will be used.
  7109. * @template TDomain This restricts which observers are available with the current domain
  7110. */
  7111. function addSelectorListener(observerName, selector, options) {
  7112. try {
  7113. if (!globserversReady) {
  7114. window.addEventListener("bytm:observersReady", () => addSelectorListener(observerName, selector, options), { once: true });
  7115. return;
  7116. }
  7117. globservers[observerName].addListener(selector, options);
  7118. }
  7119. catch (err) {
  7120. error(`Couldn't add listener to globserver '${observerName}':`, err);
  7121. }
  7122. }
  7123. //#region init
  7124. /** Call after DOM load to initialize all SelectorObserver instances */
  7125. function initObservers() {
  7126. try {
  7127. //#region both sites
  7128. //#region body
  7129. // -> the entire <body> element - use sparingly due to performance impacts!
  7130. // enabled immediately
  7131. globservers.body = new UserUtils.SelectorObserver(document.body, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounceEdge: "falling", defaultDebounce: 150, subtree: false }));
  7132. globservers.body.enable();
  7133. switch (getDomain()) {
  7134. case "ytm": {
  7135. //#region YTM
  7136. //#region browseResponse
  7137. // -> for example the /channel/UC... page#
  7138. // enabled by "body"
  7139. const browseResponseSelector = "ytmusic-browse-response";
  7140. globservers.browseResponse = new UserUtils.SelectorObserver(browseResponseSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7141. globservers.body.addListener(browseResponseSelector, {
  7142. listener: () => globservers.browseResponse.enable(),
  7143. });
  7144. //#region navBar
  7145. // -> the navigation / title bar at the top of the page
  7146. // enabled by "body"
  7147. const navBarSelector = "ytmusic-nav-bar";
  7148. globservers.navBar = new UserUtils.SelectorObserver(navBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: false }));
  7149. globservers.body.addListener(navBarSelector, {
  7150. listener: () => globservers.navBar.enable(),
  7151. });
  7152. //#region mainPanel
  7153. // -> the main content panel - includes things like the video element
  7154. // enabled by "body"
  7155. const mainPanelSelector = "ytmusic-player-page #main-panel";
  7156. globservers.mainPanel = new UserUtils.SelectorObserver(mainPanelSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7157. globservers.body.addListener(mainPanelSelector, {
  7158. listener: () => globservers.mainPanel.enable(),
  7159. });
  7160. //#region sideBar
  7161. // -> the sidebar on the left side of the page
  7162. // enabled by "body"
  7163. const sidebarSelector = "ytmusic-app-layout tp-yt-app-drawer";
  7164. globservers.sideBar = new UserUtils.SelectorObserver(sidebarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7165. globservers.body.addListener(sidebarSelector, {
  7166. listener: () => globservers.sideBar.enable(),
  7167. });
  7168. //#region sideBarMini
  7169. // -> the minimized sidebar on the left side of the page
  7170. // enabled by "body"
  7171. const sideBarMiniSelector = "ytmusic-app-layout #mini-guide";
  7172. globservers.sideBarMini = new UserUtils.SelectorObserver(sideBarMiniSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7173. globservers.body.addListener(sideBarMiniSelector, {
  7174. listener: () => globservers.sideBarMini.enable(),
  7175. });
  7176. //#region sidePanel
  7177. // -> the side panel on the right side of the /watch page
  7178. // enabled by "body"
  7179. const sidePanelSelector = "#side-panel";
  7180. globservers.sidePanel = new UserUtils.SelectorObserver(sidePanelSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7181. globservers.body.addListener(sidePanelSelector, {
  7182. listener: () => globservers.sidePanel.enable(),
  7183. });
  7184. //#region playerBar
  7185. // -> media controls bar at the bottom of the page
  7186. // enabled by "body"
  7187. const playerBarSelector = "ytmusic-app-layout ytmusic-player-bar.ytmusic-app";
  7188. globservers.playerBar = new UserUtils.SelectorObserver(playerBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 200 }));
  7189. globservers.body.addListener(playerBarSelector, {
  7190. listener: () => {
  7191. globservers.playerBar.enable();
  7192. },
  7193. });
  7194. //#region playerBarInfo
  7195. // -> song title, artist, album, etc. inside the player bar
  7196. // enabled by "playerBar"
  7197. const playerBarInfoSelector = `${playerBarSelector} .middle-controls .content-info-wrapper`;
  7198. globservers.playerBarInfo = new UserUtils.SelectorObserver(playerBarInfoSelector, Object.assign(Object.assign({}, defaultObserverOptions), { attributes: true, attributeFilter: ["title"] }));
  7199. globservers.playerBar.addListener(playerBarInfoSelector, {
  7200. listener: () => globservers.playerBarInfo.enable(),
  7201. });
  7202. //#region playerBarMiddleButtons
  7203. // -> the buttons inside the player bar (like, dislike, lyrics, etc.)
  7204. // enabled by "playerBar"
  7205. const playerBarMiddleButtonsSelector = ".middle-controls .middle-controls-buttons";
  7206. globservers.playerBarMiddleButtons = new UserUtils.SelectorObserver(playerBarMiddleButtonsSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7207. globservers.playerBar.addListener(playerBarMiddleButtonsSelector, {
  7208. listener: () => globservers.playerBarMiddleButtons.enable(),
  7209. });
  7210. //#region playerBarRightControls
  7211. // -> the controls on the right side of the player bar (volume, repeat, shuffle, etc.)
  7212. // enabled by "playerBar"
  7213. const playerBarRightControls = "#right-controls";
  7214. globservers.playerBarRightControls = new UserUtils.SelectorObserver(playerBarRightControls, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7215. globservers.playerBar.addListener(playerBarRightControls, {
  7216. listener: () => globservers.playerBarRightControls.enable(),
  7217. });
  7218. //#region popupContainer
  7219. // -> the container for popups (e.g. the queue popup)
  7220. // enabled by "body"
  7221. const popupContainerSelector = "ytmusic-app ytmusic-popup-container";
  7222. globservers.popupContainer = new UserUtils.SelectorObserver(popupContainerSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7223. globservers.body.addListener(popupContainerSelector, {
  7224. listener: () => globservers.popupContainer.enable(),
  7225. });
  7226. break;
  7227. }
  7228. case "yt": {
  7229. //#region YT
  7230. //#region ytGuide
  7231. // -> the left sidebar menu
  7232. // enabled by "body"
  7233. const ytGuideSelector = "#content tp-yt-app-drawer#guide #guide-inner-content";
  7234. globservers.ytGuide = new UserUtils.SelectorObserver(ytGuideSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7235. globservers.body.addListener(ytGuideSelector, {
  7236. listener: () => globservers.ytGuide.enable(),
  7237. });
  7238. //#region ytdBrowse
  7239. // -> channel pages for example
  7240. // enabled by "body"
  7241. const ytdBrowseSelector = "ytd-app ytd-page-manager ytd-browse";
  7242. globservers.ytdBrowse = new UserUtils.SelectorObserver(ytdBrowseSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7243. globservers.body.addListener(ytdBrowseSelector, {
  7244. listener: () => globservers.ytdBrowse.enable(),
  7245. });
  7246. //#region ytAppHeader
  7247. // -> header of the page
  7248. // enabled by "ytdBrowse"
  7249. const ytAppHeaderSelector = "#header tp-yt-app-header";
  7250. globservers.ytAppHeader = new UserUtils.SelectorObserver(ytAppHeaderSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7251. globservers.ytdBrowse.addListener(ytAppHeaderSelector, {
  7252. listener: () => globservers.ytAppHeader.enable(),
  7253. });
  7254. //#region ytWatchFlexy
  7255. // -> the main content of the /watch page
  7256. // enabled by "body"
  7257. const ytWatchFlexySelector = "ytd-app ytd-watch-flexy";
  7258. globservers.ytWatchFlexy = new UserUtils.SelectorObserver(ytWatchFlexySelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7259. globservers.body.addListener(ytWatchFlexySelector, {
  7260. listener: () => globservers.ytWatchFlexy.enable(),
  7261. });
  7262. //#region ytWatchMetadata
  7263. // -> the metadata section of the /watch page (title, channel, views, description, buttons, etc. but not comments)
  7264. // enabled by "ytWatchFlexy"
  7265. const ytWatchMetadataSelector = "#columns #primary-inner ytd-watch-metadata";
  7266. globservers.ytWatchMetadata = new UserUtils.SelectorObserver(ytWatchMetadataSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7267. globservers.ytWatchFlexy.addListener(ytWatchMetadataSelector, {
  7268. listener: () => globservers.ytWatchMetadata.enable(),
  7269. });
  7270. //#region ytMasthead
  7271. // -> the masthead (title bar) at the top of the page
  7272. // enabled by "body"
  7273. const mastheadSelector = "#content ytd-masthead#masthead";
  7274. globservers.ytMasthead = new UserUtils.SelectorObserver(mastheadSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7275. globservers.body.addListener(mastheadSelector, {
  7276. listener: () => globservers.ytMasthead.enable(),
  7277. });
  7278. }
  7279. }
  7280. //#region finalize
  7281. globserversReady = true;
  7282. emitInterface("bytm:observersReady");
  7283. }
  7284. catch (err) {
  7285. error("Failed to initialize observers:", err);
  7286. }
  7287. }/** Whether the DOM has finished loading and elements can be added or modified */
  7288. let domLoaded = false;
  7289. document.addEventListener("DOMContentLoaded", () => domLoaded = true);
  7290. //#region video time, volume
  7291. /** Returns the video element selector string based on the current domain */
  7292. const getVideoSelector = () => getDomain() === "ytm" ? "ytmusic-player video" : "#player-container ytd-player video";
  7293. /**
  7294. * Returns the current video time in seconds, with the given {@linkcode precision} (2 decimal digits by default).
  7295. * Rounds down if the precision is set to 0. The maximum average available precision on YTM is 6.
  7296. * Dispatches mouse movement events in case the video time can't be read from the video or progress bar elements (needs a prior user interaction to work)
  7297. * @returns Returns null if the video time is unavailable or no user interaction has happened prior to calling in case of the fallback behavior being used
  7298. */
  7299. function getVideoTime(precision = 2) {
  7300. return new Promise((res) => __awaiter(this, void 0, void 0, function* () {
  7301. yield waitVideoElementReady();
  7302. try {
  7303. if (getDomain() === "ytm") {
  7304. const vidElem = document.querySelector(getVideoSelector());
  7305. if (vidElem)
  7306. return res(Number(precision <= 0 ? Math.floor(vidElem.currentTime) : vidElem.currentTime.toFixed(precision)));
  7307. addSelectorListener("playerBar", "tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar", {
  7308. listener: (pbEl) => res(!isNaN(Number(pbEl.value)) ? Math.floor(Number(pbEl.value)) : null)
  7309. });
  7310. }
  7311. else if (getDomain() === "yt") {
  7312. const vidElem = document.querySelector(getVideoSelector());
  7313. if (vidElem)
  7314. return res(Number(precision <= 0 ? Math.floor(vidElem.currentTime) : vidElem.currentTime.toFixed(precision)));
  7315. // YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it)
  7316. ytForceShowVideoTime();
  7317. const pbSelector = ".ytp-chrome-bottom div.ytp-progress-bar[role=\"slider\"]";
  7318. let videoTime = -1;
  7319. const mut = new MutationObserver(() => {
  7320. // .observe() is only called when the element exists - no need to check for null
  7321. videoTime = Number(document.querySelector(pbSelector).getAttribute("aria-valuenow"));
  7322. });
  7323. const observe = (progElem) => {
  7324. mut.observe(progElem, {
  7325. attributes: true,
  7326. attributeFilter: ["aria-valuenow"],
  7327. });
  7328. if (videoTime >= 0 && !isNaN(videoTime)) {
  7329. res(Math.floor(videoTime));
  7330. mut.disconnect();
  7331. }
  7332. else
  7333. setTimeout(() => {
  7334. res(videoTime >= 0 && !isNaN(videoTime) ? Math.floor(videoTime) : null);
  7335. mut.disconnect();
  7336. }, 500);
  7337. };
  7338. addSelectorListener("body", pbSelector, { listener: observe });
  7339. }
  7340. }
  7341. catch (err) {
  7342. error("Couldn't get video time due to error:", err);
  7343. res(null);
  7344. }
  7345. }));
  7346. }
  7347. /**
  7348. * Sends events that force the video controls to become visible for about 3 seconds.
  7349. * This only works once (for some reason), then the page needs to be reloaded!
  7350. */
  7351. function ytForceShowVideoTime() {
  7352. const player = document.querySelector("#movie_player");
  7353. if (!player)
  7354. return false;
  7355. const defaultProps = {
  7356. // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
  7357. view: UserUtils.getUnsafeWindow(),
  7358. bubbles: true,
  7359. cancelable: false,
  7360. };
  7361. player.dispatchEvent(new MouseEvent("mouseenter", defaultProps));
  7362. const { x, y, width, height } = player.getBoundingClientRect();
  7363. const screenY = Math.round(y + height / 2);
  7364. const screenX = x + Math.min(50, Math.round(width / 3));
  7365. player.dispatchEvent(new MouseEvent("mousemove", Object.assign(Object.assign({}, defaultProps), { screenY,
  7366. screenX, movementX: 5, movementY: 0 })));
  7367. return true;
  7368. }
  7369. /**
  7370. * Waits for the video element to be in its readyState 4 / canplay state and returns it.
  7371. * Resolves immediately if the video element is already ready.
  7372. */
  7373. function waitVideoElementReady() {
  7374. return new Promise((res) => __awaiter(this, void 0, void 0, function* () {
  7375. const waitForEl = () => addSelectorListener("body", getVideoSelector(), {
  7376. listener: (vidElem) => __awaiter(this, void 0, void 0, function* () {
  7377. if (vidElem) {
  7378. // this is just after YT has finished doing their own shenanigans with the video time and volume
  7379. if (vidElem.readyState === 4)
  7380. res(vidElem);
  7381. else
  7382. vidElem.addEventListener("canplay", () => res(vidElem), { once: true });
  7383. }
  7384. }),
  7385. });
  7386. if (!location.pathname.startsWith("/watch"))
  7387. yield siteEvents.once("watchIdChanged");
  7388. waitForEl();
  7389. }));
  7390. }
  7391. //#region css utils
  7392. /**
  7393. * Adds a style element to the DOM at runtime.
  7394. * @param css The CSS stylesheet to add
  7395. * @param ref A reference string to identify the style element - defaults to a random 5-character string
  7396. * @param transform A function to transform the CSS before adding it to the DOM
  7397. */
  7398. function addStyle(css_1, ref_1) {
  7399. return __awaiter(this, arguments, void 0, function* (css, ref, transform = (c) => c) {
  7400. if (!domLoaded)
  7401. throw new Error("DOM has not finished loading yet");
  7402. const elem = UserUtils.addGlobalStyle(yield transform(css));
  7403. elem.id = `bytm-style-${ref !== null && ref !== void 0 ? ref : UserUtils.randomId(5, 36)}`;
  7404. return elem;
  7405. });
  7406. }
  7407. /**
  7408. * Adds a global style element with the contents fetched from the specified CSS resource.
  7409. * The CSS can be transformed using the provided function before being added to the DOM.
  7410. */
  7411. function addStyleFromResource(key_1) {
  7412. return __awaiter(this, arguments, void 0, function* (key, transform = (c) => c) {
  7413. const css = yield fetchCss(key);
  7414. if (css) {
  7415. addStyle(transform(css), key.slice(4));
  7416. return true;
  7417. }
  7418. return false;
  7419. });
  7420. }
  7421. /** Sets a global CSS variable on the &lt;document&gt; element */
  7422. function setGlobalCssVar(name, value) {
  7423. document.documentElement.style.setProperty(`--bytm-global-${name}`, String(value));
  7424. }
  7425. /** Sets multiple global CSS variables on the &lt;document&gt; element */
  7426. function setGlobalCssVars(vars) {
  7427. for (const [name, value] of Object.entries(vars))
  7428. setGlobalCssVar(name, value);
  7429. }
  7430. //#region other
  7431. /** Removes all child nodes of an element without invoking the slow-ish HTML parser */
  7432. function clearInner(element) {
  7433. while (element.hasChildNodes())
  7434. clearNode(element.firstChild);
  7435. }
  7436. /** Removes all child nodes of an element recursively and also removes the element itself */
  7437. function clearNode(element) {
  7438. while (element.hasChildNodes())
  7439. clearNode(element.firstChild);
  7440. element.parentNode.removeChild(element);
  7441. }
  7442. /**
  7443. * Checks if the currently playing media is a song or a video.
  7444. * This function should only be called after awaiting {@linkcode waitVideoElementReady}!
  7445. */
  7446. function currentMediaType() {
  7447. const songImgElem = document.querySelector("ytmusic-player #song-image");
  7448. if (!songImgElem)
  7449. throw new Error("Couldn't find the song image element. Use this function only after `await waitVideoElementReady()`!");
  7450. return UserUtils.getUnsafeWindow().getComputedStyle(songImgElem).display !== "none" ? "song" : "video";
  7451. }
  7452. /** Copies the provided text to the clipboard and shows an error message for manual copying if the grant `GM.setClipboard` is not given. */
  7453. function copyToClipboard(text) {
  7454. try {
  7455. GM.setClipboard(String(text));
  7456. }
  7457. catch (_a) {
  7458. alert(t("copy_to_clipboard_error", String(text)));
  7459. }
  7460. }let curLogLevel = LogLevel.Info;
  7461. /** Common prefix to be able to tell logged messages apart and filter them in devtools */
  7462. const consPrefix = `[${scriptInfo.name}]`;
  7463. const consPrefixDbg = `[${scriptInfo.name}/#DEBUG]`;
  7464. /** Sets the current log level. 0 = Debug, 1 = Info */
  7465. function setLogLevel(level) {
  7466. curLogLevel = level;
  7467. setGlobalProp("logLevel", level);
  7468. if (curLogLevel !== level)
  7469. log("Set the log level to", LogLevel[level]);
  7470. }
  7471. /** Extracts the log level from the last item from spread arguments - returns 0 if the last item is not a number or too low or high */
  7472. function getLogLevel(args) {
  7473. const minLogLvl = 0, maxLogLvl = 1;
  7474. if (typeof args.at(-1) === "number")
  7475. return UserUtils.clamp(args.splice(args.length - 1)[0], minLogLvl, maxLogLvl);
  7476. return LogLevel.Debug;
  7477. }
  7478. /**
  7479. * Logs all passed values to the console, as long as the log level is sufficient.
  7480. * @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if it shouldn't be.
  7481. */
  7482. function log(...args) {
  7483. if (curLogLevel <= getLogLevel(args))
  7484. console.log(consPrefix, ...args);
  7485. }
  7486. /**
  7487. * Logs all passed values to the console as info, as long as the log level is sufficient.
  7488. * @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if it shouldn't be.
  7489. */
  7490. function info(...args) {
  7491. if (curLogLevel <= getLogLevel(args))
  7492. console.info(consPrefix, ...args);
  7493. }
  7494. /** Logs all passed values to the console as a warning, no matter the log level. */
  7495. function warn(...args) {
  7496. console.warn(consPrefix, ...args);
  7497. }
  7498. /** Logs all passed values to the console as an error, no matter the log level. */
  7499. function error(...args) {
  7500. console.error(consPrefix, ...args);
  7501. }
  7502. /** Logs all passed values to the console with a debug-specific prefix */
  7503. function dbg(...args) {
  7504. console.log(consPrefixDbg, ...args);
  7505. }//#region misc
  7506. let cachedDomain;
  7507. /**
  7508. * Returns the current domain as a constant string representation
  7509. * @throws Throws if script runs on an unexpected website
  7510. */
  7511. function getDomain() {
  7512. if (cachedDomain)
  7513. return cachedDomain;
  7514. if (location.hostname.match(/^music\.youtube/))
  7515. return cachedDomain = "ytm";
  7516. else if (location.hostname.match(/youtube\./))
  7517. return cachedDomain = "yt";
  7518. else
  7519. throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
  7520. }
  7521. /** Returns a pseudo-random ID unique to each session - returns null if sessionStorage is unavailable */
  7522. function getSessionId() {
  7523. try {
  7524. let sesId = window.sessionStorage.getItem("_bytm-session-id");
  7525. if (!sesId)
  7526. window.sessionStorage.setItem("_bytm-session-id", sesId = UserUtils.randomId(8, 36));
  7527. return sesId;
  7528. }
  7529. catch (err) {
  7530. warn("Couldn't get session ID, sessionStorage / cookies might be disabled:", err);
  7531. return null;
  7532. }
  7533. }
  7534. let isCompressionSupported;
  7535. /** Tests whether compression via the predefined {@linkcode compressionFormat} is supported (only on the first call, then returns the cached result) */
  7536. function compressionSupported() {
  7537. return __awaiter(this, void 0, void 0, function* () {
  7538. if (typeof isCompressionSupported === "boolean")
  7539. return isCompressionSupported;
  7540. try {
  7541. yield UserUtils.compress(".", compressionFormat, "string");
  7542. return isCompressionSupported = true;
  7543. }
  7544. catch (_a) {
  7545. return isCompressionSupported = false;
  7546. }
  7547. });
  7548. }
  7549. /** Returns a string with the given array's items separated by a default separator (`", "` by default), with an optional different separator for the last item */
  7550. function arrayWithSeparators(array, separator = ", ", lastSeparator) {
  7551. const arr = [...array];
  7552. if (arr.length === 0)
  7553. return "";
  7554. else if (arr.length <= 2)
  7555. return arr.join(lastSeparator);
  7556. else
  7557. return `${arr.slice(0, -1).join(separator)}${lastSeparator}${arr.at(-1)}`;
  7558. }
  7559. /** Returns the watch ID of the current video or null if not on a video page */
  7560. function getWatchId() {
  7561. const { searchParams, pathname } = new URL(location.href);
  7562. return pathname.includes("/watch") ? searchParams.get("v") : null;
  7563. }
  7564. /**
  7565. * Returns the ID of the current channel in the format `@User` or `UC...` from URLs with the path `/@User`, `/@User/videos`, `/channel/UC...` or `/channel/UC.../videos`
  7566. * Returns null if the current page is not a channel page or there was an error parsing the URL
  7567. */
  7568. function getCurrentChannelId() {
  7569. return parseChannelIdFromUrl(location.href);
  7570. }
  7571. /** Returns the channel ID from a URL or null if the URL is invalid */
  7572. function parseChannelIdFromUrl(url) {
  7573. try {
  7574. const { pathname } = url instanceof URL ? url : new URL(url);
  7575. if (pathname.includes("/channel/"))
  7576. return pathname.split("/channel/")[1].split("/")[0];
  7577. else if (pathname.includes("/@"))
  7578. return pathname.split("/@")[1].split("/")[0];
  7579. else
  7580. return null;
  7581. }
  7582. catch (_a) {
  7583. return null;
  7584. }
  7585. }
  7586. /** Returns the thumbnail URL for a video with either a given quality identifier or index */
  7587. function getThumbnailUrl(watchId, qualityOrIndex = "maxresdefault") {
  7588. return `https://i.ytimg.com/vi/${watchId}/${qualityOrIndex}.jpg`;
  7589. }
  7590. /** Returns the best available thumbnail URL for a video with the given watch ID */
  7591. function getBestThumbnailUrl(watchId) {
  7592. return __awaiter(this, void 0, void 0, function* () {
  7593. const priorityList = ["maxresdefault", "sddefault", "hqdefault", 0];
  7594. for (const quality of priorityList) {
  7595. let response;
  7596. const url = getThumbnailUrl(watchId, quality);
  7597. try {
  7598. response = yield sendRequest({ url, method: "HEAD", timeout: 6000 });
  7599. }
  7600. catch (e) {
  7601. }
  7602. if (response && response.status < 300 && response.status >= 200)
  7603. return url;
  7604. }
  7605. });
  7606. }
  7607. /** Opens the given URL in a new tab, using GM.openInTab if available */
  7608. function openInTab(href, background = false) {
  7609. try {
  7610. UserUtils.openInNewTab(href, background);
  7611. }
  7612. catch (_a) {
  7613. window.open(href, "_blank", "noopener noreferrer");
  7614. }
  7615. }
  7616. /** Tries to parse an uncompressed or compressed input string as a JSON object */
  7617. function tryToDecompressAndParse(input) {
  7618. return __awaiter(this, void 0, void 0, function* () {
  7619. let parsed = null;
  7620. try {
  7621. parsed = JSON.parse(input);
  7622. }
  7623. catch (_a) {
  7624. try {
  7625. parsed = JSON.parse(yield UserUtils.decompress(input, compressionFormat, "string"));
  7626. }
  7627. catch (err) {
  7628. error("Couldn't decompress and parse data due to an error:", err);
  7629. return null;
  7630. }
  7631. }
  7632. // artificial timeout to allow animations to finish and because dumb monkey brains *expect* a delay
  7633. yield UserUtils.pauseFor(250);
  7634. return parsed;
  7635. });
  7636. }
  7637. //#region resources
  7638. /**
  7639. * Returns the URL of a resource by its name, as defined in `assets/resources.json`, from GM resource cache - [see GM.getResourceUrl docs](https://wiki.greasespot.net/GM.getResourceUrl)
  7640. * Falls back to a `raw.githubusercontent.com` URL or base64-encoded data URI if the resource is not available in the GM resource cache
  7641. */
  7642. function getResourceUrl(name) {
  7643. return __awaiter(this, void 0, void 0, function* () {
  7644. var _a;
  7645. let url = yield GM.getResourceUrl(name);
  7646. if (!url || url.length === 0) {
  7647. const resource = (_a = GM.info.script.resources) === null || _a === void 0 ? void 0 : _a[name].url;
  7648. if (typeof resource === "string") {
  7649. const resourceUrl = new URL(resource);
  7650. const resourcePath = resourceUrl.pathname;
  7651. if (resourcePath)
  7652. return `https://raw.githubusercontent.com/${repo}/${branch}${resourcePath}`;
  7653. }
  7654. warn(`Couldn't get blob URL nor external URL for @resource '${name}', trying to use base64-encoded fallback`);
  7655. // @ts-ignore
  7656. url = yield GM.getResourceUrl(name, false);
  7657. }
  7658. return url;
  7659. });
  7660. }
  7661. /**
  7662. * Returns the preferred locale of the user, provided it is supported by the userscript.
  7663. * Prioritizes `navigator.language`, then `navigator.languages`, then `"en_US"` as a fallback.
  7664. */
  7665. function getPreferredLocale() {
  7666. var _a;
  7667. const navLang = navigator.language.replace(/-/g, "_");
  7668. const navLangs = navigator.languages
  7669. .filter(lang => lang.match(/^[a-z]{2}(-|_)[A-Z]$/) !== null)
  7670. .map(lang => lang.replace(/-/g, "_"));
  7671. if (Object.entries(langMapping).find(([key]) => key === navLang))
  7672. return navLang;
  7673. for (const loc of navLangs) {
  7674. if (Object.entries(langMapping).find(([key]) => key === loc))
  7675. return loc;
  7676. }
  7677. // if navigator.languages has entries that aren't locale codes in the format xx_XX
  7678. if (navigator.languages.some(lang => lang.match(/^[a-z]{2}$/))) {
  7679. for (const lang of navLangs) {
  7680. const foundLoc = (_a = Object.entries(langMapping).find(([key]) => key.startsWith(lang))) === null || _a === void 0 ? void 0 : _a[0];
  7681. if (foundLoc)
  7682. return foundLoc;
  7683. }
  7684. }
  7685. return "en_US";
  7686. }
  7687. /** Returns the content behind the passed resource identifier as a string, for example to be assigned to an element's innerHTML property */
  7688. function resourceAsString(resource) {
  7689. return __awaiter(this, void 0, void 0, function* () {
  7690. try {
  7691. const resourceUrl = yield getResourceUrl(resource);
  7692. if (!resourceUrl)
  7693. throw new Error(`Couldn't find URL for resource '${resource}'`);
  7694. return yield (yield UserUtils.fetchAdvanced(resourceUrl)).text();
  7695. }
  7696. catch (err) {
  7697. error("Couldn't get SVG element from resource:", err);
  7698. return null;
  7699. }
  7700. });
  7701. }
  7702. /** Parses a markdown string using marked and turns it into an HTML string with default settings - doesn't sanitize against XSS! */
  7703. function parseMarkdown(mdString) {
  7704. return marked.marked.parse(mdString, {
  7705. async: true,
  7706. gfm: true,
  7707. });
  7708. }
  7709. /** Returns the content of the changelog markdown file */
  7710. function getChangelogMd() {
  7711. return __awaiter(this, void 0, void 0, function* () {
  7712. return yield (yield UserUtils.fetchAdvanced(yield getResourceUrl("doc-changelog"))).text();
  7713. });
  7714. }
  7715. /** Returns the changelog as HTML with a details element for each version */
  7716. function getChangelogHtmlWithDetails() {
  7717. return __awaiter(this, void 0, void 0, function* () {
  7718. try {
  7719. const changelogMd = yield getChangelogMd();
  7720. let changelogHtml = yield parseMarkdown(changelogMd);
  7721. const getVerId = (verStr) => verStr.trim().replace(/[._#\s-]/g, "");
  7722. changelogHtml = changelogHtml.replace(/<div\s+class="split">\s*<\/div>\s*\n?\s*<br(\s\/)?>/gm, "</details>\n<br>\n<details class=\"bytm-changelog-version-details\" tabindex=\"0\">");
  7723. const h2Matches = Array.from(changelogHtml.matchAll(/<h2(\s+id=".+")?>([\d\w\s.]+)<\/h2>/gm));
  7724. for (const match of h2Matches) {
  7725. const [fullMatch, , verStr] = match;
  7726. const verId = getVerId(verStr);
  7727. const h2Elem = `<h2 id="${verId}" role="subheading" aria-level="1">${verStr}</h2>`;
  7728. const summaryElem = `<summary tab-index="0">${h2Elem}</summary>`;
  7729. changelogHtml = changelogHtml.replace(fullMatch, `${summaryElem}`);
  7730. }
  7731. changelogHtml = `<details class="bytm-changelog-version-details" tabindex="0">${changelogHtml}</details>`;
  7732. return changelogHtml;
  7733. }
  7734. catch (err) {
  7735. return `Error while preparing changelog: ${err}`;
  7736. }
  7737. });
  7738. }/**
  7739. * Constructs a URL from a base URL and a record of query parameters.
  7740. * If a value is null, the parameter will be valueless. If a value is undefined, the parameter will be omitted.
  7741. * All values will be stringified using their `toString()` method and then URI-encoded.
  7742. * @returns Returns a string instead of a URL object
  7743. */
  7744. function constructUrlString(baseUrl, params) {
  7745. return `${baseUrl}?${Object.entries(params)
  7746. .filter(([, v]) => v !== undefined)
  7747. .map(([key, val]) => `${key}${val === null ? "" : `=${encodeURIComponent(String(val))}`}`)
  7748. .join("&")}`;
  7749. }
  7750. /**
  7751. * Constructs a URL object from a base URL and a record of query parameters.
  7752. * If a value is null, the parameter will be valueless. If a value is undefined, the parameter will be omitted.
  7753. * All values will be URI-encoded.
  7754. * @returns Returns a URL object instead of a string
  7755. */
  7756. function constructUrl(base, params) {
  7757. return new URL(constructUrlString(base, params));
  7758. }
  7759. /**
  7760. * Sends a request with the specified parameters and returns the response as a Promise.
  7761. * Ignores [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS), contrary to fetch and fetchAdvanced.
  7762. */
  7763. function sendRequest(details) {
  7764. return new Promise((resolve, reject) => {
  7765. GM.xmlHttpRequest(Object.assign(Object.assign({ timeout: 10000 }, details), { onload: resolve, onerror: reject, ontimeout: reject, onabort: reject }));
  7766. });
  7767. }
  7768. /** Fetches a CSS file from the specified resource with a key starting with `css-` */
  7769. function fetchCss(key) {
  7770. return __awaiter(this, void 0, void 0, function* () {
  7771. try {
  7772. const css = yield (yield UserUtils.fetchAdvanced(yield getResourceUrl(key))).text();
  7773. return css !== null && css !== void 0 ? css : undefined;
  7774. }
  7775. catch (err) {
  7776. error("Couldn't fetch CSS due to an error:", err);
  7777. return undefined;
  7778. }
  7779. });
  7780. }
  7781. /** Cache for the vote data of YouTube videos to prevent some unnecessary requests */
  7782. const voteCache = new Map();
  7783. /** Time-to-live for the vote cache in milliseconds */
  7784. const voteCacheTTL = 1000 * 60 * 10;
  7785. /**
  7786. * Fetches the votes object for a YouTube video from the [Return YouTube Dislike API.](https://returnyoutubedislike.com/docs)
  7787. * @param watchId The watch ID of the video
  7788. */
  7789. function fetchVideoVotes(watchId) {
  7790. return __awaiter(this, void 0, void 0, function* () {
  7791. try {
  7792. if (voteCache.has(watchId)) {
  7793. const cached = voteCache.get(watchId);
  7794. if (Date.now() - cached.timestamp < voteCacheTTL) {
  7795. info(`Returning cached video votes for watch ID '${watchId}':`, cached);
  7796. return cached;
  7797. }
  7798. else
  7799. voteCache.delete(watchId);
  7800. }
  7801. const votesRaw = JSON.parse((yield sendRequest({
  7802. method: "GET",
  7803. url: `https://returnyoutubedislikeapi.com/votes?videoId=${watchId}`,
  7804. })).response);
  7805. if (!("id" in votesRaw) || !("likes" in votesRaw) || !("dislikes" in votesRaw) || !("rating" in votesRaw)) {
  7806. error("Couldn't parse video votes due to an error:", votesRaw);
  7807. return undefined;
  7808. }
  7809. const votesObj = {
  7810. id: votesRaw.id,
  7811. likes: votesRaw.likes,
  7812. dislikes: votesRaw.dislikes,
  7813. rating: votesRaw.rating,
  7814. timestamp: Date.now(),
  7815. };
  7816. voteCache.set(votesObj.id, votesObj);
  7817. info(`Fetched video votes for watch ID '${watchId}':`, votesObj);
  7818. return votesObj;
  7819. }
  7820. catch (err) {
  7821. error("Couldn't fetch video votes due to an error:", err);
  7822. return undefined;
  7823. }
  7824. });
  7825. }/** Central serializer for all data stores */
  7826. const storeSerializer = new UserUtils.DataStoreSerializer([
  7827. cfgDataStore,
  7828. autoLikeStore,
  7829. ], {
  7830. addChecksum: true,
  7831. ensureIntegrity: true,
  7832. });//#region cns. watermark
  7833. {
  7834. // console watermark with sexy gradient
  7835. const styleGradient = "background: rgba(165, 38, 38, 1); background: linear-gradient(90deg, rgb(154, 31, 103) 0%, rgb(135, 31, 31) 40%, rgb(184, 64, 41) 100%);";
  7836. const styleCommon = "color: #fff; font-size: 1.3rem;";
  7837. console.log(`%c${scriptInfo.name}%c${scriptInfo.version}%c • ${scriptInfo.namespace}%c\n\nBuild #${buildNumber}`, `${styleCommon} ${styleGradient} font-weight: bold; padding-left: 6px; padding-right: 6px;`, `${styleCommon} background-color: #333; padding-left: 8px; padding-right: 8px;`, "color: #fff; font-size: 1.2rem;", "padding: initial; font-size: 0.9rem;");
  7838. console.log([
  7839. "Powered by:",
  7840. "─ Lots of ambition and dedication",
  7841. "─ My song metadata API: https://api.sv443.net/geniurl",
  7842. "─ My userscript utility library: https://github.com/Sv443-Network/UserUtils",
  7843. "─ This library for semver comparison: https://github.com/omichelsen/compare-versions",
  7844. "─ This tiny event listener library: https://github.com/ai/nanoevents",
  7845. "─ This markdown parser library: https://github.com/markedjs/marked",
  7846. "─ This fuzzy search library: https://github.com/krisk/Fuse",
  7847. ].join("\n"));
  7848. }
  7849. //#region preInit
  7850. /** Stuff that needs to be called ASAP, before anything async happens */
  7851. function preInit() {
  7852. try {
  7853. log("Session ID:", getSessionId());
  7854. initInterface();
  7855. setLogLevel(defaultLogLevel);
  7856. if (getDomain() === "ytm")
  7857. initBeforeUnloadHook();
  7858. init();
  7859. }
  7860. catch (err) {
  7861. return error("Fatal pre-init error:", err);
  7862. }
  7863. }
  7864. //#region init
  7865. function init() {
  7866. return __awaiter(this, void 0, void 0, function* () {
  7867. var _a, _b;
  7868. try {
  7869. const domain = getDomain();
  7870. const features = yield initConfig();
  7871. setLogLevel(features.logLevel);
  7872. yield initLyricsCache();
  7873. yield initTranslations((_a = features.locale) !== null && _a !== void 0 ? _a : "en_US");
  7874. setLocale((_b = features.locale) !== null && _b !== void 0 ? _b : "en_US");
  7875. emitInterface("bytm:registerPlugins");
  7876. if (features.disableBeforeUnloadPopup && domain === "ytm")
  7877. disableBeforeUnload();
  7878. if (features.rememberSongTime)
  7879. initRememberSongTime();
  7880. if (!domLoaded)
  7881. document.addEventListener("DOMContentLoaded", onDomLoad, { once: true });
  7882. else
  7883. onDomLoad();
  7884. }
  7885. catch (err) {
  7886. error("Fatal error:", err);
  7887. }
  7888. });
  7889. }
  7890. //#region onDomLoad
  7891. /** Called when the DOM has finished loading and can be queried and altered by the userscript */
  7892. function onDomLoad() {
  7893. return __awaiter(this, void 0, void 0, function* () {
  7894. const domain = getDomain();
  7895. const feats = getFeatures();
  7896. const ftInit = [];
  7897. // for being able to apply domain-specific styles (prefix any CSS selector with "body.bytm-dom-yt" or "body.bytm-dom-ytm")
  7898. document.body.classList.add(`bytm-dom-${domain}`);
  7899. try {
  7900. initGlobalCssVars();
  7901. initObservers();
  7902. yield Promise.allSettled([
  7903. injectCssBundle(),
  7904. initVersionCheck(),
  7905. ]);
  7906. }
  7907. catch (err) {
  7908. error("Fatal error in feature pre-init:", err);
  7909. return;
  7910. }
  7911. log(`DOM loaded and feature pre-init finished, now initializing all features for domain "${domain}"...`);
  7912. try {
  7913. //#region welcome dlg
  7914. if (typeof (yield GM.getValue("bytm-installed")) !== "string") {
  7915. // open welcome menu with language selector
  7916. const dlg = yield getWelcomeDialog();
  7917. dlg.on("close", () => GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version })));
  7918. info("Showing welcome menu");
  7919. yield dlg.open();
  7920. }
  7921. if (domain === "ytm") {
  7922. //#region (ytm) layout
  7923. if (feats.watermarkEnabled)
  7924. ftInit.push(["addWatermark", addWatermark()]);
  7925. if (feats.fixSpacing)
  7926. ftInit.push(["fixSpacing", fixSpacing()]);
  7927. ftInit.push(["thumbnailOverlay", initThumbnailOverlay()]);
  7928. if (feats.hideCursorOnIdle)
  7929. ftInit.push(["hideCursorOnIdle", initHideCursorOnIdle()]);
  7930. if (feats.fixHdrIssues)
  7931. ftInit.push(["fixHdrIssues", fixHdrIssues()]);
  7932. if (feats.showVotes)
  7933. ftInit.push(["showVotes", initShowVotes()]);
  7934. //#region (ytm) volume
  7935. ftInit.push(["volumeFeatures", initVolumeFeatures()]);
  7936. //#region (ytm) song lists
  7937. if (feats.lyricsQueueButton || feats.deleteFromQueueButton)
  7938. ftInit.push(["queueButtons", initQueueButtons()]);
  7939. ftInit.push(["aboveQueueBtns", initAboveQueueBtns()]);
  7940. //#region (ytm) behavior
  7941. if (feats.closeToastsTimeout > 0)
  7942. ftInit.push(["autoCloseToasts", initAutoCloseToasts()]);
  7943. //#region (ytm) input
  7944. ftInit.push(["arrowKeySkip", initArrowKeySkip()]);
  7945. if (feats.anchorImprovements)
  7946. ftInit.push(["anchorImprovements", addAnchorImprovements()]);
  7947. ftInit.push(["numKeysSkip", initNumKeysSkip()]);
  7948. //#region (ytm) lyrics
  7949. if (feats.geniusLyrics)
  7950. ftInit.push(["playerBarLyricsBtn", addPlayerBarLyricsBtn()]);
  7951. }
  7952. //#region (ytm+yt) cfg menu
  7953. try {
  7954. if (domain === "ytm") {
  7955. addSelectorListener("body", "tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
  7956. listener: addConfigMenuOptionYTM,
  7957. });
  7958. }
  7959. else if (domain === "yt") {
  7960. addSelectorListener("ytGuide", "#sections ytd-guide-section-renderer:nth-child(5) #items ytd-guide-entry-renderer:nth-child(1)", {
  7961. listener: (el) => el.parentElement && addConfigMenuOptionYT(el.parentElement),
  7962. });
  7963. }
  7964. }
  7965. catch (err) {
  7966. error("Couldn't add config menu option:", err);
  7967. }
  7968. if (["ytm", "yt"].includes(domain)) {
  7969. //#region general
  7970. ftInit.push(["initSiteEvents", initSiteEvents()]);
  7971. //#region (ytm+yt) layout
  7972. if (feats.disableDarkReaderSites !== "none")
  7973. disableDarkReader();
  7974. if (feats.removeShareTrackingParamSites && (feats.removeShareTrackingParamSites === domain || feats.removeShareTrackingParamSites === "all"))
  7975. ftInit.push(["initRemShareTrackParam", initRemShareTrackParam()]);
  7976. //#region (ytm+yt) input
  7977. ftInit.push(["siteSwitch", initSiteSwitch(domain)]);
  7978. if (feats.autoLikeChannels)
  7979. ftInit.push(["autoLikeChannels", initAutoLike()]);
  7980. }
  7981. emitInterface("bytm:featureInitStarted");
  7982. try {
  7983. initPlugins();
  7984. }
  7985. catch (err) {
  7986. error("Plugin loading error:", err);
  7987. emitInterface("bytm:fatalError", "Error while loading plugins");
  7988. }
  7989. const initStartTs = Date.now();
  7990. // wait for feature init or timeout (in case an init function is hung up on a promise)
  7991. yield Promise.race([
  7992. UserUtils.pauseFor(feats.initTimeout > 0 ? feats.initTimeout * 1000 : 8000),
  7993. Promise.allSettled(ftInit.map(([name, prom]) => new Promise((res) => __awaiter(this, void 0, void 0, function* () {
  7994. const v = yield prom;
  7995. emitInterface("bytm:featureInitialized", name);
  7996. res(v);
  7997. })))),
  7998. ]);
  7999. emitInterface("bytm:ready");
  8000. info(`Done initializing all ${ftInit.length} features after ${Math.floor(Date.now() - initStartTs)}ms`);
  8001. try {
  8002. registerDevMenuCommands();
  8003. }
  8004. catch (e) {
  8005. warn("Couldn't register dev menu commands:", e);
  8006. }
  8007. }
  8008. catch (err) {
  8009. error("Feature error:", err);
  8010. emitInterface("bytm:fatalError", "Error while initializing features");
  8011. }
  8012. });
  8013. }
  8014. //#region css
  8015. /** Inserts the bundled CSS files imported throughout the script into a <style> element in the <head> */
  8016. function injectCssBundle() {
  8017. return __awaiter(this, void 0, void 0, function* () {
  8018. if (!(yield addStyleFromResource("css-bundle")))
  8019. error("Couldn't inject CSS bundle due to an error");
  8020. });
  8021. }
  8022. /** Initializes global CSS variables */
  8023. function initGlobalCssVars() {
  8024. const applyVars = () => setGlobalCssVars({
  8025. "inner-height": `${window.innerHeight}px`,
  8026. "outer-height": `${window.outerHeight}px`,
  8027. "inner-width": `${window.innerWidth}px`,
  8028. "outer-width": `${window.outerWidth}px`,
  8029. });
  8030. window.addEventListener("resize", applyVars);
  8031. applyVars();
  8032. }
  8033. //#region dev menu cmds
  8034. /** Registers dev commands using `GM.registerMenuCommand` */
  8035. function registerDevMenuCommands() {
  8036. if (mode !== "development")
  8037. return;
  8038. GM.registerMenuCommand("Reset config", () => __awaiter(this, void 0, void 0, function* () {
  8039. if (confirm("Reset the configuration to its default values?\nThis will automatically reload the page.")) {
  8040. yield clearConfig();
  8041. disableBeforeUnload();
  8042. location.reload();
  8043. }
  8044. }), "r");
  8045. GM.registerMenuCommand("Fix config values", () => __awaiter(this, void 0, void 0, function* () {
  8046. const oldFeats = JSON.parse(JSON.stringify(getFeatures()));
  8047. yield setFeatures(fixCfgKeys(oldFeats));
  8048. dbg("Fixed missing or extraneous config values.\nFrom:", oldFeats, "\n\nTo:", getFeatures());
  8049. if (confirm("All missing or config values were set to their default values and extraneous ones were removed.\nDo you want to reload the page now?"))
  8050. location.reload();
  8051. }));
  8052. GM.registerMenuCommand("List GM values in console with decompression", () => __awaiter(this, void 0, void 0, function* () {
  8053. const keys = yield GM.listValues();
  8054. dbg(`GM values (${keys.length}):`);
  8055. if (keys.length === 0)
  8056. dbg(" No values found.");
  8057. const values = {};
  8058. let longestKey = 0;
  8059. for (const key of keys) {
  8060. const isEncoded = key.startsWith("_uucfg-") ? yield GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
  8061. const val = yield GM.getValue(key, undefined);
  8062. values[key] = typeof val !== "undefined" && isEncoded ? yield UserUtils.decompress(val, compressionFormat, "string") : val;
  8063. longestKey = Math.max(longestKey, key.length);
  8064. }
  8065. for (const [key, finalVal] of Object.entries(values)) {
  8066. const isEncoded = key.startsWith("_uucfg-") ? yield GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
  8067. const lengthStr = String(finalVal).length > 50 ? `(${String(finalVal).length} chars) ` : "";
  8068. dbg(` "${key}"${" ".repeat(longestKey - key.length)} -${isEncoded ? "-[decoded]-" : ""}> ${lengthStr}${finalVal}`);
  8069. }
  8070. }), "l");
  8071. GM.registerMenuCommand("List GM values in console, without decompression", () => __awaiter(this, void 0, void 0, function* () {
  8072. const keys = yield GM.listValues();
  8073. dbg(`GM values (${keys.length}):`);
  8074. if (keys.length === 0)
  8075. dbg(" No values found.");
  8076. const values = {};
  8077. let longestKey = 0;
  8078. for (const key of keys) {
  8079. const val = yield GM.getValue(key, undefined);
  8080. values[key] = val;
  8081. longestKey = Math.max(longestKey, key.length);
  8082. }
  8083. for (const [key, val] of Object.entries(values)) {
  8084. const lengthStr = String(val).length >= 16 ? `(${String(val).length} chars) ` : "";
  8085. dbg(` "${key}"${" ".repeat(longestKey - key.length)} -> ${lengthStr}${val}`);
  8086. }
  8087. }));
  8088. GM.registerMenuCommand("Delete all GM values", () => __awaiter(this, void 0, void 0, function* () {
  8089. const keys = yield GM.listValues();
  8090. if (confirm(`Clear all ${keys.length} GM values?\nSee console for details.`)) {
  8091. dbg(`Clearing ${keys.length} GM values:`);
  8092. if (keys.length === 0)
  8093. dbg(" No values found.");
  8094. for (const key of keys) {
  8095. yield GM.deleteValue(key);
  8096. dbg(` Deleted ${key}`);
  8097. }
  8098. }
  8099. }), "d");
  8100. GM.registerMenuCommand("Delete GM values by name (comma separated)", () => __awaiter(this, void 0, void 0, function* () {
  8101. var _a;
  8102. const keys = prompt("Enter the name(s) of the GM value to delete (comma separated).\nEmpty input cancels the operation.");
  8103. if (!keys)
  8104. return;
  8105. for (const key of (_a = keys === null || keys === void 0 ? void 0 : keys.split(",")) !== null && _a !== void 0 ? _a : []) {
  8106. if (key && key.length > 0) {
  8107. const truncLength = 400;
  8108. const oldVal = yield GM.getValue(key);
  8109. yield GM.deleteValue(key);
  8110. dbg(`Deleted GM value '${key}' with previous value '${oldVal && String(oldVal).length > truncLength ? String(oldVal).substring(0, truncLength) + `… (${String(oldVal).length} / ${truncLength} chars.)` : oldVal}'`);
  8111. }
  8112. }
  8113. }), "n");
  8114. GM.registerMenuCommand("Reset install timestamp", () => __awaiter(this, void 0, void 0, function* () {
  8115. yield GM.deleteValue("bytm-installed");
  8116. dbg("Reset install time.");
  8117. }), "t");
  8118. GM.registerMenuCommand("Reset version check timestamp", () => __awaiter(this, void 0, void 0, function* () {
  8119. yield GM.deleteValue("bytm-version-check");
  8120. dbg("Reset version check time.");
  8121. }), "v");
  8122. GM.registerMenuCommand("List active selector listeners in console", () => __awaiter(this, void 0, void 0, function* () {
  8123. const lines = [];
  8124. let listenersAmt = 0;
  8125. for (const [obsName, obs] of Object.entries(globservers)) {
  8126. const listeners = obs.getAllListeners();
  8127. lines.push(`- "${obsName}" (${listeners.size} listeners):`);
  8128. [...listeners].forEach(([k, v]) => {
  8129. listenersAmt += v.length;
  8130. lines.push(` [${v.length}] ${k}`);
  8131. v.forEach(({ all, continuous }, i) => {
  8132. lines.push(` ${v.length > 1 && i !== v.length - 1 ? "├" : "└"}> ${continuous ? "continuous" : "single-shot"}${all ? ", multiple" : ""}`);
  8133. });
  8134. });
  8135. }
  8136. dbg(`Showing currently active listeners for ${Object.keys(globservers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
  8137. }), "s");
  8138. GM.registerMenuCommand("Compress value", () => __awaiter(this, void 0, void 0, function* () {
  8139. const input = prompt("Enter the value to compress.\nSee console for output.");
  8140. if (input && input.length > 0) {
  8141. const compressed = yield UserUtils.compress(input, compressionFormat);
  8142. dbg(`Compression result (${input.length} chars -> ${compressed.length} chars)\nValue: ${compressed}`);
  8143. }
  8144. }));
  8145. GM.registerMenuCommand("Decompress value", () => __awaiter(this, void 0, void 0, function* () {
  8146. const input = prompt("Enter the value to decompress.\nSee console for output.");
  8147. if (input && input.length > 0) {
  8148. const decompressed = yield UserUtils.decompress(input, compressionFormat);
  8149. dbg(`Decompresion result (${input.length} chars -> ${decompressed.length} chars)\nValue: ${decompressed}`);
  8150. }
  8151. }));
  8152. GM.registerMenuCommand("Export data using DataStoreSerializer", () => __awaiter(this, void 0, void 0, function* () {
  8153. const ser = yield storeSerializer.serialize();
  8154. dbg("Serialized data stores:", JSON.stringify(JSON.parse(ser)));
  8155. alert("See console.");
  8156. }));
  8157. GM.registerMenuCommand("Import data using DataStoreSerializer", () => __awaiter(this, void 0, void 0, function* () {
  8158. const input = prompt("Enter the serialized data to import:");
  8159. if (input && input.length > 0) {
  8160. yield storeSerializer.deserialize(input);
  8161. alert("Imported data. Reload the page to apply changes.");
  8162. }
  8163. }));
  8164. log("Registered dev menu commands");
  8165. }
  8166. preInit();})(UserUtils,compareVersions,marked,Fuse);//# sourceMappingURL=http://localhost:8710/BetterYTM.user.js.map