BetterYTM.user.js 405 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509651065116512651365146515651665176518651965206521652265236524652565266527652865296530653165326533653465356536653765386539654065416542654365446545654665476548654965506551655265536554655565566557655865596560656165626563656465656566656765686569657065716572657365746575657665776578657965806581658265836584658565866587658865896590659165926593659465956596659765986599660066016602660366046605660666076608660966106611661266136614661566166617661866196620662166226623662466256626662766286629663066316632663366346635663666376638663966406641664266436644664566466647664866496650665166526653665466556656665766586659666066616662666366646665666666676668666966706671667266736674667566766677667866796680668166826683668466856686668766886689669066916692669366946695669666976698669967006701670267036704670567066707670867096710671167126713671467156716671767186719672067216722672367246725672667276728672967306731673267336734673567366737673867396740674167426743674467456746674767486749675067516752675367546755675667576758675967606761676267636764676567666767676867696770677167726773677467756776677767786779678067816782678367846785678667876788678967906791679267936794679567966797679867996800680168026803680468056806680768086809681068116812681368146815681668176818681968206821682268236824682568266827682868296830683168326833683468356836683768386839684068416842684368446845684668476848684968506851685268536854685568566857685868596860686168626863686468656866686768686869687068716872687368746875687668776878687968806881688268836884688568866887688868896890689168926893689468956896689768986899690069016902690369046905690669076908690969106911691269136914691569166917691869196920692169226923692469256926692769286929693069316932693369346935693669376938693969406941694269436944694569466947694869496950695169526953695469556956695769586959696069616962696369646965696669676968696969706971697269736974697569766977697869796980698169826983698469856986698769886989699069916992699369946995699669976998699970007001700270037004700570067007700870097010701170127013701470157016701770187019702070217022702370247025702670277028702970307031703270337034703570367037703870397040704170427043704470457046704770487049705070517052705370547055705670577058705970607061706270637064706570667067706870697070707170727073707470757076707770787079708070817082708370847085708670877088708970907091709270937094709570967097709870997100710171027103710471057106710771087109711071117112711371147115711671177118711971207121712271237124712571267127712871297130713171327133713471357136713771387139714071417142714371447145714671477148714971507151715271537154715571567157715871597160716171627163716471657166716771687169717071717172717371747175717671777178717971807181718271837184718571867187718871897190719171927193719471957196719771987199720072017202720372047205720672077208720972107211721272137214721572167217721872197220722172227223722472257226722772287229723072317232723372347235723672377238723972407241724272437244724572467247724872497250725172527253725472557256725772587259726072617262726372647265726672677268726972707271727272737274727572767277727872797280728172827283728472857286728772887289729072917292729372947295729672977298729973007301730273037304730573067307730873097310731173127313731473157316731773187319732073217322732373247325732673277328732973307331733273337334733573367337733873397340734173427343734473457346734773487349735073517352735373547355735673577358735973607361736273637364736573667367736873697370737173727373737473757376737773787379738073817382738373847385738673877388738973907391739273937394739573967397739873997400740174027403740474057406740774087409741074117412741374147415741674177418741974207421742274237424742574267427742874297430743174327433743474357436743774387439744074417442744374447445744674477448744974507451745274537454745574567457745874597460746174627463746474657466746774687469747074717472747374747475747674777478747974807481748274837484748574867487748874897490749174927493749474957496749774987499750075017502750375047505750675077508750975107511751275137514751575167517751875197520752175227523752475257526752775287529753075317532753375347535753675377538753975407541754275437544754575467547754875497550755175527553755475557556755775587559756075617562756375647565756675677568756975707571757275737574757575767577757875797580758175827583758475857586758775887589759075917592759375947595759675977598759976007601760276037604760576067607760876097610761176127613761476157616761776187619762076217622762376247625762676277628762976307631763276337634763576367637763876397640764176427643764476457646764776487649765076517652765376547655765676577658765976607661766276637664766576667667766876697670767176727673767476757676767776787679768076817682768376847685768676877688768976907691769276937694769576967697769876997700770177027703770477057706770777087709771077117712771377147715771677177718771977207721772277237724772577267727772877297730773177327733773477357736773777387739774077417742774377447745774677477748774977507751775277537754775577567757775877597760776177627763776477657766776777687769777077717772777377747775777677777778777977807781778277837784778577867787778877897790779177927793779477957796779777987799780078017802780378047805780678077808780978107811781278137814781578167817781878197820782178227823782478257826782778287829783078317832783378347835783678377838783978407841784278437844784578467847784878497850785178527853785478557856785778587859786078617862786378647865786678677868786978707871787278737874787578767877787878797880788178827883788478857886788778887889789078917892789378947895789678977898789979007901790279037904790579067907790879097910791179127913791479157916791779187919792079217922792379247925792679277928792979307931793279337934793579367937793879397940794179427943794479457946794779487949795079517952795379547955795679577958795979607961796279637964796579667967796879697970797179727973797479757976797779787979798079817982798379847985798679877988798979907991799279937994799579967997799879998000800180028003800480058006800780088009801080118012801380148015801680178018801980208021802280238024802580268027802880298030803180328033803480358036803780388039804080418042804380448045804680478048804980508051805280538054805580568057805880598060806180628063806480658066806780688069807080718072807380748075807680778078807980808081808280838084808580868087808880898090809180928093809480958096809780988099810081018102810381048105810681078108810981108111811281138114811581168117811881198120812181228123812481258126812781288129813081318132813381348135813681378138813981408141814281438144814581468147814881498150815181528153815481558156815781588159816081618162816381648165816681678168816981708171817281738174817581768177817881798180818181828183818481858186818781888189819081918192819381948195819681978198819982008201820282038204820582068207820882098210821182128213821482158216821782188219822082218222822382248225822682278228822982308231823282338234823582368237823882398240824182428243824482458246824782488249825082518252825382548255825682578258825982608261826282638264826582668267826882698270827182728273827482758276827782788279828082818282828382848285828682878288828982908291829282938294829582968297829882998300830183028303830483058306830783088309831083118312831383148315831683178318831983208321832283238324832583268327832883298330833183328333833483358336833783388339834083418342834383448345834683478348834983508351835283538354835583568357835883598360836183628363836483658366836783688369837083718372837383748375837683778378837983808381838283838384838583868387838883898390839183928393839483958396839783988399840084018402840384048405840684078408840984108411841284138414841584168417841884198420842184228423842484258426842784288429843084318432843384348435843684378438843984408441844284438444844584468447844884498450845184528453845484558456845784588459846084618462846384648465846684678468846984708471847284738474847584768477847884798480848184828483848484858486848784888489849084918492849384948495849684978498849985008501850285038504850585068507850885098510851185128513851485158516851785188519852085218522852385248525852685278528852985308531853285338534853585368537853885398540854185428543854485458546854785488549855085518552855385548555855685578558855985608561856285638564856585668567856885698570857185728573857485758576857785788579858085818582858385848585858685878588
  1. // ==UserScript==
  2. // @name BetterYTM
  3. // @namespace https://github.com/Sv443/BetterYTM
  4. // @version 2.2.0
  5. // @description Lots of configurable layout and user experience improvements for YouTube Music™ and YouTube™
  6. // @homepageURL https://github.com/Sv443/BetterYTM#readme
  7. // @supportURL https://github.com/Sv443/BetterYTM/issues
  8. // @license AGPL-3.0-only
  9. // @author Sv443
  10. // @copyright Sv443 (https://github.com/Sv443)
  11. // @icon https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@23ef6824/assets/images/logo/logo_dev_48.png
  12. // @match https://music.youtube.com/*
  13. // @match https://www.youtube.com/*
  14. // @run-at document-start
  15. // @description:de-DE Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™
  16. // @description:de Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™
  17. // @description:de-AT Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™
  18. // @description:de-CH Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™
  19. // @description:de-LI Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™
  20. // @description:de-LU Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™
  21. // @description:en-US Configurable layout and user experience improvements for YouTube Music™ and YouTube™
  22. // @description:en Configurable layout and user experience improvements for YouTube Music™ and YouTube™
  23. // @description:en-CA Configurable layout and user experience improvements for YouTube Music™ and YouTube™
  24. // @description:en-GB Configurable layout and user experience improvements for YouTube Music™ and YouTube™
  25. // @description:en-AU Configurable layout and user experience improvements for YouTube Music™ and YouTube™
  26. // @description:en-IE Configurable layout and user experience improvements for YouTube Music™ and YouTube™
  27. // @description:en-NZ Configurable layout and user experience improvements for YouTube Music™ and YouTube™
  28. // @description:en-ZA Configurable layout and user experience improvements for YouTube Music™ and YouTube™
  29. // @description:es-ES Mejoras de diseño y experiencia de usuario configurables para YouTube Music™ y YouTube™
  30. // @description:es Mejoras de diseño y experiencia de usuario configurables para YouTube Music™ y YouTube™
  31. // @description:es-MX Mejoras de diseño y experiencia de usuario configurables para YouTube Music™ y YouTube™
  32. // @description:fr-FR Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music™ et YouTube™
  33. // @description:fr Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music™ et YouTube™
  34. // @description:fr-CA Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music™ et YouTube™
  35. // @description:fr-BE Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music™ et YouTube™
  36. // @description:fr-CH Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music™ et YouTube™
  37. // @description:fr-LU Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music™ et YouTube™
  38. // @description:hi-IN YouTube Music™ और YouTube™ के लिए कॉन्फ़िगर करने योग्य लेआउट और उपयोगकर्ता अनुभव में सुधार
  39. // @description:hi YouTube Music™ और YouTube™ के लिए कॉन्फ़िगर करने योग्य लेआउट और उपयोगकर्ता अनुभव में सुधार
  40. // @description:hi-NP YouTube Music™ और YouTube™ के लिए कॉन्फ़िगर करने योग्य लेआउट और उपयोगकर्ता अनुभव में सुधार
  41. // @description:ja-JP YouTube Music™ と YouTube™ の構成可能なレイアウトとユーザー エクスペリエンスの向上
  42. // @description:ja YouTube Music™ と YouTube™ の構成可能なレイアウトとユーザー エクスペリエンスの向上
  43. // @description:pt-BR Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music™ e o YouTube™
  44. // @description:pt Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music™ e o YouTube™
  45. // @description:pt-PT Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music™ e o YouTube™
  46. // @description:zh-CN YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进
  47. // @description:zh YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进
  48. // @description:zh-TW YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进
  49. // @description:zh-HK YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进
  50. // @description:zh-SG YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进
  51. // @connect api.sv443.net
  52. // @connect github.com
  53. // @connect raw.githubusercontent.com
  54. // @connect youtube.com
  55. // @connect returnyoutubedislikeapi.com
  56. // @noframes
  57. // @updateURL https://github.com/Sv443/BetterYTM/raw/refs/heads/main/dist/BetterYTM.meta.js
  58. // @downloadURL https://github.com/Sv443/BetterYTM/raw/refs/heads/main/dist/BetterYTM.user.js
  59. // @grant GM.getValue
  60. // @grant GM.setValue
  61. // @grant GM.deleteValue
  62. // @grant GM.listValues
  63. // @grant GM.getResourceUrl
  64. // @grant GM.setClipboard
  65. // @grant GM.xmlHttpRequest
  66. // @grant GM.openInTab
  67. // @grant unsafeWindow
  68. // @require https://cdn.jsdelivr.net/npm/@sv443-network/[email protected]/dist/index.global.js
  69. // @require https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.js
  70. // @require https://cdn.jsdelivr.net/npm/[email protected]/lib/umd/index.js
  71. // @require https://cdn.jsdelivr.net/npm/[email protected]
  72. // @grant GM.registerMenuCommand
  73. // ==/UserScript==
  74. /*
  75. ▄▄▄ ▄ ▄ ▄ ▄▄▄▄▄▄ ▄
  76. █ █ ▄▄▄ █ █ ▄█▄ ▄ ▄█ █ █ █▀▄▀█
  77. █▀▀▄ █▄█ █▀ █▀ █▄█ █▀ █ █ █ █
  78. █▄▄▀ ▀▄▄ ▀▄▄ ▀▄▄ ▀▄▄ █ █ █ █ █
  79. Made with ❤️ by Sv443
  80. I welcome every contribution on GitHub!
  81. https://github.com/Sv443/BetterYTM
  82. */
  83. /* Disclaimer: I am not affiliated with or endorsed by YouTube, Google, Alphabet, Genius or anyone else */
  84. /* C&D this 🖕 */
  85. (function(UserUtils,DOMPurify,marked,compareVersions){'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);var preloadAssetPattern = "^(icon|img)-";
  86. var resources = {
  87. "css-above_queue_btns": "style/aboveQueueBtns.css",
  88. "css-above_queue_btns_sticky": "style/aboveQueueBtnsSticky.css",
  89. "css-anchor_improvements": "style/anchorImprovements.css",
  90. "css-auto_like": "style/autoLike.css",
  91. "css-bundle": "/dist/BetterYTM.css",
  92. "css-fix_hdr": "style/fixHDR.css",
  93. "css-fix_playerpage_theming": "style/fixPlayerPageTheming.css",
  94. "css-fix_spacing": "style/fixSpacing.css",
  95. "css-fix_sponsorblock": "style/fixSponsorBlock.css",
  96. "css-hide_themesong_logo": "style/hideThemeSongLogo.css",
  97. "css-show_votes": "style/showVotes.css",
  98. "css-vol_slider_size": "style/volSliderSize.css",
  99. "css-watch_page_full_size": "style/watchPageFullSize.css",
  100. "doc-license": {
  101. path: "/LICENSE.txt",
  102. ref: "$BRANCH",
  103. integrity: false
  104. },
  105. "doc-svg_spritesheet": "spritesheet.svg",
  106. "font-cousine_ttf": "fonts/Cousine/Cousine-Regular.ttf",
  107. "font-cousine_woff": "fonts/Cousine/Cousine-Regular.woff",
  108. "font-cousine_woff2": "fonts/Cousine/Cousine-Regular.woff2",
  109. "icon-advanced_mode": "icons/plus_circle_small.svg",
  110. "icon-alert": "icons/alert.svg",
  111. "icon-arrow_down": "icons/arrow_down.svg",
  112. "icon-auto_like_enabled": "icons/auto_like_enabled.svg",
  113. "icon-auto_like": "icons/auto_like.svg",
  114. "icon-clear_list": "icons/clear_list.svg",
  115. "icon-copy": "icons/copy.svg",
  116. "icon-delete": "icons/delete.svg",
  117. "icon-edit": "icons/edit.svg",
  118. "icon-error": "icons/error.svg",
  119. "icon-experimental": "icons/beaker_small.svg",
  120. "icon-globe_small": "icons/globe_small.svg",
  121. "icon-globe": "icons/globe.svg",
  122. "icon-help": "icons/help.svg",
  123. "icon-image_filled": "icons/image_filled.svg",
  124. "icon-image": "icons/image.svg",
  125. "icon-link": "icons/link.svg",
  126. "icon-lyrics": "icons/lyrics.svg",
  127. "icon-prompt": "icons/help.svg",
  128. "icon-reload": "icons/refresh.svg",
  129. "icon-restore_time": "icons/restore_time.svg",
  130. "icon-skip_to": "icons/skip_to.svg",
  131. "icon-speed": "icons/speed.svg",
  132. "icon-spinner": "icons/spinner.svg",
  133. "icon-upload": "icons/upload.svg",
  134. "icon-ytm": "icons/ytm.svg",
  135. "img-close": "images/close.png",
  136. "img-discord": "images/external/discord.png",
  137. "img-github": "images/external/github.png",
  138. "img-greasyfork": "images/external/greasyfork.png",
  139. "img-logo_dev": "images/logo/logo_dev_48.png",
  140. "img-logo": "images/logo/logo_48.png",
  141. "img-openuserjs": "images/external/openuserjs.png",
  142. "trans-de-DE": "translations/de-DE.json",
  143. "trans-en-US": "translations/en-US.json",
  144. "trans-en-GB": "translations/en-GB.json",
  145. "trans-es-ES": "translations/es-ES.json",
  146. "trans-fr-FR": "translations/fr-FR.json",
  147. "trans-hi-IN": "translations/hi-IN.json",
  148. "trans-ja-JP": "translations/ja-JP.json",
  149. "trans-pt-BR": "translations/pt-BR.json",
  150. "trans-zh-CN": "translations/zh-CN.json"
  151. };
  152. var resourcesJson = {
  153. preloadAssetPattern: preloadAssetPattern,
  154. resources: resources
  155. };var locales = {
  156. "de-DE": {
  157. name: "Deutsch (Deutschland)",
  158. nameEnglish: "German (Germany)",
  159. emoji: "🇩🇪",
  160. userscriptDesc: "Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™",
  161. authors: [
  162. "Sv443"
  163. ],
  164. altLocales: [
  165. "de",
  166. "de-AT",
  167. "de-CH",
  168. "de-LI",
  169. "de-LU"
  170. ],
  171. textDir: "ltr",
  172. sentenceTerminator: "."
  173. },
  174. "en-US": {
  175. name: "English (United States)",
  176. nameEnglish: "English (United States)",
  177. emoji: "🇺🇸",
  178. userscriptDesc: "Configurable layout and user experience improvements for YouTube Music™ and YouTube™",
  179. authors: [
  180. "Sv443"
  181. ],
  182. altLocales: [
  183. "en",
  184. "en-CA"
  185. ],
  186. textDir: "ltr",
  187. sentenceTerminator: "."
  188. },
  189. "en-GB": {
  190. name: "English (Great Britain)",
  191. nameEnglish: "English (Great Britain)",
  192. emoji: "🇬🇧",
  193. userscriptDesc: "Configurable layout and user experience improvements for YouTube Music™ and YouTube™",
  194. authors: [
  195. "Sv443"
  196. ],
  197. altLocales: [
  198. "en-AU",
  199. "en-IE",
  200. "en-NZ",
  201. "en-ZA"
  202. ],
  203. textDir: "ltr",
  204. sentenceTerminator: "."
  205. },
  206. "es-ES": {
  207. name: "Español (España)",
  208. nameEnglish: "Spanish (Spain)",
  209. emoji: "🇪🇸",
  210. userscriptDesc: "Mejoras de diseño y experiencia de usuario configurables para YouTube Music™ y YouTube™",
  211. authors: [
  212. "Sv443"
  213. ],
  214. altLocales: [
  215. "es",
  216. "es-MX"
  217. ],
  218. textDir: "ltr",
  219. sentenceTerminator: "."
  220. },
  221. "fr-FR": {
  222. name: "Français (France)",
  223. nameEnglish: "French (France)",
  224. emoji: "🇫🇷",
  225. userscriptDesc: "Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music™ et YouTube™",
  226. authors: [
  227. "Sv443"
  228. ],
  229. altLocales: [
  230. "fr",
  231. "fr-CA",
  232. "fr-BE",
  233. "fr-CH",
  234. "fr-LU"
  235. ],
  236. textDir: "ltr",
  237. sentenceTerminator: "."
  238. },
  239. "hi-IN": {
  240. name: "हिंदी (भारत)",
  241. nameEnglish: "Hindi (India)",
  242. emoji: "🇮🇳",
  243. userscriptDesc: "YouTube Music™ और YouTube™ के लिए कॉन्फ़िगर करने योग्य लेआउट और उपयोगकर्ता अनुभव में सुधार",
  244. authors: [
  245. "Sv443"
  246. ],
  247. altLocales: [
  248. "hi",
  249. "hi-NP"
  250. ],
  251. textDir: "ltr",
  252. sentenceTerminator: "।"
  253. },
  254. "ja-JP": {
  255. name: "日本語 (日本)",
  256. nameEnglish: "Japanese (Japan)",
  257. emoji: "🇯🇵",
  258. userscriptDesc: "YouTube Music™ と YouTube™ の構成可能なレイアウトとユーザー エクスペリエンスの向上",
  259. authors: [
  260. "Sv443"
  261. ],
  262. altLocales: [
  263. "ja"
  264. ],
  265. textDir: "ltr",
  266. sentenceTerminator: "。"
  267. },
  268. "pt-BR": {
  269. name: "Português (Brasil)",
  270. nameEnglish: "Portuguese (Brazil)",
  271. emoji: "🇧🇷",
  272. userscriptDesc: "Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music™ e o YouTube™",
  273. authors: [
  274. "Sv443"
  275. ],
  276. altLocales: [
  277. "pt",
  278. "pt-PT"
  279. ],
  280. textDir: "ltr",
  281. sentenceTerminator: "."
  282. },
  283. "zh-CN": {
  284. name: "中文(简化,中国)",
  285. nameEnglish: "Chinese (Simplified, China)",
  286. emoji: "🇨🇳",
  287. userscriptDesc: "YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进",
  288. authors: [
  289. "Sv443"
  290. ],
  291. altLocales: [
  292. "zh",
  293. "zh-TW",
  294. "zh-HK",
  295. "zh-SG"
  296. ],
  297. textDir: "ltr",
  298. sentenceTerminator: "。"
  299. }
  300. };// I know TS enums are impure but it doesn't really matter here, plus imo they are cooler than pure enums anyway
  301. var LogLevel;
  302. (function (LogLevel) {
  303. LogLevel[LogLevel["Debug"] = 0] = "Debug";
  304. LogLevel[LogLevel["Info"] = 1] = "Info";
  305. })(LogLevel || (LogLevel = {}));
  306. //#region plugins
  307. /**
  308. * Intents (permissions) BYTM has to grant your plugin for it to be able to access certain features.
  309. * TODO: this feature is unfinished, but you should still specify the intents your plugin needs.
  310. * Never request more permissions than you need, as this is a bad practice and can lead to your plugin being rejected.
  311. */
  312. var PluginIntent;
  313. (function (PluginIntent) {
  314. /** Plugin can read the feature configuration */
  315. PluginIntent[PluginIntent["ReadFeatureConfig"] = 1] = "ReadFeatureConfig";
  316. /** Plugin can write to the feature configuration */
  317. PluginIntent[PluginIntent["WriteFeatureConfig"] = 2] = "WriteFeatureConfig";
  318. /** Plugin has access to hidden config values */
  319. PluginIntent[PluginIntent["SeeHiddenConfigValues"] = 4] = "SeeHiddenConfigValues";
  320. /** Plugin can write to the lyrics cache */
  321. PluginIntent[PluginIntent["WriteLyricsCache"] = 8] = "WriteLyricsCache";
  322. /** Plugin can add new translations and overwrite existing ones */
  323. PluginIntent[PluginIntent["WriteTranslations"] = 16] = "WriteTranslations";
  324. /** Plugin can create modal dialogs */
  325. PluginIntent[PluginIntent["CreateModalDialogs"] = 32] = "CreateModalDialogs";
  326. /** Plugin can read auto-like data */
  327. PluginIntent[PluginIntent["ReadAutoLikeData"] = 64] = "ReadAutoLikeData";
  328. /** Plugin can write to auto-like data */
  329. PluginIntent[PluginIntent["WriteAutoLikeData"] = 128] = "WriteAutoLikeData";
  330. })(PluginIntent || (PluginIntent = {}));// these strings will have their values replaced by the post-build script:
  331. const rawConsts = {
  332. mode: "development",
  333. branch: "develop",
  334. host: "github",
  335. buildNumber: "23ef6824",
  336. assetSource: "jsdelivr",
  337. devServerPort: "8710",
  338. };
  339. const getConst = (constKey, defaultVal) => {
  340. const val = rawConsts[constKey];
  341. return (val.match(/^#{{.+}}$/) ? defaultVal : val);
  342. };
  343. /** Path to the GitHub repo */
  344. const repo = "Sv443/BetterYTM";
  345. /** The mode in which the script was built (production or development) */
  346. const mode = getConst("mode", "production");
  347. /** The branch to use in various URLs that point to the GitHub repo */
  348. const branch = getConst("branch", "main");
  349. /** Which host the userscript was installed from */
  350. const host = getConst("host", "github");
  351. /** The build number of the userscript */
  352. const buildNumber = getConst("buildNumber", "!BUILD_ERROR!");
  353. /** The source of the assets - github, jsdelivr or local */
  354. const assetSource = getConst("assetSource", "jsdelivr");
  355. /** The port of the dev server */
  356. const devServerPort = Number(getConst("devServerPort", 8710));
  357. /** URL to the changelog file */
  358. const changelogUrl = `https://raw.githubusercontent.com/${repo}/${buildNumber !== null && buildNumber !== void 0 ? buildNumber : branch}/changelog.md`;
  359. /** The URL search parameters at the earliest possible time */
  360. const initialParams = new URL(location.href).searchParams;
  361. /** Names of platforms by key of {@linkcode host} */
  362. const platformNames = UserUtils.purifyObj({
  363. github: "GitHub",
  364. greasyfork: "GreasyFork",
  365. openuserjs: "OpenUserJS",
  366. });
  367. /** Default compression format used throughout BYTM */
  368. const compressionFormat = "deflate-raw";
  369. /** Whether sessionStorage is available and working */
  370. const sessionStorageAvailable = typeof (sessionStorage === null || sessionStorage === void 0 ? void 0 : sessionStorage.setItem) === "function"
  371. && (() => {
  372. try {
  373. const key = `_bytm_test_${UserUtils.randomId(6, 36, false, true)}`;
  374. sessionStorage.setItem(key, "test");
  375. sessionStorage.removeItem(key);
  376. return true;
  377. }
  378. catch (_a) {
  379. return false;
  380. }
  381. })();
  382. /**
  383. * Fallback and initial value of how much info should be logged to the devtools console
  384. * 0 = Debug (show everything) or 1 = Info (show only important stuff)
  385. */
  386. const defaultLogLevel = mode === "production" ? LogLevel.Info : LogLevel.Debug;
  387. /** Info about the userscript, parsed from the userscript header (tools/post-build.js) */
  388. const scriptInfo = UserUtils.purifyObj({
  389. name: GM.info.script.name,
  390. version: GM.info.script.version,
  391. namespace: GM.info.script.namespace,
  392. });let canCompress$2 = true;
  393. const lyricsCacheMgr = new UserUtils.DataStore({
  394. id: "bytm-lyrics-cache",
  395. defaultData: {
  396. cache: [],
  397. },
  398. formatVersion: 1,
  399. encodeData: (data) => canCompress$2 ? UserUtils.compress(data, compressionFormat, "string") : data,
  400. decodeData: (data) => canCompress$2 ? UserUtils.decompress(data, compressionFormat, "string") : data,
  401. });
  402. async function initLyricsCache() {
  403. canCompress$2 = await compressionSupported();
  404. const data = await lyricsCacheMgr.loadData();
  405. log(`Initialized lyrics cache with ${data.cache.length} entries:`, data);
  406. emitInterface("bytm:lyricsCacheReady");
  407. return data;
  408. }
  409. /**
  410. * Returns the cache entry for the passed artist and song, or undefined if it doesn't exist yet
  411. * {@linkcode artist} and {@linkcode song} need to be sanitized first!
  412. * @param refreshEntry If true, the timestamp of the entry will be set to the current time
  413. */
  414. function getLyricsCacheEntry(artist, song, refreshEntry = true) {
  415. const { cache } = lyricsCacheMgr.getData();
  416. const entry = cache.find(e => e.artist === artist && e.song === song);
  417. if (entry && Date.now() - (entry === null || entry === void 0 ? void 0 : entry.added) > getFeature("lyricsCacheTTL") * 1000 * 60 * 60 * 24) {
  418. deleteLyricsCacheEntry(artist, song);
  419. return undefined;
  420. }
  421. // refresh timestamp of the entry by mutating cache
  422. if (entry && refreshEntry)
  423. updateLyricsCacheEntry(artist, song);
  424. return entry;
  425. }
  426. /** Updates the "last viewed" timestamp of the cache entry for the passed artist and song */
  427. function updateLyricsCacheEntry(artist, song) {
  428. const { cache } = lyricsCacheMgr.getData();
  429. const idx = cache.findIndex(e => e.artist === artist && e.song === song);
  430. if (idx !== -1) {
  431. const newEntry = cache.splice(idx, 1)[0];
  432. newEntry.viewed = Date.now();
  433. lyricsCacheMgr.setData({ cache: [newEntry, ...cache] });
  434. }
  435. }
  436. /** Deletes the cache entry for the passed artist and song */
  437. function deleteLyricsCacheEntry(artist, song) {
  438. const { cache } = lyricsCacheMgr.getData();
  439. const idx = cache.findIndex(e => e.artist === artist && e.song === song);
  440. if (idx !== -1) {
  441. cache.splice(idx, 1);
  442. lyricsCacheMgr.setData({ cache });
  443. }
  444. }
  445. /** Clears the lyrics cache locally and clears it in persistent storage */
  446. function clearLyricsCache() {
  447. emitInterface("bytm:lyricsCacheCleared");
  448. return lyricsCacheMgr.setData({ cache: [] });
  449. }
  450. /** Returns the full lyrics cache array */
  451. function getLyricsCache() {
  452. return lyricsCacheMgr.getData().cache;
  453. }
  454. /**
  455. * Adds the provided "best" (non-penalized) entry into the lyrics URL cache, synchronously to RAM and asynchronously to GM storage
  456. * {@linkcode artist} and {@linkcode song} need to be sanitized first!
  457. */
  458. function addLyricsCacheEntryBest(artist, song, url) {
  459. // refresh entry if it exists and don't overwrite / duplicate it
  460. const cachedEntry = getLyricsCacheEntry(artist, song, true);
  461. if (cachedEntry)
  462. return;
  463. const { cache } = lyricsCacheMgr.getData();
  464. const entry = {
  465. artist, song, url, viewed: Date.now(), added: Date.now(),
  466. };
  467. cache.push(entry);
  468. cache.sort((a, b) => b.viewed - a.viewed);
  469. // always keep the cache <= max size
  470. cache.splice(getFeature("lyricsCacheMaxSize"));
  471. log("Added lyrics cache entry for best result:", entry);
  472. emitInterface("bytm:lyricsCacheEntryAdded", { entry, type: "best" });
  473. return lyricsCacheMgr.setData({ cache });
  474. }/******************************************************************************
  475. Copyright (c) Microsoft Corporation.
  476. Permission to use, copy, modify, and/or distribute this software for any
  477. purpose with or without fee is hereby granted.
  478. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
  479. REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
  480. AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
  481. INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
  482. LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
  483. OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
  484. PERFORMANCE OF THIS SOFTWARE.
  485. ***************************************************************************** */
  486. /* global Reflect, Promise, SuppressedError, Symbol, Iterator */
  487. function __rest(s, e) {
  488. var t = {};
  489. for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
  490. t[p] = s[p];
  491. if (s != null && typeof Object.getOwnPropertySymbols === "function")
  492. for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
  493. if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
  494. t[p[i]] = s[p[i]];
  495. }
  496. return t;
  497. }
  498. function __values(o) {
  499. var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
  500. if (m) return m.call(o);
  501. if (o && typeof o.length === "number") return {
  502. next: function () {
  503. if (o && i >= o.length) o = void 0;
  504. return { value: o && o[i++], done: !o };
  505. }
  506. };
  507. throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
  508. }
  509. function __asyncValues(o) {
  510. if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
  511. var m = o[Symbol.asyncIterator], i;
  512. 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);
  513. 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); }); }; }
  514. function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
  515. }
  516. typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
  517. var e = new Error(message);
  518. return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
  519. };/** Contains the identifiers of all initialized and loaded translation locales */
  520. const initializedLocales = new Set();
  521. /** The currently active locale */
  522. let activeLocale = "en-US";
  523. UserUtils.tr.addTransform(UserUtils.tr.transforms.percent);
  524. UserUtils.tr.addTransform(UserUtils.tr.transforms.templateLiteral);
  525. UserUtils.tr.setFallbackLanguage("en-US");
  526. /** Initializes the translations */
  527. async function initTranslations(locale) {
  528. if (initializedLocales.has(locale))
  529. return;
  530. initializedLocales.add(locale);
  531. try {
  532. const transFile = await fetchLocaleJson(locale);
  533. let fallbackTrans = {};
  534. if (getFeature("localeFallback"))
  535. fallbackTrans = await fetchLocaleJson("en-US");
  536. // merge with base translations if specified
  537. const baseTransFile = typeof (transFile === null || transFile === void 0 ? void 0 : transFile.meta) === "object" && "base" in transFile.meta && typeof transFile.meta.base === "string"
  538. ? await fetchLocaleJson(transFile.base)
  539. : undefined;
  540. const translations = Object.assign(Object.assign(Object.assign({}, (fallbackTrans !== null && fallbackTrans !== void 0 ? fallbackTrans : {})), (baseTransFile !== null && baseTransFile !== void 0 ? baseTransFile : {})), transFile);
  541. const _a = translations.meta, { authors: _authors } = _a, meta = __rest(_a, ["authors"]), trans = __rest(translations, ["meta"]);
  542. UserUtils.tr.addTranslations(locale, Object.assign(Object.assign({}, meta), trans));
  543. info(`Loaded translations for locale '${locale}'`);
  544. }
  545. catch (err) {
  546. const errStr = `Couldn't load translations for locale '${locale}'`;
  547. error(errStr, err);
  548. throw new Error(errStr);
  549. }
  550. }
  551. /** Fetches the translation JSON file of the passed locale */
  552. async function fetchLocaleJson(locale) {
  553. const url = await getResourceUrl(`trans-${locale}`);
  554. const res = await UserUtils.fetchAdvanced(url);
  555. if (res.status < 200 || res.status >= 300)
  556. throw new Error(`Failed to fetch translation file for locale '${locale}'`);
  557. return await res.json();
  558. }
  559. /** Sets the current language for translations */
  560. function setLocale(locale) {
  561. activeLocale = locale;
  562. setGlobalProp("locale", locale);
  563. emitInterface("bytm:setLocale", { locale });
  564. }
  565. /** Returns the currently set language */
  566. function getLocale() {
  567. return activeLocale;
  568. }
  569. /** Returns whether the given translation key exists in the current locale */
  570. async function hasKey(key) {
  571. return await hasKeyFor(getLocale(), key);
  572. }
  573. /** Returns whether the given translation key exists in the given locale - if it hasn't been initialized yet, initializes it first. */
  574. async function hasKeyFor(locale, key) {
  575. var _a;
  576. if (!initializedLocales.has(locale))
  577. await initTranslations(locale);
  578. return typeof ((_a = UserUtils.tr.getTranslations(locale)) === null || _a === void 0 ? void 0 : _a[key]) === "string";
  579. }
  580. /** Returns the translated string for the given key, after optionally inserting values */
  581. function t(key, ...values) {
  582. return tl(activeLocale, key, ...values);
  583. }
  584. /**
  585. * Returns the translated string for the given {@linkcode key} with an added pluralization identifier based on the passed {@linkcode num}
  586. * Also inserts the passed {@linkcode values} into the translation at the markers `%1`, `%2`, etc.
  587. * Tries to fall back to the non-pluralized syntax if no translation was found
  588. */
  589. function tp(key, num, ...values) {
  590. return tlp(getLocale(), key, num, ...values);
  591. }
  592. /** Returns the translated string for the given key in the specified locale, after optionally inserting values */
  593. function tl(locale, key, ...values) {
  594. return UserUtils.tr.for(locale, key, ...values);
  595. }
  596. /**
  597. * Returns the translated string for the given {@linkcode key} in the given {@linkcode locale} with an added pluralization identifier based on the passed {@linkcode num}
  598. * Also inserts the passed {@linkcode values} into the translation at the markers `%1`, `%2`, etc.
  599. * Tries to fall back to the non-pluralized syntax if no translation was found
  600. */
  601. function tlp(locale, key, num, ...values) {
  602. if (typeof num !== "number")
  603. num = num.length;
  604. const plNum = num === 1 ? "1" : "n";
  605. const trans = tl(locale, `${key}-${plNum}`, ...values);
  606. if (trans === key)
  607. return t(key, ...values);
  608. return trans;
  609. }// hoist the class declaration because either rollup or babel is being a hoe
  610. /** Whether the dialog system has been initialized */
  611. let dialogsInitialized = false;
  612. /** Container element for all BytmDialog elements */
  613. let dialogContainer;
  614. // TODO: remove export as soon as config menu is migrated to use BytmDialog
  615. /** ID of the last opened (top-most) dialog */
  616. let currentDialogId = null;
  617. /** IDs of all currently open dialogs, top-most first */
  618. const openDialogs = [];
  619. /** TODO: remove as soon as config menu is migrated to use BytmDialog */
  620. const setCurrentDialogId = (id) => currentDialogId = id;
  621. /** Creates and manages a modal dialog element */
  622. class BytmDialog extends UserUtils.NanoEmitter {
  623. constructor(options) {
  624. super();
  625. Object.defineProperty(this, "options", {
  626. enumerable: true,
  627. configurable: true,
  628. writable: true,
  629. value: void 0
  630. });
  631. Object.defineProperty(this, "id", {
  632. enumerable: true,
  633. configurable: true,
  634. writable: true,
  635. value: void 0
  636. });
  637. Object.defineProperty(this, "dialogOpen", {
  638. enumerable: true,
  639. configurable: true,
  640. writable: true,
  641. value: false
  642. });
  643. Object.defineProperty(this, "dialogMounted", {
  644. enumerable: true,
  645. configurable: true,
  646. writable: true,
  647. value: false
  648. });
  649. BytmDialog.initDialogs();
  650. this.options = Object.assign({ closeOnBgClick: true, closeOnEscPress: true, closeBtnEnabled: true, destroyOnClose: false, unmountOnClose: true, removeListenersOnDestroy: true, smallHeader: false, verticalAlign: "center" }, options);
  651. this.id = options.id;
  652. }
  653. //#region public
  654. /** Call after DOMContentLoaded to pre-render the dialog and invisibly mount it in the DOM */
  655. async mount() {
  656. if (this.dialogMounted)
  657. return;
  658. this.dialogMounted = true;
  659. const bgElem = document.createElement("div");
  660. bgElem.id = `bytm-${this.id}-dialog-bg`;
  661. bgElem.classList.add("bytm-dialog-bg");
  662. if (this.options.closeOnBgClick)
  663. bgElem.ariaLabel = bgElem.title = t("close_menu_tooltip");
  664. bgElem.style.setProperty("--bytm-dialog-width-max", `${this.options.width}px`);
  665. bgElem.style.setProperty("--bytm-dialog-height-max", `${this.options.height}px`);
  666. bgElem.style.visibility = "hidden";
  667. bgElem.style.display = "none";
  668. bgElem.inert = true;
  669. try {
  670. bgElem.appendChild(await this.getDialogContent());
  671. if (dialogContainer)
  672. dialogContainer.appendChild(bgElem);
  673. else
  674. document.addEventListener("DOMContentLoaded", () => dialogContainer === null || dialogContainer === void 0 ? void 0 : dialogContainer.appendChild(bgElem));
  675. }
  676. catch (e) {
  677. return error("Failed to render dialog content:", e);
  678. }
  679. this.attachListeners(bgElem);
  680. this.events.emit("render");
  681. return bgElem;
  682. }
  683. /** Closes the dialog and clears all its contents (unmounts elements from the DOM) in preparation for a new rendering call */
  684. unmount() {
  685. var _a;
  686. this.close();
  687. this.dialogMounted = false;
  688. const clearSelectors = [
  689. `#bytm-${this.id}-dialog-bg`,
  690. ];
  691. for (const sel of clearSelectors) {
  692. const elem = document.querySelector(sel);
  693. (elem === null || elem === void 0 ? void 0 : elem.hasChildNodes()) && clearInner(elem);
  694. (_a = document.querySelector(sel)) === null || _a === void 0 ? void 0 : _a.remove();
  695. }
  696. this.events.emit("clear");
  697. }
  698. /** Clears the DOM of the dialog and then renders it again */
  699. async remount() {
  700. this.unmount();
  701. await this.mount();
  702. }
  703. /**
  704. * Opens the dialog - also mounts it if it hasn't been mounted yet
  705. * Prevents default action and immediate propagation of the passed event
  706. */
  707. async open(e) {
  708. var _a;
  709. e === null || e === void 0 ? void 0 : e.preventDefault();
  710. e === null || e === void 0 ? void 0 : e.stopImmediatePropagation();
  711. if (this.isOpen())
  712. return;
  713. this.dialogOpen = true;
  714. if (openDialogs.includes(this.id)) {
  715. openDialogs.splice(openDialogs.indexOf(this.id), 1);
  716. currentDialogId = (_a = openDialogs[0]) !== null && _a !== void 0 ? _a : null;
  717. this.removeBgInert();
  718. this.close();
  719. throw new Error(`A dialog with the same ID of '${this.id}' already exists and is open!`);
  720. }
  721. if (!this.isMounted())
  722. await this.mount();
  723. this.setBgInert();
  724. const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`);
  725. if (!dialogBg)
  726. return warn(`Couldn't find background element for dialog with ID '${this.id}'`);
  727. dialogBg.style.visibility = "visible";
  728. dialogBg.style.display = "block";
  729. currentDialogId = this.id;
  730. openDialogs.unshift(this.id);
  731. this.events.emit("open");
  732. emitInterface("bytm:dialogOpened", this);
  733. emitInterface(`bytm:dialogOpened:${this.id}`, this);
  734. return dialogBg;
  735. }
  736. /** Closes the dialog - prevents default action and immediate propagation of the passed event */
  737. close(e) {
  738. var _a;
  739. e === null || e === void 0 ? void 0 : e.preventDefault();
  740. e === null || e === void 0 ? void 0 : e.stopImmediatePropagation();
  741. if (!this.isOpen())
  742. return;
  743. this.dialogOpen = false;
  744. const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`);
  745. if (!dialogBg)
  746. return warn(`Couldn't find background element for dialog with ID '${this.id}'`);
  747. dialogBg.style.visibility = "hidden";
  748. dialogBg.style.display = "none";
  749. openDialogs.splice(openDialogs.indexOf(this.id), 1);
  750. currentDialogId = (_a = openDialogs[0]) !== null && _a !== void 0 ? _a : null;
  751. this.removeBgInert();
  752. this.events.emit("close");
  753. emitInterface("bytm:dialogClosed", this);
  754. emitInterface(`bytm:dialogClosed:${this.id}`, this);
  755. if (this.options.destroyOnClose)
  756. this.destroy();
  757. // don't destroy *and* unmount at the same time
  758. else if (this.options.unmountOnClose)
  759. this.unmount();
  760. this.removeBgInert();
  761. }
  762. /** Returns true if the dialog is currently open */
  763. isOpen() {
  764. return this.dialogOpen;
  765. }
  766. /** Returns true if the dialog is currently mounted */
  767. isMounted() {
  768. return this.dialogMounted;
  769. }
  770. /** Clears the DOM of the dialog and removes all event listeners */
  771. destroy() {
  772. this.unmount();
  773. this.events.emit("destroy");
  774. this.options.removeListenersOnDestroy && this.unsubscribeAll();
  775. }
  776. //#region static
  777. /** Initializes the dialog system */
  778. static initDialogs() {
  779. if (dialogsInitialized)
  780. return;
  781. dialogsInitialized = true;
  782. const createContainer = () => {
  783. const bytmDialogCont = dialogContainer = document.createElement("div");
  784. bytmDialogCont.id = "bytm-dialog-container";
  785. document.body.appendChild(bytmDialogCont);
  786. };
  787. if (!UserUtils.isDomLoaded())
  788. document.addEventListener("DOMContentLoaded", createContainer);
  789. else
  790. createContainer();
  791. }
  792. /** Returns the ID of the top-most dialog (the dialog that has been opened last) */
  793. static getCurrentDialogId() {
  794. return currentDialogId;
  795. }
  796. /** Returns the IDs of all currently open dialogs, top-most first */
  797. static getOpenDialogs() {
  798. return openDialogs;
  799. }
  800. //#region protected
  801. /** Sets this dialog and the body to be inert and makes sure the top-most dialog is not inert. If no other dialogs are open, the body is not set to be inert. */
  802. removeBgInert() {
  803. var _a, _b, _c;
  804. // make sure the new top-most dialog is not inert
  805. if (currentDialogId) {
  806. // special treatment for the old config menu, as always
  807. if (currentDialogId === "cfg-menu")
  808. (_a = document.querySelector("#bytm-cfg-menu-bg")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert");
  809. else
  810. (_b = document.querySelector(`#bytm-${currentDialogId}-dialog-bg`)) === null || _b === void 0 ? void 0 : _b.removeAttribute("inert");
  811. }
  812. // remove the scroll lock and inert attribute on the body if no dialogs are open
  813. if (openDialogs.length === 0) {
  814. document.body.classList.remove("bytm-disable-scroll");
  815. (_c = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _c === void 0 ? void 0 : _c.removeAttribute("inert");
  816. }
  817. const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`);
  818. dialogBg === null || dialogBg === void 0 ? void 0 : dialogBg.setAttribute("inert", "true");
  819. }
  820. /** Sets this dialog to be not inert and the body and all other dialogs to be inert */
  821. setBgInert() {
  822. var _a, _b, _c;
  823. // make sure all other dialogs are inert
  824. for (const dialogId of openDialogs) {
  825. if (dialogId !== this.id) {
  826. // special treatment for the old config menu, as always
  827. if (dialogId === "cfg-menu")
  828. (_a = document.querySelector("#bytm-cfg-menu-bg")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true");
  829. else
  830. (_b = document.querySelector(`#bytm-${dialogId}-dialog-bg`)) === null || _b === void 0 ? void 0 : _b.setAttribute("inert", "true");
  831. }
  832. }
  833. // make sure body is inert and scroll is locked
  834. document.body.classList.add("bytm-disable-scroll");
  835. (_c = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _c === void 0 ? void 0 : _c.setAttribute("inert", "true");
  836. const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`);
  837. dialogBg === null || dialogBg === void 0 ? void 0 : dialogBg.removeAttribute("inert");
  838. }
  839. /** Called on every {@linkcode mount()} to attach all generic event listeners */
  840. attachListeners(bgElem) {
  841. if (this.options.closeOnBgClick) {
  842. bgElem.addEventListener("click", (e) => {
  843. var _a;
  844. if (this.isOpen() && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === `bytm-${this.id}-dialog-bg`)
  845. this.close(e);
  846. });
  847. }
  848. if (this.options.closeOnEscPress) {
  849. document.body.addEventListener("keydown", (e) => {
  850. if (e.key === "Escape" && this.isOpen() && BytmDialog.getCurrentDialogId() === this.id)
  851. this.close(e);
  852. });
  853. }
  854. }
  855. /** Returns the dialog content element and all its children */
  856. async getDialogContent() {
  857. var _a, _b, _c, _d;
  858. const header = (_b = (_a = this.options).renderHeader) === null || _b === void 0 ? void 0 : _b.call(_a);
  859. const footer = (_d = (_c = this.options).renderFooter) === null || _d === void 0 ? void 0 : _d.call(_c);
  860. const dialogWrapperEl = document.createElement("div");
  861. dialogWrapperEl.id = `bytm-${this.id}-dialog`;
  862. dialogWrapperEl.classList.add("bytm-dialog");
  863. dialogWrapperEl.ariaLabel = dialogWrapperEl.title = "";
  864. dialogWrapperEl.role = "dialog";
  865. dialogWrapperEl.setAttribute("aria-labelledby", `bytm-${this.id}-dialog-title`);
  866. dialogWrapperEl.setAttribute("aria-describedby", `bytm-${this.id}-dialog-body`);
  867. if (this.options.verticalAlign !== "center")
  868. dialogWrapperEl.classList.add(`align-${this.options.verticalAlign}`);
  869. //#region header
  870. const headerWrapperEl = document.createElement("div");
  871. headerWrapperEl.classList.add("bytm-dialog-header");
  872. this.options.small && headerWrapperEl.classList.add("small");
  873. if (header) {
  874. const headerTitleWrapperEl = document.createElement("div");
  875. headerTitleWrapperEl.id = `bytm-${this.id}-dialog-title`;
  876. headerTitleWrapperEl.classList.add("bytm-dialog-title-wrapper");
  877. headerTitleWrapperEl.role = "heading";
  878. headerTitleWrapperEl.ariaLevel = "1";
  879. headerTitleWrapperEl.appendChild(header instanceof Promise ? await header : header);
  880. headerWrapperEl.appendChild(headerTitleWrapperEl);
  881. }
  882. else {
  883. // insert element to pad the header height
  884. const padEl = document.createElement("div");
  885. padEl.classList.add("bytm-dialog-header-pad");
  886. this.options.small && padEl.classList.add("small");
  887. headerWrapperEl.appendChild(padEl);
  888. }
  889. if (this.options.closeBtnEnabled) {
  890. const closeBtnEl = document.createElement("img");
  891. closeBtnEl.classList.add("bytm-dialog-close");
  892. this.options.small && closeBtnEl.classList.add("small");
  893. closeBtnEl.src = await getResourceUrl("img-close");
  894. closeBtnEl.role = "button";
  895. closeBtnEl.tabIndex = 0;
  896. closeBtnEl.alt = closeBtnEl.title = closeBtnEl.ariaLabel = t("close_menu_tooltip");
  897. onInteraction(closeBtnEl, () => this.close());
  898. headerWrapperEl.appendChild(closeBtnEl);
  899. }
  900. dialogWrapperEl.appendChild(headerWrapperEl);
  901. //#region body
  902. const dialogBodyElem = document.createElement("div");
  903. dialogBodyElem.id = `bytm-${this.id}-dialog-body`;
  904. dialogBodyElem.classList.add("bytm-dialog-body");
  905. this.options.small && dialogBodyElem.classList.add("small");
  906. const body = this.options.renderBody();
  907. dialogBodyElem.appendChild(body instanceof Promise ? await body : body);
  908. dialogWrapperEl.appendChild(dialogBodyElem);
  909. //#region footer
  910. if (footer) {
  911. const footerWrapper = document.createElement("div");
  912. footerWrapper.classList.add("bytm-dialog-footer-cont");
  913. this.options.small && footerWrapper.classList.add("small");
  914. dialogWrapperEl.appendChild(footerWrapper);
  915. footerWrapper.appendChild(footer instanceof Promise ? await footer : footer);
  916. }
  917. return dialogWrapperEl;
  918. }
  919. }/** Creates a simple toggle element */
  920. async function createToggleInput({ onChange, initialValue = false, id = UserUtils.randomId(6, 36), labelPos = "left", }) {
  921. const wrapperEl = document.createElement("div");
  922. wrapperEl.classList.add("bytm-toggle-input-wrapper", "bytm-no-select");
  923. wrapperEl.role = "switch";
  924. wrapperEl.tabIndex = 0;
  925. const labelEl = labelPos !== "off" ? document.createElement("label") : undefined;
  926. if (labelEl) {
  927. labelEl.id = `bytm-toggle-input-label-${id}`;
  928. labelEl.classList.add("bytm-toggle-input-label");
  929. labelEl.textContent = t(`toggled_${initialValue ? "on" : "off"}`);
  930. if (id)
  931. labelEl.htmlFor = `bytm-toggle-input-${id}`;
  932. wrapperEl.setAttribute("aria-labelledby", labelEl.id);
  933. }
  934. const toggleWrapperEl = document.createElement("div");
  935. toggleWrapperEl.classList.add("bytm-toggle-input");
  936. toggleWrapperEl.tabIndex = -1;
  937. const toggleEl = document.createElement("input");
  938. toggleEl.type = "checkbox";
  939. toggleEl.checked = initialValue;
  940. toggleEl.dataset.toggled = String(Boolean(initialValue));
  941. toggleEl.tabIndex = -1;
  942. if (id)
  943. toggleEl.id = `bytm-toggle-input-${id}`;
  944. const toggleKnobEl = document.createElement("div");
  945. toggleKnobEl.classList.add("bytm-toggle-input-knob");
  946. // TODO: this doesn't make the knob show up on Chromium
  947. setInnerHtml(toggleKnobEl, "&nbsp;");
  948. const toggleElClicked = (e) => {
  949. e.preventDefault();
  950. e.stopPropagation();
  951. onChange(toggleEl.checked);
  952. toggleEl.dataset.toggled = String(Boolean(toggleEl.checked));
  953. if (labelEl)
  954. labelEl.textContent = t(`toggled_${toggleEl.checked ? "on" : "off"}`);
  955. wrapperEl.ariaValueText = t(`toggled_${toggleEl.checked ? "on" : "off"}`);
  956. };
  957. toggleEl.addEventListener("change", toggleElClicked);
  958. wrapperEl.addEventListener("keydown", (e) => {
  959. if (["Space", " ", "Enter"].includes(e.code)) {
  960. toggleEl.checked = !toggleEl.checked;
  961. toggleElClicked(e);
  962. }
  963. });
  964. toggleEl.appendChild(toggleKnobEl);
  965. toggleWrapperEl.appendChild(toggleEl);
  966. labelEl && labelPos === "left" && wrapperEl.appendChild(labelEl);
  967. wrapperEl.appendChild(toggleWrapperEl);
  968. labelEl && labelPos === "right" && wrapperEl.appendChild(labelEl);
  969. return wrapperEl;
  970. }var name = "betterytm";
  971. var userscriptName = "BetterYTM";
  972. var version = "2.2.0";
  973. var description = "Lots of configurable layout and user experience improvements for YouTube Music™ and YouTube™";
  974. var homepage = "https://github.com/Sv443/BetterYTM";
  975. var main = "./src/index.ts";
  976. var type = "module";
  977. var scripts = {
  978. dev: "concurrently \"nodemon --exec pnpm build-local-base --config-assetSource=local\" \"pnpm serve\"",
  979. "dev-cdn": "concurrently \"nodemon --exec pnpm build-local-base\" \"pnpm serve\"",
  980. "build-dev": "rollup -c --config-mode development --config-host github --config-branch develop",
  981. "build-prod": "pnpm build-prod-gh && pnpm build-prod-gf && pnpm build-prod-oujs",
  982. "build-prod-gh": "pnpm build-prod-base --config-host github",
  983. "build-prod-gf": "pnpm build-prod-base --config-host greasyfork --config-suffix _gf",
  984. "build-prod-oujs": "pnpm build-prod-base --config-host openuserjs --config-suffix _oujs",
  985. "post-build": "pnpm node-ts ./src/tools/post-build.ts",
  986. "build-local-base": "pnpm build-dev --config-gen-meta=false",
  987. "build-prod-base": "rollup -c --config-mode production --config-branch main",
  988. preview: "pnpm build-prod-gh --config-assetSource=local && pnpm serve --auto-exit-time=6",
  989. serve: "pnpm node-ts ./src/tools/serve.ts",
  990. lint: "eslint . && tsc --noEmit",
  991. "tr-changed": "pnpm node-ts ./src/tools/tr-changed.ts",
  992. "tr-progress": "pnpm node-ts ./src/tools/tr-progress.ts",
  993. "tr-format": "pnpm node-ts ./src/tools/tr-format.ts",
  994. "tr-prep": "pnpm tr-format -p",
  995. "gen-readme": "pnpm node-ts ./src/tools/gen-readme.ts",
  996. "node-ts": "node --import tsx --no-warnings=ExperimentalWarning --enable-source-maps",
  997. invisible: "node --enable-source-maps src/tools/run-invisible.mjs",
  998. test: "pnpm node-ts ./test.ts",
  999. knip: "knip",
  1000. storybook: "storybook dev -p 6006",
  1001. "build-storybook": "storybook build"
  1002. };
  1003. var engines = {
  1004. node: ">=20",
  1005. pnpm: ">=9"
  1006. };
  1007. var repository = {
  1008. type: "git",
  1009. url: "git+https://github.com/Sv443/BetterYTM.git"
  1010. };
  1011. var author = {
  1012. name: "Sv443",
  1013. url: "https://github.com/Sv443"
  1014. };
  1015. var license = "AGPL-3.0-only";
  1016. var bugs = {
  1017. url: "https://github.com/Sv443/BetterYTM/issues"
  1018. };
  1019. var funding = {
  1020. type: "github",
  1021. url: "https://github.com/sponsors/Sv443"
  1022. };
  1023. var hosts = {
  1024. github: "https://github.com/Sv443/BetterYTM",
  1025. greasyfork: "https://greasyfork.org/en/scripts/475682-betterytm",
  1026. openuserjs: "https://openuserjs.org/scripts/Sv443/BetterYTM"
  1027. };
  1028. var updates = {
  1029. github: "https://github.com/Sv443/BetterYTM/releases",
  1030. greasyfork: "https://greasyfork.org/en/scripts/475682-betterytm",
  1031. openuserjs: "https://openuserjs.org/scripts/Sv443/BetterYTM"
  1032. };
  1033. var dependencies = {
  1034. "@sv443-network/userutils": "^9.3.0",
  1035. "compare-versions": "^6.1.1",
  1036. dompurify: "^3.2.4",
  1037. marked: "^12.0.2",
  1038. tslib: "^2.8.1"
  1039. };
  1040. var devDependencies = {
  1041. "@chromatic-com/storybook": "^1.9.0",
  1042. "@eslint/eslintrc": "^3.3.0",
  1043. "@eslint/js": "^9.22.0",
  1044. "@rollup/plugin-json": "^6.1.0",
  1045. "@rollup/plugin-node-resolve": "^15.3.1",
  1046. "@rollup/plugin-terser": "^0.4.4",
  1047. "@rollup/plugin-typescript": "^11.1.6",
  1048. "@storybook/addon-essentials": "^8.6.4",
  1049. "@storybook/addon-interactions": "^8.6.4",
  1050. "@storybook/addon-links": "^8.6.4",
  1051. "@storybook/blocks": "^8.6.4",
  1052. "@storybook/html": "^8.6.4",
  1053. "@storybook/html-vite": "^8.6.4",
  1054. "@storybook/test": "^8.6.4",
  1055. "@types/cors": "^2.8.17",
  1056. "@types/express": "^4.17.21",
  1057. "@types/greasemonkey": "^4.0.7",
  1058. "@types/node": "^20.17.24",
  1059. "@typescript-eslint/eslint-plugin": "^8.26.1",
  1060. "@typescript-eslint/parser": "^8.26.1",
  1061. "@typescript-eslint/utils": "^8.26.1",
  1062. concurrently: "^9.1.2",
  1063. cors: "^2.8.5",
  1064. dotenv: "^16.4.7",
  1065. eslint: "^9.22.0",
  1066. "eslint-plugin-storybook": "^0.11.4",
  1067. express: "^4.21.2",
  1068. globals: "^15.15.0",
  1069. kleur: "^4.1.5",
  1070. knip: "^5.45.0",
  1071. nanoevents: "^9.1.0",
  1072. nodemon: "^3.1.9",
  1073. "open-cli": "^8.0.0",
  1074. pnpm: "^10.6.2",
  1075. rollup: "^4.35.0",
  1076. "rollup-plugin-execute": "^1.1.1",
  1077. "rollup-plugin-import-css": "^3.5.8",
  1078. storybook: "^8.6.4",
  1079. "storybook-dark-mode": "^4.0.2",
  1080. tsx: "^4.19.3",
  1081. typescript: "^5.8.2"
  1082. };
  1083. var browserslist = [
  1084. "last 1 version",
  1085. "> 1%",
  1086. "not dead"
  1087. ];
  1088. var nodemonConfig = {
  1089. watch: [
  1090. "src/**",
  1091. "assets/**",
  1092. "rollup.config.mjs",
  1093. ".env",
  1094. "changelog.md",
  1095. "package.json"
  1096. ],
  1097. ext: "ts,mts,js,jsx,mjs,json,html,css,svg,png",
  1098. ignore: [
  1099. "dist/*",
  1100. "dev/*",
  1101. "*/stories/*",
  1102. "assets/**/spritesheet.svg"
  1103. ]
  1104. };
  1105. var pnpm = {
  1106. onlyBuiltDependencies: [
  1107. "esbuild"
  1108. ]
  1109. };
  1110. var pkg = {
  1111. name: name,
  1112. userscriptName: userscriptName,
  1113. version: version,
  1114. description: description,
  1115. homepage: homepage,
  1116. main: main,
  1117. type: type,
  1118. scripts: scripts,
  1119. engines: engines,
  1120. repository: repository,
  1121. author: author,
  1122. license: license,
  1123. bugs: bugs,
  1124. funding: funding,
  1125. hosts: hosts,
  1126. updates: updates,
  1127. dependencies: dependencies,
  1128. devDependencies: devDependencies,
  1129. browserslist: browserslist,
  1130. nodemonConfig: nodemonConfig,
  1131. pnpm: pnpm
  1132. };/** EventEmitter instance that is used to detect various changes to the site and userscript */
  1133. const siteEvents = new UserUtils.NanoEmitter({
  1134. publicEmit: true,
  1135. });
  1136. let observers = [];
  1137. let lastVidId = null;
  1138. let lastPathname = null;
  1139. let lastFullscreen;
  1140. /** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */
  1141. async function initSiteEvents() {
  1142. try {
  1143. if (getDomain() === "ytm") {
  1144. //#region queue
  1145. // the queue container always exists so it doesn't need an extra init function
  1146. const queueObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => {
  1147. if (addedNodes.length > 0 || removedNodes.length > 0) {
  1148. info(`Detected queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  1149. emitSiteEvent("queueChanged", target);
  1150. }
  1151. });
  1152. // only observe added or removed elements
  1153. addSelectorListener("sidePanel", "#contents.ytmusic-player-queue", {
  1154. listener: (el) => {
  1155. queueObs.observe(el, {
  1156. childList: true,
  1157. });
  1158. },
  1159. });
  1160. const autoplayObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => {
  1161. if (addedNodes.length > 0 || removedNodes.length > 0) {
  1162. info(`Detected autoplay queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  1163. emitSiteEvent("autoplayQueueChanged", target);
  1164. }
  1165. });
  1166. addSelectorListener("sidePanel", "ytmusic-player-queue #automix-contents", {
  1167. listener: (el) => {
  1168. autoplayObs.observe(el, {
  1169. childList: true,
  1170. });
  1171. },
  1172. });
  1173. //#region player bar
  1174. let lastTitle = null;
  1175. addSelectorListener("playerBarInfo", "yt-formatted-string.title", {
  1176. continuous: true,
  1177. listener: (titleElem) => {
  1178. const oldTitle = lastTitle;
  1179. const newTitle = titleElem.textContent;
  1180. if (newTitle === lastTitle || !newTitle)
  1181. return;
  1182. lastTitle = newTitle;
  1183. info(`Detected song change - old title: "${oldTitle}" - new title: "${newTitle}"`);
  1184. emitSiteEvent("songTitleChanged", newTitle, oldTitle);
  1185. runIntervalChecks();
  1186. },
  1187. });
  1188. info("Successfully initialized SiteEvents observers");
  1189. observers = observers.concat([
  1190. queueObs,
  1191. autoplayObs,
  1192. ]);
  1193. //#region player
  1194. const playerFullscreenObs = new MutationObserver(([{ target }]) => {
  1195. var _a;
  1196. const isFullscreen = ((_a = target.getAttribute("player-ui-state")) === null || _a === void 0 ? void 0 : _a.toUpperCase()) === "FULLSCREEN";
  1197. if (lastFullscreen !== isFullscreen || typeof lastFullscreen === "undefined") {
  1198. emitSiteEvent("fullscreenToggled", isFullscreen);
  1199. lastFullscreen = isFullscreen;
  1200. }
  1201. });
  1202. if (getDomain() === "ytm") {
  1203. const registerFullScreenObs = () => addSelectorListener("mainPanel", "ytmusic-player#player", {
  1204. listener: (el) => {
  1205. playerFullscreenObs.observe(el, {
  1206. attributeFilter: ["player-ui-state"],
  1207. });
  1208. },
  1209. });
  1210. if (globserversReady)
  1211. registerFullScreenObs();
  1212. else
  1213. window.addEventListener("bytm:observersReady", registerFullScreenObs, { once: true });
  1214. }
  1215. }
  1216. window.addEventListener("bytm:ready", () => {
  1217. runIntervalChecks();
  1218. setInterval(runIntervalChecks, 100);
  1219. if (getDomain() === "ytm") {
  1220. addSelectorListener("mainPanel", "ytmusic-player #song-video #movie_player .ytp-title-text > a", {
  1221. listener(el) {
  1222. const urlRefObs = new MutationObserver(([{ target }]) => {
  1223. var _a;
  1224. if (!target || !((_a = target === null || target === void 0 ? void 0 : target.href) === null || _a === void 0 ? void 0 : _a.includes("/watch")))
  1225. return;
  1226. const videoID = new URL(target.href).searchParams.get("v");
  1227. checkVideoIdChange(videoID);
  1228. });
  1229. urlRefObs.observe(el, {
  1230. attributeFilter: ["href"],
  1231. });
  1232. }
  1233. });
  1234. }
  1235. if (getDomain() === "ytm") {
  1236. setInterval(checkVideoIdChange, 250);
  1237. checkVideoIdChange();
  1238. }
  1239. }, {
  1240. once: true,
  1241. });
  1242. }
  1243. catch (err) {
  1244. error("Couldn't initialize site event observers due to an error:\n", err);
  1245. }
  1246. }
  1247. let bytmReady = false;
  1248. window.addEventListener("bytm:ready", () => bytmReady = true, { once: true });
  1249. /** 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 */
  1250. function emitSiteEvent(key, ...args) {
  1251. try {
  1252. if (!bytmReady) {
  1253. window.addEventListener("bytm:ready", () => {
  1254. bytmReady = true;
  1255. emitSiteEvent(key, ...args);
  1256. }, { once: true });
  1257. return;
  1258. }
  1259. siteEvents.emit(key, ...args);
  1260. emitInterface(`bytm:siteEvent:${key}`, args);
  1261. }
  1262. catch (err) {
  1263. error(`Couldn't emit site event "${key}" due to an error:\n`, err);
  1264. }
  1265. }
  1266. //#region other
  1267. /** Checks if the watch ID has changed and emits a `watchIdChanged` siteEvent if it has */
  1268. function checkVideoIdChange(newID) {
  1269. const newVidID = newID !== null && newID !== void 0 ? newID : new URL(location.href).searchParams.get("v");
  1270. if (newVidID && newVidID !== lastVidId) {
  1271. info(`Detected watch ID change - old ID: "${lastVidId}" - new ID: "${newVidID}"`);
  1272. emitSiteEvent("watchIdChanged", newVidID, lastVidId);
  1273. lastVidId = newVidID;
  1274. }
  1275. }
  1276. /** Periodically called to check for changes in the URL and emit associated siteEvents */
  1277. function runIntervalChecks() {
  1278. if (!lastVidId)
  1279. checkVideoIdChange();
  1280. if (location.pathname !== lastPathname) {
  1281. emitSiteEvent("pathChanged", String(location.pathname), lastPathname);
  1282. lastPathname = String(location.pathname);
  1283. }
  1284. }let verNotifDialog = null;
  1285. /** Creates and/or returns the dialog to be shown when a new version is available */
  1286. async function getVersionNotifDialog({ latestTag, }) {
  1287. if (!verNotifDialog) {
  1288. const changelogMdFull = await getChangelogMd();
  1289. // 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
  1290. const changelogMd = changelogMdFull.split("<div class=\"split\">")[1];
  1291. const changelogHtml = await parseMarkdown(changelogMd);
  1292. verNotifDialog = new BytmDialog({
  1293. id: "version-notif",
  1294. width: 600,
  1295. height: 800,
  1296. closeBtnEnabled: false,
  1297. closeOnBgClick: false,
  1298. closeOnEscPress: true,
  1299. destroyOnClose: true,
  1300. small: true,
  1301. renderHeader: renderHeader$5,
  1302. renderBody: () => renderBody$5({ latestTag, changelogHtml }),
  1303. });
  1304. }
  1305. return verNotifDialog;
  1306. }
  1307. async function renderHeader$5() {
  1308. const logoEl = document.createElement("img");
  1309. logoEl.classList.add("bytm-dialog-header-img", "bytm-no-select");
  1310. logoEl.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  1311. logoEl.alt = "BetterYTM logo";
  1312. return logoEl;
  1313. }
  1314. let disableUpdateCheck = false;
  1315. async function renderBody$5({ latestTag, changelogHtml, }) {
  1316. disableUpdateCheck = false;
  1317. const wrapperEl = document.createElement("div");
  1318. const pEl = document.createElement("p");
  1319. pEl.textContent = t("new_version_available", scriptInfo.name, scriptInfo.version, latestTag, platformNames[host]);
  1320. wrapperEl.appendChild(pEl);
  1321. const changelogDetailsEl = document.createElement("details");
  1322. changelogDetailsEl.id = "bytm-version-notif-changelog-details";
  1323. changelogDetailsEl.open = false;
  1324. const changelogSummaryEl = document.createElement("summary");
  1325. changelogSummaryEl.role = "button";
  1326. changelogSummaryEl.tabIndex = 0;
  1327. changelogSummaryEl.ariaLabel = changelogSummaryEl.title = changelogSummaryEl.textContent = t("expand_release_notes");
  1328. changelogDetailsEl.appendChild(changelogSummaryEl);
  1329. changelogDetailsEl.addEventListener("toggle", () => {
  1330. changelogSummaryEl.ariaLabel = changelogSummaryEl.title = changelogSummaryEl.textContent = changelogDetailsEl.open ? t("collapse_release_notes") : t("expand_release_notes");
  1331. });
  1332. const changelogEl = document.createElement("p");
  1333. changelogEl.id = "bytm-version-notif-changelog-cont";
  1334. changelogEl.classList.add("bytm-markdown-container");
  1335. setInnerHtml(changelogEl, changelogHtml);
  1336. changelogEl.querySelectorAll("a").forEach((a) => {
  1337. a.target = "_blank";
  1338. a.rel = "noopener noreferrer";
  1339. });
  1340. changelogDetailsEl.appendChild(changelogEl);
  1341. wrapperEl.appendChild(changelogDetailsEl);
  1342. const disableUpdCheckEl = document.createElement("div");
  1343. disableUpdCheckEl.id = "bytm-disable-update-check-wrapper";
  1344. if (!getFeature("versionCheck"))
  1345. disableUpdateCheck = true;
  1346. const disableToggleEl = await createToggleInput({
  1347. id: "disable-update-check",
  1348. initialValue: disableUpdateCheck,
  1349. labelPos: "off",
  1350. onChange(checked) {
  1351. disableUpdateCheck = checked;
  1352. if (checked)
  1353. btnClose.textContent = t("close_and_ignore_until_reenabled");
  1354. else
  1355. btnClose.textContent = t("close_and_ignore_for_24h");
  1356. },
  1357. });
  1358. const labelWrapperEl = document.createElement("div");
  1359. labelWrapperEl.classList.add("bytm-disable-update-check-toggle-label-wrapper");
  1360. const labelEl = document.createElement("label");
  1361. labelEl.htmlFor = "bytm-toggle-disable-update-check";
  1362. labelEl.textContent = t("disable_update_check");
  1363. const secondaryLabelEl = document.createElement("span");
  1364. secondaryLabelEl.classList.add("bytm-secondary-label");
  1365. secondaryLabelEl.textContent = t("reenable_in_config_menu");
  1366. labelWrapperEl.appendChild(labelEl);
  1367. labelWrapperEl.appendChild(secondaryLabelEl);
  1368. disableUpdCheckEl.appendChild(disableToggleEl);
  1369. disableUpdCheckEl.appendChild(labelWrapperEl);
  1370. wrapperEl.appendChild(disableUpdCheckEl);
  1371. verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.on("close", async () => {
  1372. const config = getFeatures();
  1373. const recreateCfgMenu = config.versionCheck === disableUpdateCheck;
  1374. if (config.versionCheck && disableUpdateCheck)
  1375. config.versionCheck = false;
  1376. else if (!config.versionCheck && !disableUpdateCheck)
  1377. config.versionCheck = true;
  1378. await setFeatures(config);
  1379. recreateCfgMenu && emitSiteEvent("recreateCfgMenu");
  1380. });
  1381. const btnWrapper = document.createElement("div");
  1382. btnWrapper.id = "bytm-version-notif-dialog-btns";
  1383. const btnUpdate = document.createElement("button");
  1384. btnUpdate.classList.add("bytm-btn");
  1385. btnUpdate.tabIndex = 0;
  1386. btnUpdate.textContent = t("open_update_page_install_manually", platformNames[host]);
  1387. onInteraction(btnUpdate, () => {
  1388. window.open(pkg.updates[host]);
  1389. verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.close();
  1390. });
  1391. const btnClose = document.createElement("button");
  1392. btnClose.classList.add("bytm-btn");
  1393. btnClose.tabIndex = 0;
  1394. btnClose.textContent = t("close_and_ignore_for_24h");
  1395. onInteraction(btnClose, () => verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.close());
  1396. btnWrapper.appendChild(btnUpdate);
  1397. btnWrapper.appendChild(btnClose);
  1398. wrapperEl.appendChild(btnWrapper);
  1399. return wrapperEl;
  1400. }//#region PromptDialog
  1401. let promptDialog = null;
  1402. class PromptDialog extends BytmDialog {
  1403. constructor(props) {
  1404. super({
  1405. id: "prompt-dialog",
  1406. width: 500,
  1407. height: 400,
  1408. destroyOnClose: true,
  1409. closeBtnEnabled: true,
  1410. closeOnBgClick: props.type !== "prompt",
  1411. closeOnEscPress: true,
  1412. small: true,
  1413. renderHeader: () => this.renderHeader(props),
  1414. renderBody: () => this.renderBody(props),
  1415. renderFooter: () => this.renderFooter(props),
  1416. });
  1417. this.on("render", this.focusOnRender);
  1418. }
  1419. emitResolve(val) {
  1420. this.events.emit("resolve", val);
  1421. }
  1422. async renderHeader({ type }) {
  1423. const headerEl = document.createElement("div");
  1424. headerEl.id = "bytm-prompt-dialog-header";
  1425. setInnerHtml(headerEl, await resourceAsString(type === "alert" ? "icon-alert" : "icon-prompt"));
  1426. return headerEl;
  1427. }
  1428. async renderBody(_a) {
  1429. var { type, message } = _a, rest = __rest(_a, ["type", "message"]);
  1430. const contElem = document.createElement("div");
  1431. contElem.classList.add(`bytm-prompt-type-${type}`);
  1432. const upperContElem = document.createElement("div");
  1433. upperContElem.id = "bytm-prompt-dialog-upper-cont";
  1434. contElem.appendChild(upperContElem);
  1435. const messageElem = document.createElement("p");
  1436. messageElem.id = "bytm-prompt-dialog-message";
  1437. messageElem.role = "alert";
  1438. messageElem.ariaLive = "polite";
  1439. messageElem.tabIndex = 0;
  1440. messageElem.textContent = String(message);
  1441. upperContElem.appendChild(messageElem);
  1442. if (type === "prompt") {
  1443. const inputElem = document.createElement("input");
  1444. inputElem.id = "bytm-prompt-dialog-input";
  1445. inputElem.type = "text";
  1446. inputElem.autocomplete = "off";
  1447. inputElem.spellcheck = false;
  1448. inputElem.value = "defaultValue" in rest && rest.defaultValue
  1449. ? await UserUtils.consumeStringGen(rest.defaultValue)
  1450. : "";
  1451. const inputEnterListener = (e) => {
  1452. var _a, _b;
  1453. if (e.key === "Enter") {
  1454. inputElem.removeEventListener("keydown", inputEnterListener);
  1455. this.emitResolve((_b = (_a = inputElem === null || inputElem === void 0 ? void 0 : inputElem.value) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : null);
  1456. promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.close();
  1457. }
  1458. };
  1459. inputElem.addEventListener("keydown", inputEnterListener);
  1460. promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.once("close", () => inputElem.removeEventListener("keydown", inputEnterListener));
  1461. upperContElem.appendChild(inputElem);
  1462. }
  1463. return contElem;
  1464. }
  1465. async renderFooter(_a) {
  1466. var { type } = _a, rest = __rest(_a, ["type"]);
  1467. const buttonsWrapper = document.createElement("div");
  1468. buttonsWrapper.id = "bytm-prompt-dialog-button-wrapper";
  1469. const buttonsCont = document.createElement("div");
  1470. buttonsCont.id = "bytm-prompt-dialog-buttons-cont";
  1471. let confirmBtn;
  1472. if (type === "confirm" || type === "prompt") {
  1473. confirmBtn = document.createElement("button");
  1474. confirmBtn.id = "bytm-prompt-dialog-confirm";
  1475. confirmBtn.classList.add("bytm-prompt-dialog-button");
  1476. confirmBtn.textContent = await this.consumePromptStringGen(type, rest.confirmBtnText, t("prompt_confirm"));
  1477. confirmBtn.ariaLabel = confirmBtn.title = await this.consumePromptStringGen(type, rest.confirmBtnTooltip, t("click_to_confirm_tooltip"));
  1478. confirmBtn.tabIndex = 0;
  1479. confirmBtn.addEventListener("click", () => {
  1480. var _a, _b, _c;
  1481. this.emitResolve(type === "confirm" ? true : (_c = (_b = (_a = (document.querySelector("#bytm-prompt-dialog-input"))) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.trim()) !== null && _c !== void 0 ? _c : null);
  1482. promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.close();
  1483. }, { once: true });
  1484. }
  1485. const closeBtn = document.createElement("button");
  1486. closeBtn.id = "bytm-prompt-dialog-close";
  1487. closeBtn.classList.add("bytm-prompt-dialog-button");
  1488. closeBtn.textContent = await this.consumePromptStringGen(type, rest.denyBtnText, t(type === "alert" ? "prompt_close" : "prompt_cancel"));
  1489. closeBtn.ariaLabel = closeBtn.title = await this.consumePromptStringGen(type, rest.denyBtnTooltip, t(type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip"));
  1490. closeBtn.tabIndex = 0;
  1491. closeBtn.addEventListener("click", () => {
  1492. const resVals = {
  1493. alert: true,
  1494. confirm: false,
  1495. prompt: null,
  1496. };
  1497. this.emitResolve(resVals[type]);
  1498. promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.close();
  1499. }, { once: true });
  1500. confirmBtn && getOS() !== "mac" && buttonsCont.appendChild(confirmBtn);
  1501. buttonsCont.appendChild(closeBtn);
  1502. confirmBtn && getOS() === "mac" && buttonsCont.appendChild(confirmBtn);
  1503. buttonsWrapper.appendChild(buttonsCont);
  1504. return buttonsWrapper;
  1505. }
  1506. /** Converts a {@linkcode stringGen} (stringifiable value or sync or async function that returns a stringifiable value) to a string - uses {@linkcode fallback} as a fallback */
  1507. async consumePromptStringGen(curPromptType, stringGen, fallback) {
  1508. if (typeof stringGen === "function")
  1509. return await stringGen(curPromptType);
  1510. return String(stringGen !== null && stringGen !== void 0 ? stringGen : fallback);
  1511. }
  1512. /** Called on render to focus on the confirm or cancel button or text input, depending on prompt type */
  1513. focusOnRender() {
  1514. const inputElem = document.querySelector("#bytm-prompt-dialog-input");
  1515. if (inputElem)
  1516. return inputElem.focus();
  1517. let captureEnterKey = true;
  1518. document.addEventListener("keydown", (e) => {
  1519. var _a;
  1520. if (e.key === "Enter" && captureEnterKey) {
  1521. const confBtn = document.querySelector("#bytm-prompt-dialog-confirm");
  1522. const closeBtn = document.querySelector("#bytm-prompt-dialog-close");
  1523. if (confBtn || closeBtn) {
  1524. (_a = confBtn === null || confBtn === void 0 ? void 0 : confBtn.click()) !== null && _a !== void 0 ? _a : closeBtn === null || closeBtn === void 0 ? void 0 : closeBtn.click();
  1525. captureEnterKey = false;
  1526. }
  1527. }
  1528. }, { capture: true, once: true });
  1529. }
  1530. }
  1531. /** Custom dialog to emulate and enhance the behavior of the native `confirm()`, `alert()`, and `prompt()` functions */
  1532. function showPrompt(_a) {
  1533. var { type } = _a, rest = __rest(_a, ["type"]);
  1534. return new Promise((resolve) => {
  1535. if (BytmDialog.getOpenDialogs().includes("prompt-dialog"))
  1536. promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.close();
  1537. promptDialog = new PromptDialog(Object.assign({ type }, rest));
  1538. promptDialog.once("render", () => {
  1539. addSelectorListener("bytmDialogContainer", `#bytm-prompt-dialog-${type === "alert" ? "close" : "confirm"}`, {
  1540. listener: (btn) => btn.focus(),
  1541. });
  1542. });
  1543. // make config menu inert while prompt dialog is open
  1544. promptDialog.once("open", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true"); });
  1545. promptDialog.once("close", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert"); });
  1546. let resolveVal;
  1547. const tryResolve = () => resolve(typeof resolveVal !== "undefined" ? resolveVal : false);
  1548. let closeUnsub; // eslint-disable-line prefer-const
  1549. const resolveUnsub = promptDialog.on("resolve", (val) => {
  1550. resolveUnsub();
  1551. if (resolveVal !== undefined)
  1552. return;
  1553. resolveVal = val;
  1554. tryResolve();
  1555. closeUnsub === null || closeUnsub === void 0 ? void 0 : closeUnsub();
  1556. });
  1557. closeUnsub = promptDialog.on("close", () => {
  1558. closeUnsub();
  1559. if (resolveVal !== undefined)
  1560. return;
  1561. resolveVal = type === "alert";
  1562. if (type === "prompt")
  1563. resolveVal = null;
  1564. tryResolve();
  1565. resolveUnsub();
  1566. });
  1567. promptDialog.open();
  1568. });
  1569. }const releaseURL = "https://github.com/Sv443/BetterYTM/releases/latest";
  1570. /** Initializes the version check feature */
  1571. async function initVersionCheck() {
  1572. try {
  1573. if (getFeature("versionCheck") === false)
  1574. return info("Version check is disabled");
  1575. const lastCheck = await GM.getValue("bytm-version-check", 0);
  1576. if (Date.now() - lastCheck < 1000 * 60 * 60 * 24)
  1577. return;
  1578. await doVersionCheck(false);
  1579. }
  1580. catch (err) {
  1581. error("Version check failed:", err);
  1582. }
  1583. }
  1584. /**
  1585. * Checks for a new version of the script and shows a dialog.
  1586. * If {@linkcode notifyNoNewVerFound} is set to true, a dialog is also shown if no updates were found.
  1587. */
  1588. async function doVersionCheck(notifyNoNewVerFound = false) {
  1589. var _a;
  1590. await GM.setValue("bytm-version-check", Date.now());
  1591. const res = await sendRequest({
  1592. method: "GET",
  1593. url: releaseURL,
  1594. });
  1595. // TODO: small dialog for "no update found" message?
  1596. const noNewVerFound = () => notifyNoNewVerFound ? showPrompt({ type: "alert", message: t("no_new_version_found") }) : undefined;
  1597. const latestTag = (_a = res.finalUrl.split("/").pop()) === null || _a === void 0 ? void 0 : _a.replace(/[a-zA-Z]/g, "");
  1598. if (!latestTag)
  1599. return await noNewVerFound();
  1600. info("Version check - current version:", scriptInfo.version, "- latest version:", latestTag, LogLevel.Info);
  1601. if (compareVersions.compare(scriptInfo.version, latestTag, "<")) {
  1602. const dialog = await getVersionNotifDialog({ latestTag });
  1603. await dialog.open();
  1604. return;
  1605. }
  1606. return await noNewVerFound();
  1607. }/** Max amount of seconds a toast can be shown for */
  1608. const maxToastDuration = 30000;
  1609. let timeout;
  1610. /** Shows a toast message with an icon */
  1611. async function showIconToast(_a) {
  1612. var { duration, position = "tr", iconPos = "left" } = _a, rest = __rest(_a, ["duration", "position", "iconPos"]);
  1613. if (typeof duration !== "number" || isNaN(duration))
  1614. duration = getFeature("toastDuration") * 1000;
  1615. if (duration <= 0)
  1616. return info("Toast duration is <= 0, so it won't be shown");
  1617. const toastWrapper = document.createElement("div");
  1618. toastWrapper.classList.add("bytm-toast-flex-wrapper");
  1619. let toastIcon;
  1620. if ("iconSrc" in rest) {
  1621. toastIcon = document.createElement("img");
  1622. toastIcon.classList.add("bytm-toast-icon", "img");
  1623. toastIcon.src = rest.iconSrc instanceof Promise
  1624. ? await rest.iconSrc
  1625. : rest.iconSrc;
  1626. }
  1627. else {
  1628. toastIcon = document.createElement("div");
  1629. toastIcon.classList.add("bytm-toast-icon");
  1630. const iconHtml = await resourceAsString(rest.icon);
  1631. if (iconHtml)
  1632. setInnerHtml(toastIcon, iconHtml);
  1633. if ("iconFill" in rest && rest.iconFill)
  1634. toastIcon.style.setProperty("--toast-icon-fill", rest.iconFill);
  1635. }
  1636. const toastMessage = document.createElement("div");
  1637. toastMessage.classList.add("bytm-toast-message");
  1638. if ("message" in rest) {
  1639. toastMessage.textContent = rest.message;
  1640. if ("subtitle" in rest && rest.subtitle) {
  1641. const subtitleEl = document.createElement("div");
  1642. subtitleEl.classList.add("bytm-toast-subtitle");
  1643. subtitleEl.textContent = rest.subtitle;
  1644. toastMessage.appendChild(subtitleEl);
  1645. }
  1646. }
  1647. else
  1648. toastMessage.appendChild(rest.element);
  1649. iconPos === "left" && toastWrapper.appendChild(toastIcon);
  1650. toastWrapper.appendChild(toastMessage);
  1651. iconPos === "right" && toastWrapper.appendChild(toastIcon);
  1652. return await showToast({
  1653. duration,
  1654. position,
  1655. element: toastWrapper,
  1656. title: "message" in rest ? rest.message : rest.title,
  1657. onClick: rest.onClick,
  1658. });
  1659. }
  1660. /** Shows a toast message or element in the specified position (top right corner by default) and uses the default timeout from the config option `toastDuration` */
  1661. async function showToast(arg) {
  1662. const props = typeof arg === "string"
  1663. ? {
  1664. message: arg,
  1665. duration: getFeature("toastDuration") * 1000,
  1666. }
  1667. : arg;
  1668. const { duration: durationMs = getFeature("toastDuration") * 1000, onClick, position = "tr" } = props, rest = __rest(props, ["duration", "onClick", "position"]);
  1669. if (durationMs <= 0)
  1670. return info("Toast duration is <= 0, so it won't be shown");
  1671. if (document.querySelector("#bytm-toast"))
  1672. await closeToast();
  1673. const toastElem = document.createElement("div");
  1674. toastElem.classList.add(`pos-${position.toLowerCase()}`);
  1675. onClick && toastElem.classList.add("clickable");
  1676. toastElem.id = "bytm-toast";
  1677. toastElem.role = "alert";
  1678. toastElem.ariaLive = "polite";
  1679. toastElem.ariaAtomic = "true";
  1680. toastElem.addEventListener("click", async (e) => {
  1681. onClick === null || onClick === void 0 ? void 0 : onClick(e);
  1682. await closeToast();
  1683. }, { once: true });
  1684. if ("message" in rest)
  1685. toastElem.title = toastElem.ariaLabel = toastElem.textContent = rest.message;
  1686. else {
  1687. toastElem.appendChild(rest.element);
  1688. toastElem.title = toastElem.ariaLabel = rest.title;
  1689. }
  1690. document.body.appendChild(toastElem);
  1691. UserUtils.pauseFor(100).then(() => {
  1692. toastElem.classList.add("visible");
  1693. if (durationMs < Number.POSITIVE_INFINITY && durationMs > 0) {
  1694. timeout && clearTimeout(timeout);
  1695. timeout = setTimeout(closeToast, UserUtils.clamp(durationMs, 250, maxToastDuration));
  1696. }
  1697. });
  1698. return toastElem;
  1699. }
  1700. /** Closes the currently open toast */
  1701. async function closeToast() {
  1702. if (timeout) {
  1703. clearTimeout(timeout);
  1704. timeout = undefined;
  1705. }
  1706. const toastEls = document.querySelectorAll("#bytm-toast");
  1707. if (toastEls.length === 0)
  1708. return;
  1709. await Promise.allSettled(Array.from(toastEls).map(async (toastEl) => {
  1710. toastEl.classList.remove("visible");
  1711. await UserUtils.pauseFor(300);
  1712. toastEl.remove();
  1713. await UserUtils.pauseFor(100);
  1714. }));
  1715. }const interactionKeys = ["Enter", " ", "Space"];
  1716. /**
  1717. * Adds generic, accessible interaction listeners to the passed element.
  1718. * All listeners have the default behavior prevented and stop propagation (for keyboard events this only applies as long as the captured key is included in {@linkcode interactionKeys}).
  1719. * @param listenerOptions Provide a {@linkcode listenerOptions} object to configure the listeners
  1720. */
  1721. function onInteraction(elem, listener, listenerOptions) {
  1722. const _a = listenerOptions !== null && listenerOptions !== void 0 ? listenerOptions : {}, { preventDefault = true, stopPropagation = true } = _a, listenerOpts = __rest(_a, ["preventDefault", "stopPropagation"]);
  1723. const proxListener = (e) => {
  1724. if (e instanceof KeyboardEvent) {
  1725. if (interactionKeys.includes(e.key)) {
  1726. preventDefault && e.preventDefault();
  1727. stopPropagation && e.stopPropagation();
  1728. }
  1729. else
  1730. return;
  1731. }
  1732. else if (e instanceof MouseEvent) {
  1733. preventDefault && e.preventDefault();
  1734. stopPropagation && e.stopPropagation();
  1735. }
  1736. // clean up the other listener that isn't automatically removed if `once` is set
  1737. (listenerOpts === null || listenerOpts === void 0 ? void 0 : listenerOpts.once) && e.type === "keydown" && elem.removeEventListener("click", proxListener, listenerOpts);
  1738. (listenerOpts === null || listenerOpts === void 0 ? void 0 : listenerOpts.once) && e.type === "click" && elem.removeEventListener("keydown", proxListener, listenerOpts);
  1739. listener(e);
  1740. };
  1741. elem.addEventListener("click", proxListener, listenerOpts);
  1742. elem.addEventListener("keydown", proxListener, listenerOpts);
  1743. }/**
  1744. * Creates an element with a ripple effect on click.
  1745. * @param rippleElement If passed, this element will be modified to have the ripple effect. Otherwise, a new element will be created.
  1746. * @returns The passed element or the newly created element with the ripple effect.
  1747. */
  1748. function createRipple(rippleElement, properties) {
  1749. const props = Object.assign({ speed: "normal" }, properties);
  1750. const rippleEl = rippleElement !== null && rippleElement !== void 0 ? rippleElement : document.createElement("div");
  1751. "additionalProps" in props && Object.assign(rippleEl, props.additionalProps);
  1752. rippleEl.classList.add("bytm-ripple", props.speed);
  1753. const updateRippleWidth = () => rippleEl.style.setProperty("--bytm-ripple-cont-width", `${rippleEl.clientWidth}px`);
  1754. rippleEl.addEventListener("mousedown", (e) => {
  1755. updateRippleWidth();
  1756. const x = e.clientX - rippleEl.getBoundingClientRect().left;
  1757. const y = e.clientY - rippleEl.getBoundingClientRect().top;
  1758. const rippleAreaEl = document.createElement("span");
  1759. rippleAreaEl.classList.add("bytm-ripple-area");
  1760. rippleAreaEl.style.left = `${Math.round(x)}px`;
  1761. rippleAreaEl.style.top = `${Math.round(y)}px`;
  1762. if (rippleEl.firstChild)
  1763. rippleEl.insertBefore(rippleAreaEl, rippleEl.firstChild);
  1764. else
  1765. rippleEl.appendChild(rippleAreaEl);
  1766. rippleAreaEl.addEventListener("animationend", () => rippleAreaEl.remove());
  1767. });
  1768. updateRippleWidth();
  1769. return rippleEl;
  1770. }/**
  1771. * Creates a generic, circular, long button element with an icon and text.
  1772. * Has classes for the enabled and disabled states for easier styling.
  1773. * If `href` is provided, the button will be an anchor element.
  1774. * If `onClick` or `onToggle` is provided, the button will be a div element.
  1775. * Provide either `resourceName` or `src` to specify the icon inside the button.
  1776. */
  1777. async function createLongBtn(_a) {
  1778. var { title, text, iconPosition, ripple } = _a, rest = __rest(_a, ["title", "text", "iconPosition", "ripple"]);
  1779. if (["href", "onClick", "onToggle"].every((key) => !(key in rest)))
  1780. throw new TypeError("Either 'href', 'onClick' or 'onToggle' must be provided");
  1781. let btnElem;
  1782. if ("href" in rest && rest.href) {
  1783. btnElem = document.createElement("a");
  1784. btnElem.href = rest.href;
  1785. btnElem.role = "button";
  1786. btnElem.target = "_blank";
  1787. btnElem.rel = "noopener noreferrer";
  1788. }
  1789. else
  1790. btnElem = document.createElement("div");
  1791. if ("toggle" in rest && rest.toggle) {
  1792. btnElem.classList.add("bytm-toggle");
  1793. if ("toggleInitialState" in rest && rest.toggleInitialState)
  1794. btnElem.classList.add("toggled");
  1795. }
  1796. onInteraction(btnElem, (evt) => {
  1797. var _a;
  1798. if ("onClick" in rest)
  1799. rest.onClick(evt);
  1800. if ("toggle" in rest && rest.toggle && ((_a = rest.togglePredicate) !== null && _a !== void 0 ? _a : (() => true))(evt))
  1801. rest.onToggle(btnElem.classList.toggle("toggled"), evt);
  1802. });
  1803. btnElem.classList.add("bytm-generic-btn", "long");
  1804. btnElem.ariaLabel = btnElem.title = title;
  1805. btnElem.tabIndex = 0;
  1806. btnElem.role = "button";
  1807. const imgElem = document.createElement("src" in rest ? "img" : "div");
  1808. imgElem.classList.add("bytm-generic-btn-img", iconPosition !== null && iconPosition !== void 0 ? iconPosition : "left");
  1809. if ("src" in rest)
  1810. imgElem.src = rest.src;
  1811. else
  1812. setInnerHtml(imgElem, await resourceAsString(rest.resourceName));
  1813. const txtElem = document.createElement("span");
  1814. txtElem.classList.add("bytm-generic-long-btn-txt", "bytm-no-select");
  1815. txtElem.textContent = txtElem.ariaLabel = text;
  1816. iconPosition === "left" || !iconPosition && btnElem.appendChild(imgElem);
  1817. btnElem.appendChild(txtElem);
  1818. iconPosition === "right" && btnElem.appendChild(imgElem);
  1819. return ripple ? createRipple(btnElem, { speed: "normal" }) : btnElem;
  1820. }//#region class
  1821. /** Generic dialog for exporting and importing any string of data */
  1822. class ExImDialog extends BytmDialog {
  1823. constructor(options) {
  1824. super(Object.assign({ renderHeader: () => ExImDialog.renderHeader(options), renderBody: () => ExImDialog.renderBody(options), renderFooter: undefined, closeOnBgClick: true, closeOnEscPress: true, closeBtnEnabled: true, unmountOnClose: true, small: true }, options));
  1825. }
  1826. //#region header
  1827. static async renderHeader(opts) {
  1828. const headerEl = document.createElement("h2");
  1829. headerEl.classList.add("bytm-menu-title");
  1830. headerEl.role = "heading";
  1831. headerEl.ariaLevel = "1";
  1832. headerEl.tabIndex = 0;
  1833. headerEl.textContent = headerEl.ariaLabel = await UserUtils.consumeStringGen(opts.title);
  1834. return headerEl;
  1835. }
  1836. //#region body
  1837. static async renderBody(opts) {
  1838. const panesCont = document.createElement("div");
  1839. panesCont.classList.add("bytm-exim-dialog-panes-cont");
  1840. //#region export
  1841. const exportPane = document.createElement("div");
  1842. exportPane.classList.add("bytm-exim-dialog-pane", "export");
  1843. {
  1844. const descEl = document.createElement("p");
  1845. descEl.classList.add("bytm-exim-dialog-desc");
  1846. descEl.role = "note";
  1847. descEl.tabIndex = 0;
  1848. descEl.textContent = descEl.ariaLabel = await UserUtils.consumeStringGen(opts.descExport);
  1849. const dataEl = document.createElement("textarea");
  1850. dataEl.classList.add("bytm-exim-dialog-data");
  1851. dataEl.readOnly = true;
  1852. dataEl.tabIndex = 0;
  1853. dataEl.value = t("click_to_reveal");
  1854. onInteraction(dataEl, async () => {
  1855. dataEl.value = await UserUtils.consumeStringGen(opts.exportData);
  1856. dataEl.setSelectionRange(0, dataEl.value.length);
  1857. });
  1858. const exportCenterBtnCont = document.createElement("div");
  1859. exportCenterBtnCont.classList.add("bytm-exim-dialog-center-btn-cont");
  1860. const copyBtn = createRipple(await createLongBtn({
  1861. title: t("copy_to_clipboard"),
  1862. text: t("copy"),
  1863. resourceName: "icon-copy",
  1864. async onClick({ shiftKey }) {
  1865. const copyData = shiftKey && opts.exportDataSpecial ? opts.exportDataSpecial : opts.exportData;
  1866. copyToClipboard(await UserUtils.consumeStringGen(copyData));
  1867. await showToast({ message: t("copied_to_clipboard") });
  1868. },
  1869. }));
  1870. exportCenterBtnCont.appendChild(copyBtn);
  1871. exportPane.append(descEl, dataEl, exportCenterBtnCont);
  1872. }
  1873. //#region import
  1874. const importPane = document.createElement("div");
  1875. importPane.classList.add("bytm-exim-dialog-pane", "import");
  1876. {
  1877. const descEl = document.createElement("p");
  1878. descEl.classList.add("bytm-exim-dialog-desc");
  1879. descEl.role = "note";
  1880. descEl.tabIndex = 0;
  1881. descEl.textContent = descEl.ariaLabel = await UserUtils.consumeStringGen(opts.descImport);
  1882. const dataEl = document.createElement("textarea");
  1883. dataEl.classList.add("bytm-exim-dialog-data");
  1884. dataEl.tabIndex = 0;
  1885. const importCenterBtnCont = document.createElement("div");
  1886. importCenterBtnCont.classList.add("bytm-exim-dialog-center-btn-cont");
  1887. const importBtn = createRipple(await createLongBtn({
  1888. title: t("start_import_tooltip"),
  1889. text: t("import"),
  1890. resourceName: "icon-upload",
  1891. onClick: () => opts.onImport(dataEl.value),
  1892. }));
  1893. importCenterBtnCont.appendChild(importBtn);
  1894. importPane.append(descEl, dataEl, importCenterBtnCont);
  1895. }
  1896. panesCont.append(exportPane, importPane);
  1897. return panesCont;
  1898. }
  1899. }/**
  1900. * Creates a generic, circular button element.
  1901. * If `href` is provided, the button will be an anchor element.
  1902. * If `onClick` is provided, the button will be a div element.
  1903. * Provide either `resourceName` or `src` to specify the icon inside the button.
  1904. */
  1905. async function createCircularBtn(_a) {
  1906. var { title, ripple = true } = _a, rest = __rest(_a, ["title", "ripple"]);
  1907. let btnElem;
  1908. if ("href" in rest && rest.href) {
  1909. btnElem = document.createElement("a");
  1910. btnElem.href = rest.href;
  1911. btnElem.role = "button";
  1912. btnElem.target = "_blank";
  1913. btnElem.rel = "noopener noreferrer";
  1914. }
  1915. else if ("onClick" in rest && rest.onClick) {
  1916. btnElem = document.createElement("div");
  1917. rest.onClick && onInteraction(btnElem, rest.onClick);
  1918. }
  1919. else
  1920. throw new TypeError("Either 'href' or 'onClick' must be provided");
  1921. btnElem.classList.add("bytm-generic-btn");
  1922. btnElem.ariaLabel = btnElem.title = title;
  1923. btnElem.tabIndex = 0;
  1924. btnElem.role = "button";
  1925. const imgElem = document.createElement("img");
  1926. imgElem.classList.add("bytm-generic-btn-img");
  1927. imgElem.src = "src" in rest
  1928. ? rest.src instanceof Promise
  1929. ? await rest.src
  1930. : rest.src
  1931. : await getResourceUrl(rest.resourceName);
  1932. btnElem.appendChild(imgElem);
  1933. return ripple ? createRipple(btnElem) : btnElem;
  1934. }let autoLikeDialog = null;
  1935. let autoLikeExImDialog = null;
  1936. /** Creates and/or returns the import dialog */
  1937. async function getAutoLikeDialog() {
  1938. if (!autoLikeDialog) {
  1939. await initAutoLikeStore();
  1940. autoLikeDialog = new BytmDialog({
  1941. id: "auto-like-channels",
  1942. width: 700,
  1943. height: 1000,
  1944. closeBtnEnabled: true,
  1945. closeOnBgClick: true,
  1946. closeOnEscPress: true,
  1947. destroyOnClose: true,
  1948. removeListenersOnDestroy: false,
  1949. small: true,
  1950. verticalAlign: "top",
  1951. renderHeader: renderHeader$4,
  1952. renderBody: renderBody$4,
  1953. renderFooter: renderFooter$1,
  1954. });
  1955. siteEvents.on("autoLikeChannelsUpdated", async () => {
  1956. try {
  1957. if (autoLikeExImDialog === null || autoLikeExImDialog === void 0 ? void 0 : autoLikeExImDialog.isOpen())
  1958. autoLikeExImDialog.unmount();
  1959. if (autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.isOpen()) {
  1960. autoLikeDialog.unmount();
  1961. await autoLikeDialog.open();
  1962. log("Auto-like channels updated, refreshed dialog");
  1963. }
  1964. }
  1965. catch (err) {
  1966. error("Couldn't refresh auto-like channels dialog:", err);
  1967. }
  1968. });
  1969. autoLikeDialog.on("close", () => emitSiteEvent("autoLikeChannelsUpdated"));
  1970. }
  1971. if (!autoLikeExImDialog) {
  1972. autoLikeExImDialog = new ExImDialog({
  1973. id: "auto-like-channels-export-import",
  1974. width: 800,
  1975. height: 600,
  1976. // try to compress the data if possible
  1977. exportData: async () => await compressionSupported()
  1978. ? await UserUtils.compress(JSON.stringify(autoLikeStore.getData()), compressionFormat, "string")
  1979. : JSON.stringify(autoLikeStore.getData()),
  1980. // copy plain when shift-clicking the copy button
  1981. exportDataSpecial: () => JSON.stringify(autoLikeStore.getData()),
  1982. async onImport(data) {
  1983. try {
  1984. const parsed = await tryToDecompressAndParse(data);
  1985. log("Trying to import auto-like data:", parsed);
  1986. if (!parsed || typeof parsed !== "object")
  1987. return await showPrompt({ type: "alert", message: t("import_error_invalid") });
  1988. if (!parsed.channels || typeof parsed.channels !== "object" || Object.keys(parsed.channels).length === 0)
  1989. return await showPrompt({ type: "alert", message: t("import_error_no_data") });
  1990. await autoLikeStore.setData(parsed);
  1991. emitSiteEvent("autoLikeChannelsUpdated");
  1992. showToast({ message: t("import_success") });
  1993. autoLikeExImDialog === null || autoLikeExImDialog === void 0 ? void 0 : autoLikeExImDialog.unmount();
  1994. }
  1995. catch (err) {
  1996. error("Couldn't import auto-like channels data:", err);
  1997. }
  1998. },
  1999. title: () => t("auto_like_export_import_title"),
  2000. descImport: () => t("auto_like_import_desc"),
  2001. descExport: () => t("auto_like_export_desc"),
  2002. });
  2003. }
  2004. return autoLikeDialog;
  2005. }
  2006. //#region header
  2007. async function renderHeader$4() {
  2008. const headerEl = document.createElement("h2");
  2009. headerEl.classList.add("bytm-dialog-title");
  2010. headerEl.role = "heading";
  2011. headerEl.ariaLevel = "1";
  2012. headerEl.tabIndex = 0;
  2013. headerEl.textContent = headerEl.ariaLabel = t("auto_like_channels_dialog_title");
  2014. return headerEl;
  2015. }
  2016. //#region body
  2017. async function renderBody$4() {
  2018. const contElem = document.createElement("div");
  2019. const descriptionEl = document.createElement("p");
  2020. descriptionEl.classList.add("bytm-auto-like-channels-desc");
  2021. descriptionEl.textContent = t("auto_like_channels_dialog_desc");
  2022. descriptionEl.tabIndex = 0;
  2023. contElem.appendChild(descriptionEl);
  2024. const searchCont = document.createElement("div");
  2025. searchCont.classList.add("bytm-auto-like-channels-search-cont");
  2026. contElem.appendChild(searchCont);
  2027. const searchbarEl = document.createElement("input");
  2028. searchbarEl.classList.add("bytm-auto-like-channels-searchbar");
  2029. searchbarEl.placeholder = t("search_placeholder");
  2030. searchbarEl.type = searchbarEl.role = "search";
  2031. searchbarEl.tabIndex = 0;
  2032. searchbarEl.autofocus = true;
  2033. searchbarEl.autocomplete = searchbarEl.autocapitalize = "off";
  2034. searchbarEl.spellcheck = false;
  2035. searchbarEl.addEventListener("input", () => {
  2036. var _a, _b, _c, _d, _e, _f;
  2037. const searchVal = searchbarEl.value.trim().toLowerCase();
  2038. const rows = document.querySelectorAll(".bytm-auto-like-channel-row");
  2039. for (const row of rows) {
  2040. 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().replace(/\s/g, "")) !== null && _c !== void 0 ? _c : "";
  2041. const id = (_f = (_e = (_d = row.querySelector(".bytm-auto-like-channel-id")) === null || _d === void 0 ? void 0 : _d.textContent) === null || _e === void 0 ? void 0 : _e.trim()) !== null && _f !== void 0 ? _f : "";
  2042. row.classList.toggle("hidden", !name.includes(searchVal) && !(id.startsWith("@") ? id : "").includes(searchVal));
  2043. }
  2044. });
  2045. searchCont.appendChild(searchbarEl);
  2046. const searchClearEl = document.createElement("button");
  2047. searchClearEl.classList.add("bytm-auto-like-channels-search-clear");
  2048. searchClearEl.title = searchClearEl.ariaLabel = t("search_clear");
  2049. searchClearEl.tabIndex = 0;
  2050. searchClearEl.innerText = "×";
  2051. onInteraction(searchClearEl, () => {
  2052. searchbarEl.value = "";
  2053. searchbarEl.dispatchEvent(new Event("input"));
  2054. });
  2055. searchCont.appendChild(searchClearEl);
  2056. const channelListCont = document.createElement("div");
  2057. channelListCont.id = "bytm-auto-like-channels-list";
  2058. const setChannelEnabled = UserUtils.debounce((id, enabled) => {
  2059. autoLikeStore.setData({
  2060. channels: autoLikeStore.getData().channels
  2061. .map((ch) => ch.id === id ? Object.assign(Object.assign({}, ch), { enabled }) : ch),
  2062. });
  2063. }, 250);
  2064. const sortedChannels = autoLikeStore
  2065. .getData().channels
  2066. .sort((a, b) => a.name.localeCompare(b.name));
  2067. for (const { name: chanName, id: chanId, enabled } of sortedChannels) {
  2068. const rowElem = document.createElement("div");
  2069. rowElem.classList.add("bytm-auto-like-channel-row");
  2070. const leftCont = document.createElement("div");
  2071. leftCont.classList.add("bytm-auto-like-channel-row-left-cont");
  2072. const nameLabelEl = document.createElement("label");
  2073. nameLabelEl.ariaLabel = nameLabelEl.title = chanName;
  2074. nameLabelEl.htmlFor = `bytm-auto-like-channel-list-toggle-${chanId}`;
  2075. nameLabelEl.classList.add("bytm-auto-like-channel-name-label");
  2076. const nameElem = document.createElement("a");
  2077. nameElem.classList.add("bytm-auto-like-channel-name", "bytm-link");
  2078. nameElem.ariaLabel = nameElem.textContent = chanName;
  2079. nameElem.href = (!chanId.startsWith("@") && getDomain() === "ytm")
  2080. ? `https://music.youtube.com/channel/${chanId}`
  2081. : `https://youtube.com/${chanId.startsWith("@") ? chanId : `channel/${chanId}`}`;
  2082. nameElem.target = "_blank";
  2083. nameElem.rel = "noopener noreferrer";
  2084. nameElem.tabIndex = 0;
  2085. const idElem = document.createElement("span");
  2086. idElem.classList.add("bytm-auto-like-channel-id");
  2087. idElem.textContent = idElem.title = chanId;
  2088. nameLabelEl.appendChild(nameElem);
  2089. nameLabelEl.appendChild(idElem);
  2090. const toggleElem = await createToggleInput({
  2091. id: `auto-like-channel-list-${chanId}`,
  2092. labelPos: "off",
  2093. initialValue: enabled,
  2094. onChange: (en) => setChannelEnabled(chanId, en),
  2095. });
  2096. toggleElem.classList.add("bytm-auto-like-channel-toggle");
  2097. toggleElem.title = toggleElem.ariaLabel = t("auto_like_channel_toggle_tooltip", chanName);
  2098. const btnCont = document.createElement("div");
  2099. btnCont.classList.add("bytm-auto-like-channel-row-btn-cont");
  2100. const editBtn = await createCircularBtn({
  2101. resourceName: "icon-edit",
  2102. title: t("edit_entry"),
  2103. async onClick() {
  2104. var _a, _b, _c;
  2105. const newNamePr = (_a = (await showPrompt({ type: "prompt", message: t("auto_like_channel_edit_name_prompt"), defaultValue: chanName }))) === null || _a === void 0 ? void 0 : _a.trim();
  2106. if (!newNamePr || newNamePr.length === 0)
  2107. return;
  2108. const newName = newNamePr.length > 0 ? newNamePr : chanName;
  2109. const newIdPr = (_b = (await showPrompt({ type: "prompt", message: t("auto_like_channel_edit_id_prompt"), defaultValue: chanId }))) === null || _b === void 0 ? void 0 : _b.trim();
  2110. if (!newIdPr || newIdPr.length === 0)
  2111. return;
  2112. const newId = newIdPr.length > 0 ? (_c = getChannelIdFromPrompt(newIdPr)) !== null && _c !== void 0 ? _c : chanId : chanId;
  2113. await autoLikeStore.setData({
  2114. channels: autoLikeStore.getData().channels
  2115. .map((ch) => ch.id === chanId ? Object.assign(Object.assign({}, ch), { name: newName, id: newId }) : ch),
  2116. });
  2117. emitSiteEvent("autoLikeChannelsUpdated");
  2118. },
  2119. });
  2120. btnCont.appendChild(editBtn);
  2121. const removeBtn = await createCircularBtn({
  2122. resourceName: "icon-delete",
  2123. title: t("remove_entry"),
  2124. onClick() {
  2125. autoLikeStore.setData({
  2126. channels: autoLikeStore.getData().channels.filter((ch) => ch.id !== chanId),
  2127. });
  2128. rowElem.remove();
  2129. emitSiteEvent("autoLikeChannelsUpdated");
  2130. },
  2131. });
  2132. btnCont.appendChild(removeBtn);
  2133. leftCont.appendChild(toggleElem);
  2134. leftCont.appendChild(nameLabelEl);
  2135. rowElem.appendChild(leftCont);
  2136. rowElem.appendChild(btnCont);
  2137. channelListCont.appendChild(rowElem);
  2138. }
  2139. contElem.appendChild(channelListCont);
  2140. return contElem;
  2141. }
  2142. //#region footer
  2143. function renderFooter$1() {
  2144. const wrapperEl = document.createElement("div");
  2145. wrapperEl.classList.add("bytm-auto-like-channels-footer-wrapper");
  2146. const addNewBtnElem = document.createElement("button");
  2147. addNewBtnElem.classList.add("bytm-btn");
  2148. addNewBtnElem.textContent = t("new_entry");
  2149. addNewBtnElem.ariaLabel = addNewBtnElem.title = t("new_entry_tooltip");
  2150. wrapperEl.appendChild(addNewBtnElem);
  2151. const importExportBtnElem = document.createElement("button");
  2152. importExportBtnElem.classList.add("bytm-btn");
  2153. importExportBtnElem.textContent = t("export_import");
  2154. importExportBtnElem.ariaLabel = importExportBtnElem.title = t("auto_like_export_or_import_tooltip");
  2155. wrapperEl.appendChild(importExportBtnElem);
  2156. onInteraction(addNewBtnElem, addAutoLikeEntryPrompts);
  2157. onInteraction(importExportBtnElem, openImportExportAutoLikeChannelsDialog);
  2158. return wrapperEl;
  2159. }
  2160. async function openImportExportAutoLikeChannelsDialog() {
  2161. await (autoLikeExImDialog === null || autoLikeExImDialog === void 0 ? void 0 : autoLikeExImDialog.open());
  2162. }
  2163. //#region add prompt
  2164. async function addAutoLikeEntryPrompts() {
  2165. var _a, _b, _c;
  2166. await autoLikeStore.loadData();
  2167. const idPrompt = (_a = (await showPrompt({ type: "prompt", message: t("add_auto_like_channel_id_prompt") }))) === null || _a === void 0 ? void 0 : _a.trim();
  2168. if (!idPrompt)
  2169. return;
  2170. const id = (_b = parseChannelIdFromUrl(idPrompt)) !== null && _b !== void 0 ? _b : (isValidChannelId(idPrompt) ? idPrompt : null);
  2171. if (!id || id.length <= 0)
  2172. return await showPrompt({ type: "alert", message: t("add_auto_like_channel_invalid_id") });
  2173. let overwriteName = false;
  2174. const hasChannelEntry = autoLikeStore.getData().channels.find((ch) => ch.id === id);
  2175. if (hasChannelEntry) {
  2176. if (!await showPrompt({ type: "confirm", message: t("add_auto_like_channel_already_exists_prompt_new_name") }))
  2177. return;
  2178. overwriteName = true;
  2179. }
  2180. const name = (_c = (await showPrompt({ type: "prompt", message: t("add_auto_like_channel_name_prompt"), defaultValue: hasChannelEntry === null || hasChannelEntry === void 0 ? void 0 : hasChannelEntry.name }))) === null || _c === void 0 ? void 0 : _c.trim();
  2181. if (!name || name.length === 0)
  2182. return;
  2183. await autoLikeStore.setData(overwriteName
  2184. ? {
  2185. channels: autoLikeStore.getData().channels
  2186. .map((ch) => ch.id === id ? Object.assign(Object.assign({}, ch), { name }) : ch),
  2187. }
  2188. : {
  2189. channels: [
  2190. ...autoLikeStore.getData().channels,
  2191. { id, name, enabled: true },
  2192. ],
  2193. });
  2194. emitSiteEvent("autoLikeChannelsUpdated");
  2195. const unsub = autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.on("clear", async () => {
  2196. unsub === null || unsub === void 0 ? void 0 : unsub();
  2197. await (autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.open());
  2198. });
  2199. autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.unmount();
  2200. }
  2201. function getChannelIdFromPrompt(promptStr) {
  2202. const isId = promptStr.match(/^@?.+$/);
  2203. const isUrl = promptStr.match(/^(?:https?:\/\/)?(?:www\.)?(?:music\.)?youtube\.com\/(?:channel\/|@)([a-zA-Z0-9_-]+)/);
  2204. const id = ((isId === null || isId === void 0 ? void 0 : isId[0]) || (isUrl === null || isUrl === void 0 ? void 0 : isUrl[1]) || "").trim();
  2205. return id.length > 0 ? id : null;
  2206. }const inputIgnoreTagNames = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A"];
  2207. //#region arrow key skip
  2208. let sliderEl;
  2209. async function initArrowKeySkip() {
  2210. addSelectorListener("playerBarRightControls", "tp-yt-paper-slider#volume-slider", {
  2211. listener: (el) => sliderEl = el,
  2212. });
  2213. document.addEventListener("keydown", (evt) => {
  2214. var _a, _b, _c, _d, _e, _f;
  2215. if (!getFeature("arrowKeySupport"))
  2216. return;
  2217. if (["ArrowUp", "ArrowDown"].includes(evt.code) && getDomain() === "ytm")
  2218. return handleVolumeKeyPress(evt);
  2219. if (!["ArrowLeft", "ArrowRight"].includes(evt.code))
  2220. return;
  2221. const allowedClasses = ["bytm-generic-btn", "yt-spec-button-shape-next"];
  2222. // discard the event when a (text) input is currently active, like when editing a playlist
  2223. 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 : ""))
  2224. && !allowedClasses.some((cls) => { var _a; return (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.classList.contains(cls); }))
  2225. 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`);
  2226. evt.preventDefault();
  2227. evt.stopImmediatePropagation();
  2228. let skipBy = (_f = getFeature("arrowKeySkipBy")) !== null && _f !== void 0 ? _f : featInfo.arrowKeySkipBy.default;
  2229. if (evt.code === "ArrowLeft")
  2230. skipBy *= -1;
  2231. log(`Captured arrow key '${evt.code}' - skipping by ${skipBy} seconds`);
  2232. const vidElem = getVideoElement();
  2233. if (vidElem && vidElem.readyState > 0)
  2234. vidElem.currentTime = UserUtils.clamp(vidElem.currentTime + skipBy, 0, vidElem.duration);
  2235. });
  2236. log("Added arrow key press listener");
  2237. }
  2238. function handleVolumeKeyPress(evt) {
  2239. var _a;
  2240. evt.preventDefault();
  2241. evt.stopImmediatePropagation();
  2242. if (!getVideoElement())
  2243. return warn("Couldn't find video element, so the keypress is ignored");
  2244. if (!sliderEl)
  2245. return warn("Couldn't find volume slider element, so the keypress is ignored");
  2246. const step = Number(sliderEl.step);
  2247. const newVol = UserUtils.clamp(Number(sliderEl.value)
  2248. + (evt.code === "ArrowUp" ? 1 : -1)
  2249. * UserUtils.clamp(((_a = getFeature("arrowKeyVolumeStep")) !== null && _a !== void 0 ? _a : featInfo.arrowKeyVolumeStep.default), isNaN(step) ? 5 : step, 100), 0, 100);
  2250. if (newVol !== Number(sliderEl.value)) {
  2251. sliderEl.value = String(newVol);
  2252. sliderEl.dispatchEvent(new Event("change", { bubbles: true }));
  2253. log(`Captured key '${evt.code}' - changed volume to ${newVol}%`);
  2254. }
  2255. }
  2256. //#region frame skip
  2257. /** Initializes the feature that lets users skip by a frame with the period and comma keys while the video is paused */
  2258. async function initFrameSkip() {
  2259. document.addEventListener("keydown", async (evt) => {
  2260. if (!getFeature("frameSkip"))
  2261. return;
  2262. if (!["Comma", "Period"].includes(evt.code))
  2263. return;
  2264. const vid = getVideoElement();
  2265. if (!vid || vid.readyState === 0)
  2266. return warn("Could not find video element or it hasn't loaded yet, so the keypress is ignored");
  2267. if (!getFeature("frameSkipWhilePlaying") && (vid.playbackRate === 0 || !vid.paused))
  2268. return;
  2269. evt.preventDefault();
  2270. evt.stopImmediatePropagation();
  2271. const newTime = vid.currentTime + getFeature("frameSkipAmount") * (evt.code === "Comma" ? -1 : 1);
  2272. vid.currentTime = UserUtils.clamp(newTime, 0, vid.duration);
  2273. log(`Captured key '${evt.code}' and skipped to ${Math.floor(newTime / 60)}m ${(newTime % 60).toFixed(1)}s (${Math.floor(newTime * 1000 % 1000)}ms)`);
  2274. });
  2275. log("Added frame skip key press listener");
  2276. }
  2277. //#region num keys skip
  2278. const numKeysIgnoreTagNames = [...inputIgnoreTagNames];
  2279. /** Adds the ability to skip to a certain time in the video by pressing a number key (0-9) */
  2280. async function initNumKeysSkip() {
  2281. document.addEventListener("keydown", (e) => {
  2282. var _a, _b;
  2283. if (!getFeature("numKeysSkipToTime"))
  2284. return;
  2285. if (!e.key.trim().match(/^[0-9]$/))
  2286. return;
  2287. // 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
  2288. const ignoreElement = numKeysIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "");
  2289. if ((document.activeElement !== document.body && ignoreElement) || ignoreElement)
  2290. return info("Captured valid key to skip video to, but ignored it since this element is currently active:", document.activeElement);
  2291. const vidElem = getVideoElement();
  2292. if (!vidElem || vidElem.readyState === 0)
  2293. return warn("Could not find video element, so the keypress is ignored");
  2294. const newVidTime = vidElem.duration / (10 / Number(e.key));
  2295. if (!isNaN(newVidTime)) {
  2296. log(`Captured number key [${e.key}], skipping to ${Math.floor(newVidTime / 60)}m ${(newVidTime % 60).toFixed(1)}s`);
  2297. vidElem.currentTime = newVidTime;
  2298. }
  2299. });
  2300. log("Added number key press listener");
  2301. }
  2302. //#region auto-like vids
  2303. let canCompress$1 = false;
  2304. /** DataStore instance for all auto-liked channels */
  2305. const autoLikeStore = new UserUtils.DataStore({
  2306. id: "bytm-auto-like-channels",
  2307. formatVersion: 2,
  2308. defaultData: {
  2309. channels: [],
  2310. },
  2311. encodeData: (data) => canCompress$1 ? UserUtils.compress(data, compressionFormat, "string") : data,
  2312. decodeData: (data) => canCompress$1 ? UserUtils.decompress(data, compressionFormat, "string") : data,
  2313. migrations: {
  2314. // 1 -> 2 (v2.1-pre) - add @ prefix to channel IDs if missing
  2315. 2: (oldData) => ({
  2316. channels: oldData.channels.map((ch) => (Object.assign(Object.assign({}, ch), { id: isValidChannelId(ch.id.trim())
  2317. ? ch.id.trim()
  2318. : `@${ch.id.trim()}` }))),
  2319. }),
  2320. },
  2321. });
  2322. let autoLikeStoreLoaded = false;
  2323. /** Inits the auto-like DataStore instance */
  2324. async function initAutoLikeStore() {
  2325. if (autoLikeStoreLoaded)
  2326. return;
  2327. autoLikeStoreLoaded = true;
  2328. return autoLikeStore.loadData();
  2329. }
  2330. /** Initializes the auto-like feature */
  2331. async function initAutoLike() {
  2332. try {
  2333. canCompress$1 = await compressionSupported();
  2334. await initAutoLikeStore();
  2335. //#SECTION ytm
  2336. if (getDomain() === "ytm") {
  2337. let timeout;
  2338. siteEvents.on("songTitleChanged", () => {
  2339. var _a;
  2340. const autoLikeTimeoutMs = ((_a = getFeature("autoLikeTimeout")) !== null && _a !== void 0 ? _a : 5) * 1000;
  2341. timeout && clearTimeout(timeout);
  2342. const ytmTryAutoLike = () => {
  2343. const artistEls = document.querySelectorAll("ytmusic-player-bar .content-info-wrapper .subtitle a.yt-formatted-string[href]");
  2344. const channelIds = [...artistEls].map(a => a.href.split("/").pop()).filter(a => typeof a === "string");
  2345. const likeChan = autoLikeStore.getData().channels.find((ch) => channelIds.includes(ch.id));
  2346. if (!likeChan || !likeChan.enabled)
  2347. return;
  2348. if (artistEls.length === 0)
  2349. return error("Couldn't auto-like channel because the artist element couldn't be found");
  2350. const { likeBtn, likeState } = getLikeDislikeBtns();
  2351. if (!likeBtn)
  2352. return error("Couldn't auto-like channel because the like button couldn't be found");
  2353. if (likeState !== "LIKE") {
  2354. likeBtn.click();
  2355. getFeature("autoLikeShowToast") && showIconToast({
  2356. message: t(`auto_liked_a_channels_${getCurrentMediaType()}`, likeChan.name),
  2357. subtitle: t("auto_like_click_to_configure"),
  2358. icon: "icon-auto_like",
  2359. onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()),
  2360. }).catch(e => error("Error while showing auto-like toast:", e));
  2361. log(`Auto-liked ${getCurrentMediaType()} from channel '${likeChan.name}' (${likeChan.id})`);
  2362. }
  2363. };
  2364. timeout = setTimeout(ytmTryAutoLike, autoLikeTimeoutMs);
  2365. siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytmTryAutoLike, autoLikeTimeoutMs));
  2366. });
  2367. const recreateBtn = (headerCont, chanId) => {
  2368. var _a, _b, _c, _d, _e, _f;
  2369. const titleCont = headerCont.querySelector("ytd-channel-name #container, yt-dynamic-text-view-model.page-header-view-model-wiz__page-header-title, ytmusic-immersive-header-renderer .ytmusic-immersive-header-renderer yt-formatted-string.title");
  2370. if (!titleCont)
  2371. return;
  2372. const checkBtn = () => setTimeout(() => {
  2373. if (!document.querySelector(".bytm-auto-like-toggle-btn"))
  2374. recreateBtn(headerCont, chanId);
  2375. }, 250);
  2376. const chanName = (_b = (_a = titleCont.querySelector("yt-formatted-string, span.yt-core-attributed-string")) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : null;
  2377. log("Re-rendering auto-like toggle button for channel", chanName, "with ID", chanId);
  2378. const buttonsCont = headerCont.querySelector(".buttons");
  2379. if (buttonsCont) {
  2380. const lastBtn = buttonsCont.querySelector("ytmusic-subscribe-button-renderer");
  2381. const chanName = (_d = (_c = document.querySelector("ytmusic-immersive-header-renderer .content-container yt-formatted-string[role=\"heading\"]")) === null || _c === void 0 ? void 0 : _c.textContent) !== null && _d !== void 0 ? _d : null;
  2382. lastBtn && addAutoLikeToggleBtn(lastBtn, chanId, chanName).then(checkBtn);
  2383. }
  2384. else {
  2385. // some channels don't have a subscribe button and instead only have a "share" button for some bullshit reason
  2386. const shareBtnEl = headerCont.querySelector("ytmusic-menu-renderer #top-level-buttons yt-button-renderer:last-of-type");
  2387. const chanName = (_f = (_e = headerCont.querySelector("ytmusic-visual-header-renderer .content-container h2 yt-formatted-string")) === null || _e === void 0 ? void 0 : _e.textContent) !== null && _f !== void 0 ? _f : null;
  2388. shareBtnEl && chanName && addAutoLikeToggleBtn(shareBtnEl, chanId, chanName).then(checkBtn);
  2389. }
  2390. };
  2391. siteEvents.on("pathChanged", (path) => {
  2392. if (getFeature("autoLikeChannelToggleBtn") && path.match(/\/channel\/.+/)) {
  2393. const chanId = getCurrentChannelId();
  2394. if (!chanId)
  2395. return error("Couldn't extract channel ID from URL");
  2396. document.querySelectorAll(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
  2397. addSelectorListener("browseResponse", "ytmusic-browse-response #header.ytmusic-browse-response", {
  2398. listener: (el) => recreateBtn(el, chanId),
  2399. });
  2400. }
  2401. });
  2402. }
  2403. //#SECTION yt
  2404. else if (getDomain() === "yt") {
  2405. addStyleFromResource("css-auto_like");
  2406. let timeout;
  2407. siteEvents.on("watchIdChanged", () => {
  2408. var _a;
  2409. const autoLikeTimeoutMs = ((_a = getFeature("autoLikeTimeout")) !== null && _a !== void 0 ? _a : 5) * 1000;
  2410. timeout && clearTimeout(timeout);
  2411. if (!location.pathname.startsWith("/watch"))
  2412. return;
  2413. const ytTryAutoLike = () => {
  2414. addSelectorListener("ytWatchMetadata", "#owner ytd-channel-name yt-formatted-string a", {
  2415. listener(chanElem) {
  2416. var _a, _b;
  2417. const chanElemId = (_b = (_a = chanElem.href.split("/").pop()) === null || _a === void 0 ? void 0 : _a.split("/")[0]) !== null && _b !== void 0 ? _b : null;
  2418. const likeChan = autoLikeStore.getData().channels.find((ch) => ch.id === chanElemId);
  2419. if (!likeChan || !likeChan.enabled)
  2420. return;
  2421. addSelectorListener("ytWatchMetadata", "#actions ytd-menu-renderer like-button-view-model button", {
  2422. listener(likeBtn) {
  2423. if (likeBtn.getAttribute("aria-pressed") !== "true") {
  2424. likeBtn.click();
  2425. getFeature("autoLikeShowToast") && showIconToast({
  2426. message: t("auto_liked_a_channels_video", likeChan.name),
  2427. subtitle: t("auto_like_click_to_configure"),
  2428. icon: "icon-auto_like",
  2429. onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()),
  2430. }).catch(e => error("Error while showing auto-like toast:", e));
  2431. log(`Auto-liked video from channel '${likeChan.name}' (${likeChan.id})`);
  2432. }
  2433. }
  2434. });
  2435. }
  2436. });
  2437. };
  2438. siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytTryAutoLike, autoLikeTimeoutMs));
  2439. timeout = setTimeout(ytTryAutoLike, autoLikeTimeoutMs);
  2440. });
  2441. siteEvents.on("pathChanged", (path) => {
  2442. if (path.match(/(\/?@|\/?channel\/)\S+/)) {
  2443. const chanId = getCurrentChannelId();
  2444. if (!chanId)
  2445. return error("Couldn't extract channel ID from URL");
  2446. document.querySelectorAll(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
  2447. const recreateBtn = (headerCont) => {
  2448. var _a, _b;
  2449. const titleCont = headerCont.querySelector("ytd-channel-name #container, yt-dynamic-text-view-model.page-header-view-model-wiz__page-header-title");
  2450. if (!titleCont)
  2451. return;
  2452. const checkBtn = () => setTimeout(() => {
  2453. if (!document.querySelector(".bytm-auto-like-toggle-btn"))
  2454. recreateBtn(headerCont);
  2455. }, 350);
  2456. const chanName = (_b = (_a = titleCont.querySelector("yt-formatted-string, span.yt-core-attributed-string")) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : null;
  2457. log("Re-rendering auto-like toggle button for channel", chanName, "with ID", chanId);
  2458. const buttonsCont = headerCont.querySelector("#inner-header-container #buttons, yt-flexible-actions-view-model");
  2459. if (buttonsCont) {
  2460. addSelectorListener("ytAppHeader", "#channel-header-container #other-buttons, yt-flexible-actions-view-model .yt-flexible-actions-view-model-wiz__action", {
  2461. listener: (otherBtns) => addAutoLikeToggleBtn(otherBtns, chanId, chanName, ["left-margin", "right-margin"]).then(checkBtn),
  2462. });
  2463. }
  2464. else if (titleCont)
  2465. addAutoLikeToggleBtn(titleCont, chanId, chanName).then(checkBtn);
  2466. };
  2467. addSelectorListener("ytAppHeader", "#channel-header-container, #page-header", {
  2468. listener: recreateBtn,
  2469. });
  2470. }
  2471. });
  2472. }
  2473. log("Initialized auto-like channels feature");
  2474. }
  2475. catch (err) {
  2476. error("Error while auto-liking channel:", err);
  2477. }
  2478. }
  2479. //#SECTION toggle btn
  2480. /** Adds a toggle button to enable or disable auto-liking videos from a channel */
  2481. async function addAutoLikeToggleBtn(siblingEl, channelId, channelName, extraClasses) {
  2482. var _a;
  2483. const chan = autoLikeStore.getData().channels.find((ch) => ch.id === channelId);
  2484. log(`Adding auto-like toggle button for channel with ID '${channelId}' - current state:`, chan);
  2485. siteEvents.on("autoLikeChannelsUpdated", () => {
  2486. var _a, _b;
  2487. const buttonEl = document.querySelector(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`);
  2488. if (!buttonEl)
  2489. return warn("Couldn't find auto-like toggle button for channel ID:", channelId);
  2490. 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;
  2491. if (enabled)
  2492. buttonEl.classList.add("toggled");
  2493. else
  2494. buttonEl.classList.remove("toggled");
  2495. });
  2496. const buttonEl = await createLongBtn({
  2497. resourceName: `icon-auto_like${(chan === null || chan === void 0 ? void 0 : chan.enabled) ? "_enabled" : ""}`,
  2498. text: t("auto_like"),
  2499. title: t(`auto_like_button_tooltip${(chan === null || chan === void 0 ? void 0 : chan.enabled) ? "_enabled" : "_disabled"}`),
  2500. toggle: true,
  2501. toggleInitialState: (_a = chan === null || chan === void 0 ? void 0 : chan.enabled) !== null && _a !== void 0 ? _a : false,
  2502. togglePredicate(e) {
  2503. e.shiftKey && getAutoLikeDialog().then((dlg) => dlg.open());
  2504. return !e.shiftKey;
  2505. },
  2506. async onToggle(toggled) {
  2507. var _a;
  2508. try {
  2509. await autoLikeStore.loadData();
  2510. buttonEl.title = buttonEl.ariaLabel = t(`auto_like_button_tooltip${toggled ? "_enabled" : "_disabled"}`);
  2511. const chanId = sanitizeChannelId((_a = buttonEl.dataset.channelId) !== null && _a !== void 0 ? _a : channelId);
  2512. const imgEl = buttonEl.querySelector(".bytm-generic-btn-img");
  2513. imgEl && setInnerHtml(imgEl, await resourceAsString(`icon-auto_like${toggled ? "_enabled" : ""}`));
  2514. if (autoLikeStore.getData().channels.find((ch) => ch.id === chanId) === undefined) {
  2515. await autoLikeStore.setData({
  2516. channels: [
  2517. ...autoLikeStore.getData().channels,
  2518. { id: chanId, name: channelName !== null && channelName !== void 0 ? channelName : "", enabled: toggled },
  2519. ],
  2520. });
  2521. }
  2522. else {
  2523. await autoLikeStore.setData({
  2524. channels: autoLikeStore.getData().channels
  2525. .map((ch) => ch.id === chanId ? Object.assign(Object.assign({}, ch), { enabled: toggled }) : ch),
  2526. });
  2527. }
  2528. emitSiteEvent("autoLikeChannelsUpdated");
  2529. showIconToast({
  2530. message: toggled ? t("auto_like_enabled_toast") : t("auto_like_disabled_toast"),
  2531. subtitle: t("auto_like_click_to_configure"),
  2532. icon: `icon-auto_like${toggled ? "_enabled" : ""}`,
  2533. onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()),
  2534. }).catch(e => error("Error while showing auto-like toast:", e));
  2535. log(`Toggled auto-like for channel '${channelName}' (ID: '${chanId}') to ${toggled ? "enabled" : "disabled"}`);
  2536. }
  2537. catch (err) {
  2538. error("Error while toggling auto-like channel:", err);
  2539. }
  2540. }
  2541. });
  2542. buttonEl.classList.add(...["bytm-auto-like-toggle-btn", ...(extraClasses !== null && extraClasses !== void 0 ? extraClasses : [])]);
  2543. buttonEl.dataset.channelId = channelId;
  2544. siblingEl.insertAdjacentElement("afterend", createRipple(buttonEl));
  2545. siteEvents.on("autoLikeChannelsUpdated", async () => {
  2546. var _a, _b;
  2547. const buttonEl = document.querySelector(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`);
  2548. if (!buttonEl)
  2549. return;
  2550. 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;
  2551. if (enabled)
  2552. buttonEl.classList.add("toggled");
  2553. else
  2554. buttonEl.classList.remove("toggled");
  2555. const imgEl = buttonEl.querySelector(".bytm-generic-btn-img");
  2556. imgEl && setInnerHtml(imgEl, await resourceAsString(`icon-auto_like${enabled ? "_enabled" : ""}`));
  2557. });
  2558. }class MarkdownDialog extends BytmDialog {
  2559. constructor(options) {
  2560. super(Object.assign(Object.assign({}, options), { id: `md-${options.id}`, renderBody: () => this.renderBody() }));
  2561. Object.defineProperty(this, "opts", {
  2562. enumerable: true,
  2563. configurable: true,
  2564. writable: true,
  2565. value: void 0
  2566. });
  2567. this.opts = options;
  2568. }
  2569. /** Parses the passed markdown string (supports GitHub flavor and HTML mixins) and returns it as an HTML string */
  2570. static async parseMd(md) {
  2571. return await marked.marked.parse(md, {
  2572. async: true,
  2573. gfm: true,
  2574. breaks: true,
  2575. });
  2576. }
  2577. /** Renders the dialog body elements from a markdown string using what's set in `this.opts.body` */
  2578. async renderBody() {
  2579. const bodyEl = document.createElement("div");
  2580. bodyEl.classList.add("bytm-md-dialog-body");
  2581. const mdCont = await UserUtils.consumeStringGen(this.opts.body);
  2582. const markdownEl = document.createElement("div");
  2583. markdownEl.classList.add("bytm-markdown-dialog-content", "bytm-markdown-container");
  2584. markdownEl.tabIndex = 0;
  2585. setInnerHtml(markdownEl, await MarkdownDialog.parseMd(mdCont));
  2586. bodyEl.appendChild(markdownEl);
  2587. return bodyEl;
  2588. }
  2589. }//#region logging fns
  2590. let curLogLevel = LogLevel.Info;
  2591. /** Common prefix to be able to tell logged messages apart and filter them in devtools */
  2592. const consPrefix = `[${scriptInfo.name}]`;
  2593. const consPrefixDbg = `[${scriptInfo.name}/#DEBUG]`;
  2594. /** Sets the current log level. 0 = Debug, 1 = Info */
  2595. function setLogLevel(level) {
  2596. curLogLevel = level;
  2597. setGlobalProp("logLevel", level);
  2598. if (curLogLevel !== level)
  2599. log("Set the log level to", LogLevel[level]);
  2600. }
  2601. /** 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 */
  2602. function getLogLevel(args) {
  2603. const minLogLvl = 0, maxLogLvl = 1;
  2604. if (typeof args.at(-1) === "number")
  2605. return UserUtils.clamp(args.splice(args.length - 1)[0], minLogLvl, maxLogLvl);
  2606. return LogLevel.Debug;
  2607. }
  2608. /**
  2609. * Logs all passed values to the console, as long as the log level is sufficient.
  2610. * @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.
  2611. */
  2612. function log(...args) {
  2613. if (curLogLevel <= getLogLevel(args))
  2614. console.log(consPrefix, ...args);
  2615. }
  2616. /**
  2617. * Logs all passed values to the console as info, as long as the log level is sufficient.
  2618. * @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.
  2619. */
  2620. function info(...args) {
  2621. if (curLogLevel <= getLogLevel(args))
  2622. console.info(consPrefix, ...args);
  2623. }
  2624. /** Logs all passed values to the console as a warning, no matter the log level. */
  2625. function warn(...args) {
  2626. console.warn(consPrefix, ...args);
  2627. }
  2628. const showErrToast = UserUtils.debounce((errName, ...args) => showIconToast({
  2629. message: t("generic_error_toast_encountered_error_type", errName),
  2630. subtitle: t("generic_error_toast_click_for_details"),
  2631. icon: "icon-error",
  2632. iconFill: "var(--bytm-error-col)",
  2633. onClick: () => getErrorDialog(errName, Array.isArray(args) ? args : []).open(),
  2634. }), 1000);
  2635. /** Logs all passed values to the console as an error, no matter the log level. */
  2636. function error(...args) {
  2637. var _a, _b;
  2638. console.error(consPrefix, ...args);
  2639. getFeature("showToastOnGenericError") && showErrToast((_b = (_a = args.find(a => a instanceof Error)) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : t("error"), ...args);
  2640. }
  2641. /** Logs all passed values to the console with a debug-specific prefix */
  2642. function dbg(...args) {
  2643. console.log(consPrefixDbg, ...args);
  2644. }
  2645. //#region error dialog
  2646. function getErrorDialog(errName, args) {
  2647. return new MarkdownDialog({
  2648. id: "generic-error",
  2649. height: 400,
  2650. width: 500,
  2651. small: true,
  2652. destroyOnClose: true,
  2653. renderHeader() {
  2654. const header = document.createElement("h2");
  2655. header.classList.add("bytm-dialog-title");
  2656. header.role = "heading";
  2657. header.ariaLevel = "1";
  2658. header.tabIndex = 0;
  2659. header.textContent = header.ariaLabel = errName;
  2660. return header;
  2661. },
  2662. body: `\
  2663. ${args.length > 0 ? args.join(" ") : t("generic_error_dialog_message")}
  2664. ${t("generic_error_dialog_open_console_note", consPrefix, pkg.bugs.url)}`,
  2665. });
  2666. }
  2667. //#region error classes
  2668. class CustomError extends Error {
  2669. constructor(name, message, opts) {
  2670. super(message, opts);
  2671. Object.defineProperty(this, "time", {
  2672. enumerable: true,
  2673. configurable: true,
  2674. writable: true,
  2675. value: void 0
  2676. });
  2677. this.name = name;
  2678. this.time = Date.now();
  2679. }
  2680. }
  2681. class LyricsError extends CustomError {
  2682. constructor(message, opts) {
  2683. super("LyricsError", message, opts);
  2684. }
  2685. }
  2686. class PluginError extends CustomError {
  2687. constructor(message, opts) {
  2688. super("PluginError", message, opts);
  2689. }
  2690. }//#region beforeunload popup
  2691. let discardBeforeUnload = false;
  2692. /** Disables the popup before leaving the site */
  2693. function enableDiscardBeforeUnload() {
  2694. discardBeforeUnload = true;
  2695. info("Disabled popup before leaving the site");
  2696. }
  2697. /** Adds a spy function into `window.__proto__.addEventListener` to selectively discard `beforeunload` event listeners before they can be called by the site */
  2698. async function initBeforeUnloadHook() {
  2699. try {
  2700. UserUtils.interceptWindowEvent("beforeunload", () => discardBeforeUnload);
  2701. }
  2702. catch (err) {
  2703. error("Error in beforeunload hook:", err);
  2704. }
  2705. }
  2706. //#region auto close toasts
  2707. /** Closes toasts after a set amount of time */
  2708. async function initAutoCloseToasts() {
  2709. const animTimeout = 300;
  2710. addSelectorListener("popupContainer", "ytmusic-notification-action-renderer", {
  2711. all: true,
  2712. continuous: true,
  2713. listener: async (toastContElems) => {
  2714. try {
  2715. for (const toastContElem of toastContElems) {
  2716. const toastElem = toastContElem.querySelector("tp-yt-paper-toast#toast");
  2717. if (!toastElem || !toastElem.hasAttribute("allow-click-through"))
  2718. continue;
  2719. if (toastElem.classList.contains("bytm-closing"))
  2720. continue;
  2721. toastElem.classList.add("bytm-closing");
  2722. const closeTimeout = Math.max(getFeature("closeToastsTimeout") * 1000 + animTimeout, animTimeout);
  2723. await UserUtils.pauseFor(closeTimeout);
  2724. toastElem.classList.remove("paper-toast-open");
  2725. toastElem.addEventListener("transitionend", () => {
  2726. toastElem.classList.remove("bytm-closing");
  2727. toastElem.style.display = "none";
  2728. clearNode(toastElem);
  2729. log(`Automatically closed toast after ${getFeature("closeToastsTimeout") * 1000}ms`);
  2730. }, { once: true });
  2731. }
  2732. }
  2733. catch (err) {
  2734. error("Error in automatic toast closing:", err);
  2735. }
  2736. },
  2737. });
  2738. log("Initialized automatic toast closing");
  2739. }
  2740. //#region auto scroll to active
  2741. let initialAutoScrollToActiveSong = true;
  2742. let prevVidMaxTime = Infinity;
  2743. let prevTime = -1;
  2744. /** Initializes the autoScrollToActiveSong feature */
  2745. async function initAutoScrollToActiveSong() {
  2746. setInterval(() => {
  2747. var _a, _b, _c, _d;
  2748. prevTime = (_b = (_a = getVideoElement()) === null || _a === void 0 ? void 0 : _a.currentTime) !== null && _b !== void 0 ? _b : -1;
  2749. prevVidMaxTime = (_d = (_c = getVideoElement()) === null || _c === void 0 ? void 0 : _c.duration) !== null && _d !== void 0 ? _d : Infinity;
  2750. }, 50);
  2751. siteEvents.on("watchIdChanged", (_, oldId) => {
  2752. if (!oldId)
  2753. return;
  2754. const isManualChange = prevTime < prevVidMaxTime - 1;
  2755. if (["videoChangeManual", "videoChangeAll"].includes(getFeature("autoScrollToActiveSongMode")) && isManualChange)
  2756. scrollToCurrentSongInQueue();
  2757. else if (["videoChangeAuto", "videoChangeAll"].includes(getFeature("autoScrollToActiveSongMode")) && !isManualChange)
  2758. scrollToCurrentSongInQueue();
  2759. });
  2760. if (getFeature("autoScrollToActiveSongMode") !== "never" && initialAutoScrollToActiveSong) {
  2761. initialAutoScrollToActiveSong = false;
  2762. scrollToCurrentSongInQueue();
  2763. }
  2764. }
  2765. /**
  2766. * Remembers the time of the last played video and resumes playback from that time.
  2767. * **Needs to be called *before* DOM is ready!**
  2768. */
  2769. async function initRememberSongTime() {
  2770. if (getFeature("rememberSongTimeSites") !== "all" && getFeature("rememberSongTimeSites") !== getDomain())
  2771. return;
  2772. const storedDataRaw = await GM.getValue("bytm-rem-songs");
  2773. if (!storedDataRaw)
  2774. await GM.setValue("bytm-rem-songs", "[]");
  2775. let remVids;
  2776. try {
  2777. remVids = JSON.parse(String(storedDataRaw !== null && storedDataRaw !== void 0 ? storedDataRaw : "[]"));
  2778. }
  2779. catch (err) {
  2780. error("Error parsing stored video time data, defaulting to empty cache:", err);
  2781. await GM.setValue("bytm-rem-songs", "[]");
  2782. remVids = [];
  2783. }
  2784. if (remVids.some(e => "watchID" in e)) {
  2785. remVids = remVids.filter(e => "id" in e);
  2786. await GM.setValue("bytm-rem-songs", JSON.stringify(remVids));
  2787. log(`Removed ${remVids.length} ${UserUtils.autoPlural("entry", remVids)} with an outdated format from the video time cache`);
  2788. }
  2789. log(`Initialized video time restoring with ${remVids.length} initial ${UserUtils.autoPlural("entry", remVids)}:`, remVids);
  2790. await remTimeRestoreTime();
  2791. try {
  2792. if (!UserUtils.isDomLoaded())
  2793. document.addEventListener("DOMContentLoaded", remTimeStartUpdateLoop);
  2794. else
  2795. remTimeStartUpdateLoop();
  2796. }
  2797. catch (err) {
  2798. error("Error in video time remembering update loop:", err);
  2799. }
  2800. }
  2801. /** Tries to restore the time of the currently playing video */
  2802. async function remTimeRestoreTime() {
  2803. const remVids = JSON.parse(await GM.getValue("bytm-rem-songs", "[]"));
  2804. if (location.pathname.startsWith("/watch")) {
  2805. const videoID = new URL(location.href).searchParams.get("v");
  2806. if (!videoID)
  2807. return;
  2808. if (initialParams.has("t"))
  2809. return info("Not restoring song time because the URL has the '&t' parameter", LogLevel.Info);
  2810. const entry = remVids.find(entry => entry.id === videoID);
  2811. if (entry) {
  2812. if (Date.now() - entry.updated > getFeature("rememberSongTimeDuration") * 1000) {
  2813. await remTimeDeleteEntry(entry.id);
  2814. return;
  2815. }
  2816. else if (isNaN(Number(entry.time)) || entry.time < 0)
  2817. return warn("Invalid time in remembered song time entry:", entry);
  2818. else {
  2819. let vidElem;
  2820. const doRestoreTime = async () => {
  2821. var _a;
  2822. if (!vidElem)
  2823. vidElem = await waitVideoElementReady();
  2824. const vidRestoreTime = entry.time - ((_a = getFeature("rememberSongTimeReduction")) !== null && _a !== void 0 ? _a : 0);
  2825. vidElem.currentTime = UserUtils.clamp(Math.max(vidRestoreTime, 0), 0, vidElem.duration);
  2826. await remTimeDeleteEntry(entry.id);
  2827. info(`Restored ${getDomain() === "ytm" ? getCurrentMediaType() : "video"} time to ${Math.floor(vidRestoreTime / 60)}m, ${(vidRestoreTime % 60).toFixed(1)}s`, LogLevel.Info);
  2828. };
  2829. if (!UserUtils.isDomLoaded())
  2830. document.addEventListener("DOMContentLoaded", doRestoreTime);
  2831. else
  2832. doRestoreTime();
  2833. }
  2834. }
  2835. }
  2836. }
  2837. let lastSongTime = -1;
  2838. let remVidCheckTimeout;
  2839. /** Only call once as this calls itself after a timeout! - Updates the currently playing video's entry in GM storage */
  2840. async function remTimeStartUpdateLoop() {
  2841. var _a, _b, _c;
  2842. const remVids = JSON.parse(await GM.getValue("bytm-rem-songs", "[]"));
  2843. if (location.pathname.startsWith("/watch")) {
  2844. const id = getWatchId();
  2845. const songTime = (_a = await getVideoTime()) !== null && _a !== void 0 ? _a : 0;
  2846. if (id && songTime !== lastSongTime) {
  2847. lastSongTime = songTime;
  2848. const paused = (_c = (_b = getVideoElement()) === null || _b === void 0 ? void 0 : _b.paused) !== null && _c !== void 0 ? _c : false;
  2849. // don't immediately update to reduce race conditions and only update if the video is playing
  2850. // also it just sounds better if the song starts at the beginning if only a couple seconds have passed
  2851. if (songTime > getFeature("rememberSongTimeMinPlayTime") && !paused) {
  2852. const entry = {
  2853. id,
  2854. time: songTime,
  2855. updated: Date.now(),
  2856. };
  2857. await remTimeUpsertEntry(entry);
  2858. }
  2859. // if the song is rewound to the beginning, update the entry accordingly
  2860. else if (!paused) {
  2861. const entry = remVids.find(entry => entry.id === id);
  2862. if (entry && songTime <= entry.time)
  2863. await remTimeUpsertEntry(Object.assign(Object.assign({}, entry), { time: songTime, updated: Date.now() }));
  2864. }
  2865. }
  2866. }
  2867. const expiredEntries = remVids.filter(entry => Date.now() - entry.updated > getFeature("rememberSongTimeDuration") * 1000);
  2868. for (const entry of expiredEntries)
  2869. await remTimeDeleteEntry(entry.id);
  2870. // for no overlapping calls and better error handling:
  2871. if (remVidCheckTimeout)
  2872. clearTimeout(remVidCheckTimeout);
  2873. remVidCheckTimeout = setTimeout(remTimeStartUpdateLoop, 500);
  2874. }
  2875. /** Updates an existing or inserts a new entry to be remembered */
  2876. async function remTimeUpsertEntry(data) {
  2877. const remVids = JSON.parse(await GM.getValue("bytm-rem-songs", "[]"));
  2878. const foundIdx = remVids.findIndex(entry => entry.id === data.id);
  2879. if (foundIdx >= 0)
  2880. remVids[foundIdx] = data;
  2881. else
  2882. remVids.push(data);
  2883. await GM.setValue("bytm-rem-songs", JSON.stringify(remVids));
  2884. }
  2885. /** Deletes an entry in the "remember cache" */
  2886. async function remTimeDeleteEntry(videoID) {
  2887. const remVids = JSON.parse(await GM.getValue("bytm-rem-songs", "[]"))
  2888. .filter(entry => entry.id !== videoID);
  2889. await GM.setValue("bytm-rem-songs", JSON.stringify(remVids));
  2890. }//#region misc
  2891. let domain;
  2892. /**
  2893. * Returns the current domain as a constant string representation
  2894. * @throws Throws if script runs on an unexpected website
  2895. */
  2896. function getDomain() {
  2897. if (domain)
  2898. return domain;
  2899. if (location.hostname.match(/^music\.youtube/))
  2900. return domain = "ytm";
  2901. else if (location.hostname.match(/youtube\./))
  2902. return domain = "yt";
  2903. else
  2904. throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
  2905. }
  2906. /** Returns a pseudo-random ID unique to each session - returns null if sessionStorage is unavailable */
  2907. function getSessionId() {
  2908. try {
  2909. if (!sessionStorageAvailable)
  2910. throw new Error("Session storage unavailable");
  2911. let sesId = window.sessionStorage.getItem("_bytm-session-id");
  2912. if (!sesId)
  2913. window.sessionStorage.setItem("_bytm-session-id", sesId = UserUtils.randomId(10, 36));
  2914. return sesId;
  2915. }
  2916. catch (err) {
  2917. warn("Couldn't get session ID, sessionStorage / cookies might be disabled:", err);
  2918. return null;
  2919. }
  2920. }
  2921. let isCompressionSupported;
  2922. /** Tests whether compression via the predefined {@linkcode compressionFormat} is supported (only on the first call, then returns the cached result) */
  2923. async function compressionSupported() {
  2924. if (typeof isCompressionSupported === "boolean")
  2925. return isCompressionSupported;
  2926. try {
  2927. await UserUtils.compress(".", compressionFormat, "string");
  2928. return isCompressionSupported = true;
  2929. }
  2930. catch (_a) {
  2931. return isCompressionSupported = false;
  2932. }
  2933. }
  2934. /** 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 */
  2935. function arrayWithSeparators(array, separator = ", ", lastSeparator) {
  2936. const arr = [...array];
  2937. if (arr.length === 0)
  2938. return "";
  2939. else if (arr.length <= 2)
  2940. return arr.join(lastSeparator);
  2941. else
  2942. return `${arr.slice(0, -1).join(separator)}${lastSeparator}${arr.at(-1)}`;
  2943. }
  2944. /** Returns the watch ID of the current video or null if not on a video page */
  2945. function getWatchId() {
  2946. const { searchParams, pathname } = new URL(location.href);
  2947. return pathname.includes("/watch") ? searchParams.get("v") : null;
  2948. }
  2949. /**
  2950. * 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`
  2951. * Returns null if the current page is not a channel page or there was an error parsing the URL
  2952. */
  2953. function getCurrentChannelId() {
  2954. return parseChannelIdFromUrl(location.href);
  2955. }
  2956. /** Returns the channel ID from a URL or null if the URL is invalid */
  2957. function parseChannelIdFromUrl(url) {
  2958. try {
  2959. const { pathname } = url instanceof URL ? url : new URL(url);
  2960. if (pathname.includes("/channel/"))
  2961. return sanitizeChannelId(pathname.split("/channel/")[1].split("/")[0]);
  2962. else if (pathname.includes("/@"))
  2963. return sanitizeChannelId(pathname.split("/@")[1].split("/")[0]);
  2964. else
  2965. return null;
  2966. }
  2967. catch (_a) {
  2968. return null;
  2969. }
  2970. }
  2971. /** Sanitizes a channel ID by adding a leading `@` if the ID doesn't start with `UC...` */
  2972. function sanitizeChannelId(channelId) {
  2973. channelId = String(channelId).trim();
  2974. return isValidChannelId(channelId) || channelId.startsWith("@")
  2975. ? channelId
  2976. : `@${channelId}`;
  2977. }
  2978. /** Tests whether a string is a valid channel ID in the format `@User` or `UC...` */
  2979. function isValidChannelId(channelId) {
  2980. return channelId.match(/^(UC|@)[a-zA-Z0-9_-]+$/) !== null;
  2981. }
  2982. /** Returns the thumbnail URL for a video with either a given quality identifier or index */
  2983. function getThumbnailUrl(videoID, qualityOrIndex = "maxresdefault") {
  2984. return `https://img.youtube.com/vi/${videoID}/${qualityOrIndex}.jpg`;
  2985. }
  2986. /** Returns the best available thumbnail URL for a video with the given video ID */
  2987. async function getBestThumbnailUrl(videoID) {
  2988. try {
  2989. const priorityList = ["maxresdefault", "sddefault", "hqdefault", 0];
  2990. for (const quality of priorityList) {
  2991. let response;
  2992. const url = getThumbnailUrl(videoID, quality);
  2993. try {
  2994. response = await sendRequest({ url, method: "HEAD", timeout: 6000 });
  2995. }
  2996. catch (err) {
  2997. error(`Error while sending HEAD request to thumbnail URL for video ID '${videoID}' with quality '${quality}':`, err);
  2998. void err;
  2999. }
  3000. if (response && response.status < 300 && response.status >= 200)
  3001. return url;
  3002. }
  3003. }
  3004. catch (err) {
  3005. throw new Error(`Couldn't get thumbnail URL for video ID '${videoID}': ${err}`);
  3006. }
  3007. }
  3008. /** Opens the given URL in a new tab, using GM.openInTab if available */
  3009. function openInTab(href, background = false) {
  3010. try {
  3011. UserUtils.openInNewTab(href, background);
  3012. }
  3013. catch (_a) {
  3014. window.open(href, "_blank", "noopener noreferrer");
  3015. }
  3016. }
  3017. /** Tries to parse an uncompressed or compressed input string as a JSON object */
  3018. async function tryToDecompressAndParse(input) {
  3019. let parsed = null;
  3020. const val = await UserUtils.consumeStringGen(input);
  3021. try {
  3022. parsed = JSON.parse(val);
  3023. }
  3024. catch (_a) {
  3025. try {
  3026. parsed = JSON.parse(await UserUtils.decompress(val, compressionFormat, "string"));
  3027. }
  3028. catch (err) {
  3029. error("Couldn't decompress and parse data due to an error:", err);
  3030. return null;
  3031. }
  3032. }
  3033. // artificial timeout to allow animations to finish and because dumb monkey brains *expect* a delay
  3034. await UserUtils.pauseFor(UserUtils.randRange(250, 500));
  3035. return parsed;
  3036. }
  3037. /** Very crude OS detection */
  3038. function getOS() {
  3039. if (navigator.userAgent.match(/mac(\s?os|intel)/i))
  3040. return "mac";
  3041. return "other";
  3042. }
  3043. /** Formats a number based on the config or the passed {@linkcode notation} */
  3044. function formatNumber(num, notation) {
  3045. return num.toLocaleString(getLocale().replace(/_/g, "-"), (notation !== null && notation !== void 0 ? notation : getFeature("numbersFormat")) === "short"
  3046. ? {
  3047. notation: "compact",
  3048. compactDisplay: "short",
  3049. maximumFractionDigits: 1,
  3050. }
  3051. : {
  3052. style: "decimal",
  3053. maximumFractionDigits: 0,
  3054. });
  3055. }
  3056. /** add `time_continue` param only if current video time is greater than this value */
  3057. const reloadTabVideoTimeThreshold = 3;
  3058. /** Reloads the tab. If a video is currently playing, its time and volume will be preserved through the URL parameter `time_continue` and `bytm-reload-tab-volume` in GM storage */
  3059. async function reloadTab() {
  3060. var _a, _b, _c;
  3061. const win = UserUtils.getUnsafeWindow();
  3062. try {
  3063. enableDiscardBeforeUnload();
  3064. if (((_b = (_a = getVideoElement()) === null || _a === void 0 ? void 0 : _a.readyState) !== null && _b !== void 0 ? _b : 0) > 0) {
  3065. const time = (_c = await getVideoTime(0)) !== null && _c !== void 0 ? _c : 0;
  3066. const volume = Math.round(getVideoElement().volume * 100);
  3067. const url = new URL(win.location.href);
  3068. if (!isNaN(time) && time > reloadTabVideoTimeThreshold)
  3069. url.searchParams.set("time_continue", String(time));
  3070. if (!isNaN(volume) && volume > 0)
  3071. await GM.setValue("bytm-reload-tab-volume", String(volume));
  3072. return win.location.replace(url);
  3073. }
  3074. win.location.reload();
  3075. }
  3076. catch (err) {
  3077. error("Couldn't save video time and volume before reloading tab:", err);
  3078. win.location.reload();
  3079. }
  3080. }
  3081. /** Scrolls to the currently playing queue item in the queue once it's available */
  3082. function scrollToCurrentSongInQueue(evt) {
  3083. addSelectorListener("sidePanel", "ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]", {
  3084. listener(activeItem) {
  3085. activeItem.scrollIntoView({
  3086. behavior: (evt === null || evt === void 0 ? void 0 : evt.shiftKey) ? "instant" : "smooth",
  3087. block: (evt === null || evt === void 0 ? void 0 : evt.ctrlKey) || (evt === null || evt === void 0 ? void 0 : evt.altKey) ? "start" : "center",
  3088. inline: "center",
  3089. });
  3090. log("Scrolled to active song in queue:", activeItem);
  3091. }
  3092. });
  3093. }
  3094. //#region resources
  3095. /**
  3096. * Returns the blob-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)
  3097. * Falls back to a CDN URL or base64-encoded data URI if the resource is not available in the GM resource cache
  3098. * @param name The name / key of the resource as defined in `assets/resources.json` - you can use `as "_"` to make TypeScript shut up if the name can not be typed as `ResourceKey`
  3099. * @param uncached Set to true to always fetch from the CDN URL instead of the GM resource cache
  3100. */
  3101. async function getResourceUrl(name, uncached = false) {
  3102. var _a;
  3103. let url = !uncached && await GM.getResourceUrl(name);
  3104. if (!url || url.length === 0) {
  3105. const resObjOrStr = (_a = resourcesJson.resources) === null || _a === void 0 ? void 0 : _a[name];
  3106. if (typeof resObjOrStr === "object" || typeof resObjOrStr === "string") {
  3107. const pathName = typeof resObjOrStr === "object" && "path" in resObjOrStr ? resObjOrStr === null || resObjOrStr === void 0 ? void 0 : resObjOrStr.path : resObjOrStr;
  3108. const ghRef = typeof resObjOrStr === "object" && "ref" in resObjOrStr ? resObjOrStr === null || resObjOrStr === void 0 ? void 0 : resObjOrStr.ref : buildNumber;
  3109. if (pathName) {
  3110. return pathName.startsWith("http")
  3111. ? pathName
  3112. : (() => {
  3113. let path = pathName;
  3114. if (path.startsWith("/"))
  3115. path = path.slice(1);
  3116. else
  3117. path = `assets/${path}`;
  3118. switch (assetSource) {
  3119. case "jsdelivr":
  3120. return `https://cdn.jsdelivr.net/gh/${repo}@${ghRef}/${path}`;
  3121. case "github":
  3122. return `https://raw.githubusercontent.com/${repo}/${ghRef}/${path}`;
  3123. case "local":
  3124. return `http://localhost:${devServerPort}/${path}`;
  3125. }
  3126. })();
  3127. }
  3128. }
  3129. warn(`Couldn't get blob URL nor external URL for @resource '${name}', attempting to use base64-encoded fallback`);
  3130. // @ts-ignore
  3131. url = await GM.getResourceUrl(name, false);
  3132. }
  3133. return url;
  3134. }
  3135. /**
  3136. * Resolves the preferred locale of the user given their browser's language settings, as long as it is supported by the userscript directly or via the `altLocales` prop in `locales.json`
  3137. * Prioritizes any supported value of `navigator.language`, then `navigator.languages`, then goes over them again, trimming off the part after the hyphen, then falls back to `"en-US"`
  3138. */
  3139. function getPreferredLocale() {
  3140. var _a, _b;
  3141. const sanEq = (str1, str2) => str1.trim().toLowerCase() === str2.trim().toLowerCase();
  3142. const allNvLocs = [...new Set([navigator.language, ...navigator.languages])]
  3143. .map((v) => v.replace(/_/g, "-"));
  3144. for (const nvLoc of allNvLocs) {
  3145. const resolvedLoc = (_a = Object.entries(locales)
  3146. .find(([key, { altLocales }]) => sanEq(key, nvLoc) || altLocales.find(al => sanEq(al, nvLoc)))) === null || _a === void 0 ? void 0 : _a[0];
  3147. if (resolvedLoc)
  3148. return resolvedLoc.trim();
  3149. const trimmedNvLoc = nvLoc.split("-")[0];
  3150. const resolvedFallbackLoc = (_b = Object.entries(locales)
  3151. .find(([key, { altLocales }]) => sanEq(key.split("-")[0], trimmedNvLoc) || altLocales.find(al => sanEq(al.split("-")[0], trimmedNvLoc)))) === null || _b === void 0 ? void 0 : _b[0];
  3152. if (resolvedFallbackLoc)
  3153. return resolvedFallbackLoc.trim();
  3154. }
  3155. return "en-US";
  3156. }
  3157. const resourceCache = new Map();
  3158. /**
  3159. * Returns the content behind the passed resource identifier as a string, for example to be assigned to an element's innerHTML property.
  3160. * Caches the resulting string if the resource key starts with `icon-`
  3161. */
  3162. async function resourceAsString(resource) {
  3163. if (resourceCache.has(resource))
  3164. return resourceCache.get(resource);
  3165. const resourceUrl = await getResourceUrl(resource);
  3166. try {
  3167. if (!resourceUrl)
  3168. throw new Error(`Couldn't find URL for resource '${resource}'`);
  3169. const str = await (await UserUtils.fetchAdvanced(resourceUrl)).text();
  3170. // since SVG is lightweight, caching it in memory is fine
  3171. if (resource.startsWith("icon-"))
  3172. resourceCache.set(resource, str);
  3173. return str;
  3174. }
  3175. catch (err) {
  3176. error(`Couldn't fetch resource '${resource}' at URL '${resourceUrl}' due to an error:`, err);
  3177. return null;
  3178. }
  3179. }
  3180. /** Parses a markdown string using marked and turns it into an HTML string with default settings - doesn't sanitize against XSS! */
  3181. function parseMarkdown(mdString) {
  3182. return marked.marked.parse(mdString, {
  3183. async: true,
  3184. gfm: true,
  3185. });
  3186. }
  3187. /** Returns the content of the changelog markdown file */
  3188. async function getChangelogMd() {
  3189. const clRes = await UserUtils.fetchAdvanced(changelogUrl);
  3190. log("Fetched changelog:", clRes);
  3191. return await clRes.text();
  3192. }
  3193. /** Returns the changelog as HTML with a details element for each version */
  3194. async function getChangelogHtmlWithDetails() {
  3195. try {
  3196. const changelogMd = await getChangelogMd();
  3197. let changelogHtml = await parseMarkdown(changelogMd);
  3198. const getVerId = (verStr) => verStr.trim().replace(/[._#\s-]/g, "");
  3199. 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\">");
  3200. const h2Matches = Array.from(changelogHtml.matchAll(/<h2(\s+id=".+")?>([\d\w\s.]+)<\/h2>/gm));
  3201. for (const [fullMatch, , verStr] of h2Matches)
  3202. changelogHtml = changelogHtml.replace(fullMatch, `<summary tab-index="0"><h2 id="${getVerId(verStr)}" role="subheading" aria-level="1">${verStr}</h2></summary>`);
  3203. changelogHtml = `<details class="bytm-changelog-version-details">${changelogHtml}</details>`;
  3204. return changelogHtml;
  3205. }
  3206. catch (err) {
  3207. return `Error while preparing changelog: ${err}`;
  3208. }
  3209. }/** Central serializer for all data stores */
  3210. let serializer;
  3211. /** Array of all data stores that are included in the DataStoreSerializer instance */
  3212. const getSerializerStores = () => [
  3213. configStore,
  3214. autoLikeStore,
  3215. ];
  3216. /** Returns the serializer for all data stores */
  3217. function getStoreSerializer() {
  3218. if (!serializer)
  3219. serializer = new UserUtils.DataStoreSerializer(getSerializerStores(), {
  3220. addChecksum: true,
  3221. ensureIntegrity: true,
  3222. });
  3223. return serializer;
  3224. }
  3225. /** Downloads the current data stores as a single file */
  3226. async function downloadData(useEncoding = true) {
  3227. const serializer = getStoreSerializer();
  3228. const pad = (val, len = 2) => String(val).padStart(len, "0");
  3229. const d = new Date();
  3230. const dateStr = `${pad(d.getFullYear(), 4)}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}`;
  3231. const fileName = `BetterYTM ${pkg.version} data export ${dateStr}.json`;
  3232. const data = JSON.stringify(JSON.parse(await serializer.serialize(useEncoding)), undefined, 2);
  3233. downloadFile(fileName, data, "application/json");
  3234. }let pluginListDialog = null;
  3235. /** Creates and/or returns the import dialog */
  3236. async function getPluginListDialog() {
  3237. return pluginListDialog = pluginListDialog !== null && pluginListDialog !== void 0 ? pluginListDialog : new BytmDialog({
  3238. id: "plugin-list",
  3239. width: 800,
  3240. height: 600,
  3241. closeBtnEnabled: true,
  3242. closeOnBgClick: true,
  3243. closeOnEscPress: true,
  3244. destroyOnClose: true,
  3245. small: true,
  3246. renderHeader: renderHeader$3,
  3247. renderBody: renderBody$3,
  3248. });
  3249. }
  3250. async function renderHeader$3() {
  3251. const titleElem = document.createElement("h2");
  3252. titleElem.id = "bytm-plugin-list-title";
  3253. titleElem.classList.add("bytm-dialog-title");
  3254. titleElem.role = "heading";
  3255. titleElem.ariaLevel = "1";
  3256. titleElem.tabIndex = 0;
  3257. titleElem.textContent = t("plugin_list_title");
  3258. return titleElem;
  3259. }
  3260. async function renderBody$3() {
  3261. var _a;
  3262. const listContainerEl = document.createElement("div");
  3263. listContainerEl.id = "bytm-plugin-list-container";
  3264. const registeredPlugins = getRegisteredPlugins();
  3265. if (registeredPlugins.length === 0) {
  3266. const noPluginsEl = document.createElement("div");
  3267. noPluginsEl.classList.add("bytm-plugin-list-no-plugins");
  3268. noPluginsEl.tabIndex = 0;
  3269. setInnerHtml(noPluginsEl, t("plugin_list_no_plugins", `<a class="bytm-link" href="${pkg.homepage}#plugins" target="_blank" rel="noopener noreferrer">`, "</a>"));
  3270. noPluginsEl.title = noPluginsEl.ariaLabel = t("plugin_list_no_plugins_tooltip");
  3271. listContainerEl.appendChild(noPluginsEl);
  3272. return listContainerEl;
  3273. }
  3274. for (const [, { def: { plugin, intents } }] of registeredPlugins) {
  3275. const rowEl = document.createElement("div");
  3276. rowEl.classList.add("bytm-plugin-list-row");
  3277. const leftEl = document.createElement("div");
  3278. leftEl.classList.add("bytm-plugin-list-row-left");
  3279. rowEl.appendChild(leftEl);
  3280. const headerWrapperEl = document.createElement("div");
  3281. headerWrapperEl.classList.add("bytm-plugin-list-row-header-wrapper");
  3282. leftEl.appendChild(headerWrapperEl);
  3283. if (plugin.iconUrl) {
  3284. const iconEl = document.createElement("img");
  3285. iconEl.classList.add("bytm-plugin-list-row-icon");
  3286. iconEl.src = plugin.iconUrl;
  3287. iconEl.alt = "";
  3288. headerWrapperEl.appendChild(iconEl);
  3289. }
  3290. const headerEl = document.createElement("div");
  3291. headerEl.classList.add("bytm-plugin-list-row-header");
  3292. headerWrapperEl.appendChild(headerEl);
  3293. const titleEl = document.createElement("div");
  3294. titleEl.classList.add("bytm-plugin-list-row-title");
  3295. titleEl.tabIndex = 0;
  3296. titleEl.textContent = titleEl.title = titleEl.ariaLabel = plugin.name;
  3297. headerEl.appendChild(titleEl);
  3298. const verEl = document.createElement("span");
  3299. verEl.classList.add("bytm-plugin-list-row-version");
  3300. verEl.textContent = verEl.title = verEl.ariaLabel = `v${plugin.version}`;
  3301. titleEl.appendChild(verEl);
  3302. const namespaceEl = document.createElement("div");
  3303. namespaceEl.classList.add("bytm-plugin-list-row-namespace");
  3304. namespaceEl.tabIndex = 0;
  3305. namespaceEl.textContent = namespaceEl.title = namespaceEl.ariaLabel = plugin.namespace;
  3306. headerEl.appendChild(namespaceEl);
  3307. const descEl = document.createElement("p");
  3308. descEl.classList.add("bytm-plugin-list-row-desc");
  3309. descEl.tabIndex = 0;
  3310. descEl.textContent = descEl.title = descEl.ariaLabel = (_a = plugin.description[getLocale()]) !== null && _a !== void 0 ? _a : plugin.description["en-US"];
  3311. leftEl.appendChild(descEl);
  3312. const linksList = document.createElement("div");
  3313. linksList.classList.add("bytm-plugin-list-row-links-list");
  3314. leftEl.appendChild(linksList);
  3315. let linkElCreated = false;
  3316. for (const key in plugin.homepage) {
  3317. const url = plugin.homepage[key];
  3318. if (!url)
  3319. continue;
  3320. if (linkElCreated) {
  3321. const bulletEl = document.createElement("span");
  3322. bulletEl.classList.add("bytm-plugin-list-row-links-list-bullet");
  3323. bulletEl.textContent = "•";
  3324. linksList.appendChild(bulletEl);
  3325. }
  3326. linkElCreated = true;
  3327. const linkEl = document.createElement("a");
  3328. linkEl.classList.add("bytm-plugin-list-row-link", "bytm-link");
  3329. linkEl.href = url;
  3330. linkEl.tabIndex = 0;
  3331. linkEl.target = "_blank";
  3332. linkEl.rel = "noopener noreferrer";
  3333. linkEl.textContent = linkEl.title = linkEl.ariaLabel = t(`plugin_link_type_${key}`);
  3334. linksList.appendChild(linkEl);
  3335. }
  3336. const rightEl = document.createElement("div");
  3337. rightEl.classList.add("bytm-plugin-list-row-right");
  3338. rowEl.appendChild(rightEl);
  3339. const intentsAmount = Object.keys(PluginIntent).length / 2;
  3340. const intentsArr = typeof intents === "number" && intents > 0 ? (() => {
  3341. const arr = [];
  3342. for (let i = 0; i < intentsAmount; i++)
  3343. if (intents & (2 ** i))
  3344. arr.push(2 ** i);
  3345. return arr;
  3346. })() : [];
  3347. const permissionsHeaderEl = document.createElement("div");
  3348. permissionsHeaderEl.classList.add("bytm-plugin-list-row-permissions-header");
  3349. permissionsHeaderEl.tabIndex = 0;
  3350. permissionsHeaderEl.textContent = permissionsHeaderEl.title = permissionsHeaderEl.ariaLabel = t("plugin_list_permissions_header");
  3351. rightEl.appendChild(permissionsHeaderEl);
  3352. for (const intent of intentsArr) {
  3353. const intentEl = document.createElement("div");
  3354. intentEl.classList.add("bytm-plugin-list-row-intent-item");
  3355. intentEl.tabIndex = 0;
  3356. intentEl.textContent = t(`plugin_intent_name_${PluginIntent[intent]}`);
  3357. intentEl.title = intentEl.ariaLabel = t(`plugin_intent_description_${PluginIntent[intent]}`);
  3358. rightEl.appendChild(intentEl);
  3359. }
  3360. listContainerEl.appendChild(rowEl);
  3361. }
  3362. return listContainerEl;
  3363. }let featHelpDialog = null;
  3364. let curFeatKey = null;
  3365. /** Creates or modifies the help dialog for a specific feature and returns it */
  3366. async function getFeatHelpDialog({ featKey, }) {
  3367. curFeatKey = featKey;
  3368. if (!featHelpDialog) {
  3369. featHelpDialog = new BytmDialog({
  3370. id: "feat-help",
  3371. width: 600,
  3372. height: 400,
  3373. closeBtnEnabled: true,
  3374. closeOnBgClick: true,
  3375. closeOnEscPress: true,
  3376. small: true,
  3377. renderHeader: renderHeader$2,
  3378. renderBody: renderBody$2,
  3379. });
  3380. // make config menu inert while help dialog is open
  3381. featHelpDialog.on("open", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true"); });
  3382. featHelpDialog.on("close", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert"); });
  3383. }
  3384. return featHelpDialog;
  3385. }
  3386. async function renderHeader$2() {
  3387. const headerEl = document.createElement("div");
  3388. setInnerHtml(headerEl, await resourceAsString("icon-help"));
  3389. return headerEl;
  3390. }
  3391. async function renderBody$2() {
  3392. var _a, _b;
  3393. const contElem = document.createElement("div");
  3394. const localeObj = locales === null || locales === void 0 ? void 0 : locales[getLocale()];
  3395. // insert sentence terminator if not present, to improve flow with screenreaders
  3396. let featText = t(`feature_desc_${curFeatKey}`);
  3397. if (localeObj) {
  3398. if (!featText.endsWith(localeObj.sentenceTerminator))
  3399. featText = `${localeObj.textDir !== "rtl" ? featText : ""}${localeObj.sentenceTerminator}${localeObj.textDir === "rtl" ? featText : ""}`;
  3400. }
  3401. const featDescElem = document.createElement("h3");
  3402. featDescElem.role = "subheading";
  3403. featDescElem.tabIndex = 0;
  3404. featDescElem.textContent = featText;
  3405. featDescElem.id = "bytm-feat-help-dialog-desc";
  3406. const helpTextElem = document.createElement("div");
  3407. helpTextElem.id = "bytm-feat-help-dialog-text";
  3408. helpTextElem.tabIndex = 0;
  3409. // @ts-ignore
  3410. const helpText = (_b = (_a = featInfo[curFeatKey]) === null || _a === void 0 ? void 0 : _a.helpText) === null || _b === void 0 ? void 0 : _b.call(_a);
  3411. helpTextElem.textContent = helpText !== null && helpText !== void 0 ? helpText : t(`feature_helptext_${curFeatKey}`);
  3412. contElem.appendChild(featDescElem);
  3413. contElem.appendChild(helpTextElem);
  3414. return contElem;
  3415. }let changelogDialog = null;
  3416. /** Creates and/or returns the changelog dialog */
  3417. async function getChangelogDialog() {
  3418. if (!changelogDialog) {
  3419. changelogDialog = new BytmDialog({
  3420. id: "changelog",
  3421. width: 1000,
  3422. height: 800,
  3423. closeBtnEnabled: true,
  3424. closeOnBgClick: true,
  3425. closeOnEscPress: true,
  3426. small: true,
  3427. verticalAlign: "top",
  3428. renderHeader: renderHeader$1,
  3429. renderBody: renderBody$1,
  3430. });
  3431. changelogDialog.on("render", () => {
  3432. const mdContElem = document.querySelector("#bytm-changelog-dialog-text");
  3433. if (!mdContElem)
  3434. return;
  3435. const anchors = mdContElem.querySelectorAll("a");
  3436. for (const anchor of anchors) {
  3437. anchor.ariaLabel = anchor.title = anchor.href;
  3438. anchor.target = "_blank";
  3439. }
  3440. const firstDetails = mdContElem.querySelector("details");
  3441. if (firstDetails)
  3442. firstDetails.open = true;
  3443. const kbdElems = mdContElem.querySelectorAll("kbd");
  3444. for (const kbdElem of kbdElems)
  3445. kbdElem.addEventListener("selectstart", (e) => e.preventDefault());
  3446. });
  3447. }
  3448. return changelogDialog;
  3449. }
  3450. async function renderHeader$1() {
  3451. const headerEl = document.createElement("h2");
  3452. headerEl.classList.add("bytm-dialog-title");
  3453. headerEl.role = "heading";
  3454. headerEl.ariaLevel = "1";
  3455. headerEl.tabIndex = 0;
  3456. headerEl.textContent = headerEl.ariaLabel = t("changelog_menu_title", scriptInfo.name);
  3457. return headerEl;
  3458. }
  3459. async function renderBody$1() {
  3460. const contElem = document.createElement("div");
  3461. const mdContElem = document.createElement("div");
  3462. mdContElem.id = "bytm-changelog-dialog-text";
  3463. mdContElem.classList.add("bytm-markdown-container");
  3464. setInnerHtml(mdContElem, await getChangelogHtmlWithDetails());
  3465. contElem.appendChild(mdContElem);
  3466. return contElem;
  3467. }let otherHotkeyInputActive = false;
  3468. const reservedKeys = ["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "Meta", "Tab", "Space", " "];
  3469. /** Creates a hotkey input element */
  3470. function createHotkeyInput({ initialValue, onChange, createTitle }) {
  3471. var _a;
  3472. const initialHotkey = initialValue;
  3473. let currentHotkey;
  3474. if (!createTitle)
  3475. createTitle = (value) => value;
  3476. const wrapperElem = document.createElement("div");
  3477. wrapperElem.classList.add("bytm-hotkey-wrapper");
  3478. const infoElem = document.createElement("span");
  3479. infoElem.classList.add("bytm-hotkey-info");
  3480. const inputElem = document.createElement("button");
  3481. inputElem.role = "button";
  3482. inputElem.classList.add("bytm-ftconf-input", "bytm-hotkey-input", "bytm-btn");
  3483. inputElem.dataset.state = "inactive";
  3484. inputElem.innerText = (_a = initialValue === null || initialValue === void 0 ? void 0 : initialValue.code) !== null && _a !== void 0 ? _a : t("hotkey_input_click_to_change");
  3485. inputElem.ariaLabel = inputElem.title = createTitle(hotkeyToString(initialValue));
  3486. const resetElem = document.createElement("span");
  3487. resetElem.classList.add("bytm-hotkey-reset", "bytm-link", "bytm-hidden");
  3488. resetElem.role = "button";
  3489. resetElem.tabIndex = 0;
  3490. resetElem.textContent = `(${t("reset")})`;
  3491. resetElem.ariaLabel = resetElem.title = t("hotkey_input_click_to_reset_tooltip");
  3492. const deactivate = () => {
  3493. var _a;
  3494. if (!otherHotkeyInputActive)
  3495. return;
  3496. emitSiteEvent("hotkeyInputActive", false);
  3497. otherHotkeyInputActive = false;
  3498. const curHk = currentHotkey !== null && currentHotkey !== void 0 ? currentHotkey : initialValue;
  3499. inputElem.innerText = (_a = curHk === null || curHk === void 0 ? void 0 : curHk.code) !== null && _a !== void 0 ? _a : t("hotkey_input_click_to_change");
  3500. inputElem.dataset.state = "inactive";
  3501. inputElem.ariaLabel = inputElem.title = createTitle(hotkeyToString(curHk));
  3502. setInnerHtml(infoElem, curHk ? getHotkeyInfoHtml(curHk) : "");
  3503. };
  3504. const activate = () => {
  3505. if (otherHotkeyInputActive)
  3506. return;
  3507. emitSiteEvent("hotkeyInputActive", true);
  3508. otherHotkeyInputActive = true;
  3509. inputElem.innerText = "< ... >";
  3510. inputElem.dataset.state = "active";
  3511. inputElem.ariaLabel = inputElem.title = t("click_to_cancel_tooltip");
  3512. };
  3513. const resetClicked = (e) => {
  3514. e.preventDefault();
  3515. e.stopImmediatePropagation();
  3516. onChange(initialValue);
  3517. currentHotkey = initialValue;
  3518. deactivate();
  3519. inputElem.innerText = initialValue.code;
  3520. setInnerHtml(infoElem, getHotkeyInfoHtml(initialValue));
  3521. resetElem.classList.add("bytm-hidden");
  3522. inputElem.ariaLabel = inputElem.title = createTitle(hotkeyToString(initialValue));
  3523. };
  3524. onInteraction(resetElem, resetClicked);
  3525. if (initialValue)
  3526. setInnerHtml(infoElem, getHotkeyInfoHtml(initialValue));
  3527. let lastKeyDown;
  3528. document.addEventListener("keypress", (e) => {
  3529. if (inputElem.dataset.state === "inactive")
  3530. return;
  3531. 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)
  3532. return;
  3533. e.preventDefault();
  3534. e.stopImmediatePropagation();
  3535. const hotkey = {
  3536. code: e.code,
  3537. shift: e.shiftKey,
  3538. ctrl: e.ctrlKey,
  3539. alt: e.altKey,
  3540. };
  3541. inputElem.innerText = hotkey.code;
  3542. inputElem.dataset.state = "inactive";
  3543. setInnerHtml(infoElem, getHotkeyInfoHtml(hotkey));
  3544. inputElem.ariaLabel = inputElem.title = t("click_to_cancel_tooltip");
  3545. onChange(hotkey);
  3546. currentHotkey = hotkey;
  3547. });
  3548. document.addEventListener("keydown", (e) => {
  3549. if (reservedKeys.filter(k => k !== "Tab").includes(e.code))
  3550. return;
  3551. if (inputElem.dataset.state !== "active")
  3552. return;
  3553. if (e.code === "Tab" || e.code === " " || e.code === "Space" || e.code === "Escape" || e.code === "Enter") {
  3554. deactivate();
  3555. return;
  3556. }
  3557. if (["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight"].includes(e.code))
  3558. return;
  3559. e.preventDefault();
  3560. e.stopImmediatePropagation();
  3561. const hotkey = {
  3562. code: e.code,
  3563. shift: e.shiftKey,
  3564. ctrl: e.ctrlKey,
  3565. alt: e.altKey,
  3566. };
  3567. 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;
  3568. lastKeyDown = hotkey;
  3569. onChange(hotkey);
  3570. currentHotkey = hotkey;
  3571. if (keyChanged) {
  3572. deactivate();
  3573. resetElem.classList.remove("bytm-hidden");
  3574. }
  3575. else
  3576. resetElem.classList.add("bytm-hidden");
  3577. inputElem.innerText = hotkey.code;
  3578. inputElem.dataset.state = "inactive";
  3579. setInnerHtml(infoElem, getHotkeyInfoHtml(hotkey));
  3580. });
  3581. siteEvents.on("cfgMenuClosed", deactivate);
  3582. inputElem.addEventListener("click", () => {
  3583. if (inputElem.dataset.state === "inactive")
  3584. activate();
  3585. else
  3586. deactivate();
  3587. });
  3588. inputElem.addEventListener("keydown", (e) => {
  3589. if (reservedKeys.includes(e.code))
  3590. return;
  3591. if (inputElem.dataset.state === "inactive")
  3592. activate();
  3593. });
  3594. wrapperElem.appendChild(resetElem);
  3595. wrapperElem.appendChild(infoElem);
  3596. wrapperElem.appendChild(inputElem);
  3597. return wrapperElem;
  3598. }
  3599. /** Returns HTML for the hotkey modifier keys info element */
  3600. function getHotkeyInfoHtml(hotkey) {
  3601. const modifiers = [];
  3602. hotkey.ctrl && modifiers.push(`<kbd class="bytm-kbd">${t("hotkey_key_ctrl")}</kbd>`);
  3603. hotkey.shift && modifiers.push(`<kbd class="bytm-kbd">${t("hotkey_key_shift")}</kbd>`);
  3604. hotkey.alt && modifiers.push(`<kbd class="bytm-kbd">${getOS() === "mac" ? t("hotkey_key_mac_option") : t("hotkey_key_alt")}</kbd>`);
  3605. return `\
  3606. <div style="display: flex; align-items: center;">
  3607. <span>
  3608. ${modifiers.reduce((a, c) => `${a ? a + " " : ""}${c}`, "")}
  3609. </span>
  3610. <span style="padding: 0px 5px;">
  3611. ${modifiers.length > 0 ? "+" : ""}
  3612. </span>
  3613. </div>`;
  3614. }
  3615. /** Converts a hotkey object to a string */
  3616. function hotkeyToString(hotkey) {
  3617. if (!hotkey)
  3618. return t("hotkey_key_none");
  3619. let str = "";
  3620. if (hotkey.ctrl)
  3621. str += `${t("hotkey_key_ctrl")}+`;
  3622. if (hotkey.shift)
  3623. str += `${t("hotkey_key_shift")}+`;
  3624. if (hotkey.alt)
  3625. str += `${getOS() === "mac" ? t("hotkey_key_mac_option") : t("hotkey_key_alt")}+`;
  3626. str += hotkey.code;
  3627. return str;
  3628. }//#region create menu
  3629. let isCfgMenuMounted = false;
  3630. let isCfgMenuOpen = false;
  3631. /** Threshold in pixels from the top of the options container that dictates for how long the scroll indicator is shown */
  3632. const scrollIndicatorOffsetThreshold = 50;
  3633. let scrollIndicatorEnabled = true;
  3634. /** Locale at the point of initializing the config menu */
  3635. let initLocale;
  3636. /** Stringified config at the point of initializing the config menu */
  3637. let initConfig$1;
  3638. /** Timeout id for the "copied" text in the hidden value copy button */
  3639. let hiddenCopiedTxtTimeout;
  3640. /**
  3641. * Adds an element to open the BetterYTM menu
  3642. * @deprecated to be replaced with new menu - see https://github.com/Sv443/BetterYTM/issues/23
  3643. */
  3644. async function mountCfgMenu() {
  3645. var _a, _b, _c, _d, _e;
  3646. try {
  3647. if (isCfgMenuMounted)
  3648. return;
  3649. isCfgMenuMounted = true;
  3650. BytmDialog.initDialogs();
  3651. initLocale = getFeature("locale");
  3652. initConfig$1 = getFeatures();
  3653. const initLangReloadText = t("lang_changed_prompt_reload");
  3654. //#region bg & container
  3655. const backgroundElem = document.createElement("div");
  3656. backgroundElem.id = "bytm-cfg-menu-bg";
  3657. backgroundElem.classList.add("bytm-menu-bg");
  3658. backgroundElem.ariaLabel = backgroundElem.title = t("close_menu_tooltip");
  3659. backgroundElem.style.visibility = "hidden";
  3660. backgroundElem.style.display = "none";
  3661. backgroundElem.addEventListener("click", (e) => {
  3662. var _a;
  3663. if (isCfgMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-cfg-menu-bg")
  3664. closeCfgMenu(e);
  3665. });
  3666. document.body.addEventListener("keydown", (e) => {
  3667. if (isCfgMenuOpen && e.key === "Escape" && BytmDialog.getCurrentDialogId() === "cfg-menu")
  3668. closeCfgMenu(e);
  3669. });
  3670. const menuContainer = document.createElement("div");
  3671. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  3672. menuContainer.classList.add("bytm-menu");
  3673. menuContainer.id = "bytm-cfg-menu";
  3674. //#region title bar
  3675. const headerElem = document.createElement("div");
  3676. headerElem.classList.add("bytm-menu-header");
  3677. const titleLogoHeaderCont = document.createElement("div");
  3678. titleLogoHeaderCont.classList.add("bytm-menu-title-logo-header-cont");
  3679. const titleCont = document.createElement("div");
  3680. titleCont.classList.add("bytm-menu-titlecont");
  3681. titleCont.role = "heading";
  3682. titleCont.ariaLevel = "1";
  3683. const titleLogoElem = document.createElement("img");
  3684. const logoSrc = await getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`);
  3685. titleLogoElem.classList.add("bytm-cfg-menu-logo", "bytm-no-select");
  3686. if (logoSrc)
  3687. titleLogoElem.src = logoSrc;
  3688. titleLogoHeaderCont.appendChild(titleLogoElem);
  3689. const titleElem = document.createElement("h2");
  3690. titleElem.classList.add("bytm-menu-title");
  3691. const titleTextElem = document.createElement("div");
  3692. titleTextElem.textContent = t("config_menu_title", scriptInfo.name);
  3693. titleElem.appendChild(titleTextElem);
  3694. const linksCont = document.createElement("div");
  3695. linksCont.id = "bytm-menu-linkscont";
  3696. linksCont.role = "navigation";
  3697. const linkTitlesShort = {
  3698. github: "GitHub",
  3699. greasyfork: "GreasyFork",
  3700. openuserjs: "OpenUserJS",
  3701. discord: "Discord",
  3702. };
  3703. const addLink = (imgSrc, href, title, titleKey) => {
  3704. const anchorElem = document.createElement("a");
  3705. anchorElem.classList.add("bytm-menu-link", "bytm-no-select");
  3706. anchorElem.rel = "noopener noreferrer";
  3707. anchorElem.href = href;
  3708. anchorElem.target = "_blank";
  3709. anchorElem.tabIndex = 0;
  3710. anchorElem.role = "button";
  3711. anchorElem.ariaLabel = anchorElem.title = title;
  3712. const extendedAnchorEl = document.createElement("a");
  3713. extendedAnchorEl.classList.add("bytm-menu-link", "extended-link", "bytm-no-select");
  3714. extendedAnchorEl.rel = "noopener noreferrer";
  3715. extendedAnchorEl.href = href;
  3716. extendedAnchorEl.target = "_blank";
  3717. extendedAnchorEl.tabIndex = -1;
  3718. extendedAnchorEl.textContent = linkTitlesShort[titleKey];
  3719. extendedAnchorEl.ariaLabel = extendedAnchorEl.title = title;
  3720. const imgElem = document.createElement("img");
  3721. imgElem.classList.add("bytm-menu-img");
  3722. imgElem.src = imgSrc;
  3723. anchorElem.appendChild(imgElem);
  3724. anchorElem.appendChild(extendedAnchorEl);
  3725. linksCont.appendChild(anchorElem);
  3726. };
  3727. const links = [
  3728. ["github", await getResourceUrl("img-github"), scriptInfo.namespace, t("open_github", scriptInfo.name), "github"],
  3729. ["greasyfork", await getResourceUrl("img-greasyfork"), pkg.hosts.greasyfork, t("open_greasyfork", scriptInfo.name), "greasyfork"],
  3730. ["openuserjs", await getResourceUrl("img-openuserjs"), pkg.hosts.openuserjs, t("open_openuserjs", scriptInfo.name), "openuserjs"],
  3731. ];
  3732. const hostLink = links.find(([name]) => name === host);
  3733. const otherLinks = links.filter(([name]) => name !== host);
  3734. const reorderedLinks = hostLink ? [hostLink, ...otherLinks] : links;
  3735. for (const [, ...args] of reorderedLinks)
  3736. addLink(...args);
  3737. addLink(await getResourceUrl("img-discord"), "https://dc.sv443.net/", t("open_discord"), "discord");
  3738. const closeElem = document.createElement("img");
  3739. closeElem.classList.add("bytm-menu-close");
  3740. closeElem.role = "button";
  3741. closeElem.tabIndex = 0;
  3742. closeElem.src = await getResourceUrl("img-close");
  3743. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  3744. onInteraction(closeElem, closeCfgMenu);
  3745. titleCont.appendChild(titleElem);
  3746. titleCont.appendChild(linksCont);
  3747. titleLogoHeaderCont.appendChild(titleCont);
  3748. headerElem.appendChild(titleLogoHeaderCont);
  3749. headerElem.appendChild(closeElem);
  3750. //#region footer
  3751. const footerCont = document.createElement("div");
  3752. footerCont.classList.add("bytm-menu-footer-cont");
  3753. const reloadFooterCont = document.createElement("div");
  3754. const reloadFooterEl = document.createElement("div");
  3755. reloadFooterEl.id = "bytm-menu-footer-reload-hint";
  3756. reloadFooterEl.classList.add("bytm-menu-footer", "hidden");
  3757. reloadFooterEl.setAttribute("aria-hidden", "true");
  3758. reloadFooterEl.textContent = t("reload_hint");
  3759. reloadFooterEl.role = "alert";
  3760. reloadFooterEl.ariaLive = "polite";
  3761. const reloadTxtEl = document.createElement("button");
  3762. reloadTxtEl.classList.add("bytm-btn");
  3763. reloadTxtEl.style.marginLeft = "10px";
  3764. reloadTxtEl.textContent = t("reload_now");
  3765. reloadTxtEl.ariaLabel = reloadTxtEl.title = t("reload_tooltip");
  3766. reloadTxtEl.addEventListener("click", () => {
  3767. closeCfgMenu();
  3768. reloadTab();
  3769. });
  3770. reloadFooterEl.appendChild(reloadTxtEl);
  3771. reloadFooterCont.appendChild(reloadFooterEl);
  3772. /** For copying plain when shift-clicking the copy button or when compression is not supported */
  3773. const exportDataSpecial = () => JSON.stringify({ formatVersion, data: getFeatures() });
  3774. const exImDlg = new ExImDialog({
  3775. id: "bytm-config-export-import",
  3776. width: 800,
  3777. height: 600,
  3778. // try to compress the data if possible
  3779. exportData: async () => await compressionSupported()
  3780. ? await UserUtils.compress(JSON.stringify({ formatVersion, data: getFeatures() }), compressionFormat, "string")
  3781. : exportDataSpecial(),
  3782. exportDataSpecial,
  3783. async onImport(data) {
  3784. try {
  3785. const parsed = await tryToDecompressAndParse(data.trim());
  3786. log("Trying to import configuration:", parsed);
  3787. if (!parsed || typeof parsed !== "object")
  3788. return await showPrompt({ type: "alert", message: t("import_error_invalid") });
  3789. if (typeof parsed.formatVersion !== "number")
  3790. return await showPrompt({ type: "alert", message: t("import_error_no_format_version") });
  3791. if (typeof parsed.data !== "object" || parsed.data === null || Object.keys(parsed.data).length === 0)
  3792. return await showPrompt({ type: "alert", message: t("import_error_no_data") });
  3793. if (parsed.formatVersion < formatVersion) {
  3794. let newData = JSON.parse(JSON.stringify(parsed.data));
  3795. const sortedMigrations = Object.entries(migrations)
  3796. .sort(([a], [b]) => Number(a) - Number(b));
  3797. let curFmtVer = Number(parsed.formatVersion);
  3798. for (const [fmtVer, migrationFunc] of sortedMigrations) {
  3799. const ver = Number(fmtVer);
  3800. if (curFmtVer < formatVersion && curFmtVer < ver) {
  3801. try {
  3802. const migRes = JSON.parse(JSON.stringify(migrationFunc(newData)));
  3803. newData = migRes instanceof Promise ? await migRes : migRes;
  3804. curFmtVer = ver;
  3805. }
  3806. catch (err) {
  3807. error(`Error while running migration function for format version ${fmtVer}:`, err);
  3808. }
  3809. }
  3810. }
  3811. parsed.formatVersion = curFmtVer;
  3812. parsed.data = newData;
  3813. }
  3814. else if (parsed.formatVersion !== formatVersion)
  3815. return await showPrompt({ type: "alert", message: t("import_error_wrong_format_version", formatVersion, parsed.formatVersion) });
  3816. await setFeatures(Object.assign(Object.assign({}, getFeatures()), parsed.data));
  3817. if (await showPrompt({ type: "confirm", message: t("import_success_confirm_reload") })) {
  3818. log("Reloading tab after importing configuration");
  3819. return reloadTab();
  3820. }
  3821. exImDlg.unmount();
  3822. emitSiteEvent("rebuildCfgMenu", parsed.data);
  3823. }
  3824. catch (err) {
  3825. warn("Couldn't import configuration:", err);
  3826. await showPrompt({ type: "alert", message: t("import_error_invalid") });
  3827. }
  3828. },
  3829. title: () => t("bytm_config_export_import_title"),
  3830. descImport: () => t("bytm_config_import_desc"),
  3831. descExport: () => t("bytm_config_export_desc"),
  3832. });
  3833. const exportImportBtn = document.createElement("button");
  3834. exportImportBtn.classList.add("bytm-btn");
  3835. exportImportBtn.textContent = exportImportBtn.ariaLabel = exportImportBtn.title = t("export_import");
  3836. onInteraction(exportImportBtn, async () => await exImDlg.open());
  3837. const buttonsCont = document.createElement("div");
  3838. buttonsCont.classList.add("bytm-menu-footer-buttons-cont");
  3839. buttonsCont.appendChild(exportImportBtn);
  3840. footerCont.appendChild(reloadFooterCont);
  3841. footerCont.appendChild(buttonsCont);
  3842. //#region feature list
  3843. const featuresCont = document.createElement("div");
  3844. featuresCont.id = "bytm-menu-opts";
  3845. const onCfgChange = async (key, initialVal, newVal) => {
  3846. var _a, _b, _c, _d;
  3847. try {
  3848. const fmt = (val) => typeof val === "object" ? JSON.stringify(val) : String(val);
  3849. info(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`);
  3850. const featConf = JSON.parse(JSON.stringify(getFeatures()));
  3851. featConf[key] = newVal;
  3852. const changedKeys = initConfig$1 ? Object.keys(featConf).filter((k) => typeof featConf[k] !== "object"
  3853. && featConf[k] !== initConfig$1[k]) : [];
  3854. const requiresReload =
  3855. // @ts-ignore
  3856. changedKeys.some((k) => { var _a; return ((_a = featInfo[k]) === null || _a === void 0 ? void 0 : _a.reloadRequired) !== false; });
  3857. await setFeatures(featConf);
  3858. // @ts-ignore
  3859. (_b = (_a = featInfo[key]) === null || _a === void 0 ? void 0 : _a.change) === null || _b === void 0 ? void 0 : _b.call(_a, key, initialVal, newVal);
  3860. if (requiresReload) {
  3861. reloadFooterEl.classList.remove("hidden");
  3862. reloadFooterEl.setAttribute("aria-hidden", "false");
  3863. }
  3864. else {
  3865. reloadFooterEl.classList.add("hidden");
  3866. reloadFooterEl.setAttribute("aria-hidden", "true");
  3867. }
  3868. if (initLocale !== featConf.locale) {
  3869. await initTranslations(featConf.locale);
  3870. setLocale(featConf.locale);
  3871. const newText = t("lang_changed_prompt_reload");
  3872. const newLangEmoji = ((_c = locales[featConf.locale]) === null || _c === void 0 ? void 0 : _c.emoji) ? `${locales[featConf.locale].emoji}\n` : "";
  3873. const initLangEmoji = ((_d = locales[initLocale]) === null || _d === void 0 ? void 0 : _d.emoji) ? `${locales[initLocale].emoji}\n` : "";
  3874. const confirmText = newText !== initLangReloadText ? `${newLangEmoji}${newText}\n\n\n${initLangEmoji}${initLangReloadText}` : newText;
  3875. if (await showPrompt({
  3876. type: "confirm",
  3877. message: confirmText,
  3878. confirmBtnText: () => `${t("prompt_confirm")} / ${tl(initLocale, "prompt_confirm")}`,
  3879. confirmBtnTooltip: () => `${t("click_to_confirm_tooltip")} / ${tl(initLocale, "click_to_confirm_tooltip")}`,
  3880. denyBtnText: (type) => `${t(type === "alert" ? "prompt_close" : "prompt_cancel")} / ${tl(initLocale, type === "alert" ? "prompt_close" : "prompt_cancel")}`,
  3881. denyBtnTooltip: (type) => `${t(type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")} / ${tl(initLocale, type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")}`,
  3882. })) {
  3883. closeCfgMenu();
  3884. log("Reloading tab after changing language");
  3885. await reloadTab();
  3886. }
  3887. }
  3888. else if (getLocale() !== featConf.locale)
  3889. setLocale(featConf.locale);
  3890. }
  3891. catch (err) {
  3892. error("Error while reacting to config change:", err);
  3893. }
  3894. finally {
  3895. emitSiteEvent("configOptionChanged", key, initialVal, newVal);
  3896. }
  3897. };
  3898. /** Call whenever the feature config is changed */
  3899. const confChanged = UserUtils.debounce(onCfgChange, 333);
  3900. const featureCfg = getFeatures();
  3901. const featureCfgWithCategories = Object.entries(featInfo)
  3902. .reduce((acc, [key, { category }]) => {
  3903. if (!acc[category])
  3904. acc[category] = {};
  3905. acc[category][key] = featureCfg[key];
  3906. return acc;
  3907. }, {});
  3908. /**
  3909. * Formats the value `v` based on the provided `key` using the `featInfo` object.
  3910. * If a custom `renderValue` function is defined for the `key`, it will be used to format the value.
  3911. * If no custom `renderValue` function is defined, the value will be converted to a string and trimmed.
  3912. * If the value is an object, it will be converted to a JSON string representation.
  3913. * If an error occurs during formatting (like when passing objects with circular references), the original value will be returned as a string (trimmed).
  3914. */
  3915. const fmtVal = (v, key) => {
  3916. var _a;
  3917. try {
  3918. // @ts-ignore
  3919. 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;
  3920. const retVal = (typeof v === "object" ? JSON.stringify(v) : String(v)).trim();
  3921. return renderValue ? renderValue(retVal) : retVal;
  3922. }
  3923. catch (_b) {
  3924. // absolute last resort fallback because stringify throws on circular refs
  3925. return String(v).trim();
  3926. }
  3927. };
  3928. for (const category in featureCfgWithCategories) {
  3929. const featObj = featureCfgWithCategories[category];
  3930. const catHeaderElem = document.createElement("h3");
  3931. catHeaderElem.classList.add("bytm-ftconf-category-header");
  3932. catHeaderElem.role = "heading";
  3933. catHeaderElem.ariaLevel = "2";
  3934. catHeaderElem.tabIndex = 0;
  3935. catHeaderElem.textContent = `${t(`feature_category_${category}`)}:`;
  3936. featuresCont.appendChild(catHeaderElem);
  3937. for (const featKey in featObj) {
  3938. const ftInfo = featInfo[featKey];
  3939. if (!ftInfo || ("hidden" in ftInfo && ftInfo.hidden === true))
  3940. continue;
  3941. if (ftInfo.advanced && !featureCfg.advancedMode)
  3942. continue;
  3943. const { type, default: ftDefault } = ftInfo;
  3944. const step = "step" in ftInfo ? ftInfo.step : undefined;
  3945. const val = featureCfg[featKey];
  3946. const initialVal = val !== null && val !== void 0 ? val : ftDefault;
  3947. const ftConfElem = document.createElement("div");
  3948. ftConfElem.classList.add("bytm-ftitem");
  3949. {
  3950. const featLeftSideElem = document.createElement("div");
  3951. featLeftSideElem.classList.add("bytm-ftitem-leftside");
  3952. if (getFeature("advancedMode")) {
  3953. const defVal = fmtVal(ftDefault, featKey);
  3954. const extraTxts = [
  3955. `default: ${defVal.length === 0 ? "(undefined)" : defVal}`,
  3956. ];
  3957. "min" in ftInfo && extraTxts.push(`min: ${ftInfo.min}`);
  3958. "max" in ftInfo && extraTxts.push(`max: ${ftInfo.max}`);
  3959. "step" in ftInfo && extraTxts.push(`step: ${ftInfo.step}`);
  3960. const rel = "reloadRequired" in ftInfo && ftInfo.reloadRequired !== false ? " (reload required)" : "";
  3961. const adv = ftInfo.advanced ? " (advanced feature)" : "";
  3962. ftConfElem.title = `${featKey}${rel}${adv}${extraTxts.length > 0 ? `\n${extraTxts.join(" - ")}` : ""}`;
  3963. }
  3964. if (!await hasKeyFor("en-US", `feature_desc_${featKey}`)) {
  3965. error(`Missing en-US translation with key "feature_desc_${featKey}" for feature description, skipping this config menu feature...`);
  3966. continue;
  3967. }
  3968. const textElem = document.createElement("span");
  3969. textElem.id = `bytm-ftitem-text-${featKey}`;
  3970. textElem.classList.add("bytm-ftitem-text", "bytm-ellipsis-wrap");
  3971. textElem.textContent = textElem.title = textElem.ariaLabel = t(`feature_desc_${featKey}`);
  3972. let adornmentElem;
  3973. const adornContentAsync = (_a = ftInfo.textAdornment) === null || _a === void 0 ? void 0 : _a.call(ftInfo);
  3974. const adornContent = adornContentAsync instanceof Promise ? await adornContentAsync : adornContentAsync;
  3975. if ((typeof adornContentAsync === "string" || adornContentAsync instanceof Promise) && typeof adornContent !== "undefined") {
  3976. adornmentElem = document.createElement("span");
  3977. adornmentElem.id = `bytm-ftitem-${featKey}-adornment`;
  3978. adornmentElem.classList.add("bytm-ftitem-adornment");
  3979. setInnerHtml(adornmentElem, adornContent);
  3980. }
  3981. let helpElem;
  3982. // @ts-ignore
  3983. const hasHelpTextFunc = typeof ((_b = featInfo[featKey]) === null || _b === void 0 ? void 0 : _b.helpText) === "function";
  3984. // @ts-ignore
  3985. const helpTextVal = hasHelpTextFunc && featInfo[featKey].helpText();
  3986. if (await hasKey(`feature_helptext_${featKey}`) || (helpTextVal && await hasKey(helpTextVal))) {
  3987. const helpElemImgHtml = await resourceAsString("icon-help");
  3988. if (helpElemImgHtml) {
  3989. helpElem = document.createElement("div");
  3990. helpElem.classList.add("bytm-ftitem-help-btn", "bytm-generic-btn");
  3991. helpElem.ariaLabel = helpElem.title = t("feature_help_button_tooltip", t(`feature_desc_${featKey}`));
  3992. helpElem.role = "button";
  3993. helpElem.tabIndex = 0;
  3994. setInnerHtml(helpElem, helpElemImgHtml);
  3995. onInteraction(helpElem, async (e) => {
  3996. e.preventDefault();
  3997. e.stopPropagation();
  3998. await (await getFeatHelpDialog({ featKey: featKey })).open();
  3999. });
  4000. }
  4001. else {
  4002. error(`Couldn't create help button SVG element for feature '${featKey}'`);
  4003. }
  4004. }
  4005. adornmentElem && featLeftSideElem.appendChild(adornmentElem);
  4006. featLeftSideElem.appendChild(textElem);
  4007. helpElem && featLeftSideElem.appendChild(helpElem);
  4008. ftConfElem.appendChild(featLeftSideElem);
  4009. }
  4010. {
  4011. let inputType = "text";
  4012. let inputTag = "input";
  4013. switch (type) {
  4014. case "toggle":
  4015. inputTag = undefined;
  4016. inputType = undefined;
  4017. break;
  4018. case "slider":
  4019. inputType = "range";
  4020. break;
  4021. case "number":
  4022. inputType = "number";
  4023. break;
  4024. case "text":
  4025. inputType = "text";
  4026. break;
  4027. case "select":
  4028. inputTag = "select";
  4029. inputType = undefined;
  4030. break;
  4031. case "hotkey":
  4032. inputTag = undefined;
  4033. inputType = undefined;
  4034. break;
  4035. case "button":
  4036. inputTag = undefined;
  4037. inputType = undefined;
  4038. break;
  4039. }
  4040. const inputElemId = `bytm-ftconf-${featKey}-input`;
  4041. const ctrlElem = document.createElement("span");
  4042. ctrlElem.classList.add("bytm-ftconf-ctrl");
  4043. // to prevent dev mode title from propagating:
  4044. ctrlElem.title = "";
  4045. let advCopyHiddenCont;
  4046. if ((getFeature("advancedMode") || mode === "development") && ftInfo.valueHidden) {
  4047. const advCopyHintElem = document.createElement("span");
  4048. advCopyHintElem.classList.add("bytm-ftconf-adv-copy-hint");
  4049. advCopyHintElem.textContent = t("copied");
  4050. advCopyHintElem.role = "status";
  4051. advCopyHintElem.style.display = "none";
  4052. const advCopyHiddenBtn = document.createElement("button");
  4053. advCopyHiddenBtn.classList.add("bytm-ftconf-adv-copy-btn", "bytm-btn");
  4054. advCopyHiddenBtn.tabIndex = 0;
  4055. advCopyHiddenBtn.textContent = t("copy_hidden");
  4056. advCopyHiddenBtn.ariaLabel = advCopyHiddenBtn.title = t("copy_hidden_tooltip");
  4057. const copyHiddenInteraction = (e) => {
  4058. e.preventDefault();
  4059. e.stopPropagation();
  4060. copyToClipboard(getFeatures()[featKey]);
  4061. advCopyHintElem.style.display = "inline";
  4062. if (typeof hiddenCopiedTxtTimeout === "undefined") {
  4063. hiddenCopiedTxtTimeout = setTimeout(() => {
  4064. advCopyHintElem.style.display = "none";
  4065. hiddenCopiedTxtTimeout = undefined;
  4066. }, 3000);
  4067. }
  4068. };
  4069. onInteraction(advCopyHiddenBtn, copyHiddenInteraction);
  4070. advCopyHiddenCont = document.createElement("span");
  4071. advCopyHiddenCont.appendChild(advCopyHintElem);
  4072. advCopyHiddenCont.appendChild(advCopyHiddenBtn);
  4073. }
  4074. advCopyHiddenCont && ctrlElem.appendChild(advCopyHiddenCont);
  4075. if (inputTag) {
  4076. // standard input element:
  4077. const inputElem = document.createElement(inputTag);
  4078. inputElem.classList.add("bytm-ftconf-input");
  4079. inputElem.id = inputElemId;
  4080. inputElem.ariaLabel = t(`feature_desc_${featKey}`);
  4081. if (inputType)
  4082. inputElem.type = inputType;
  4083. if ("min" in ftInfo && typeof ftInfo.min !== "undefined")
  4084. inputElem.min = String(ftInfo.min);
  4085. if ("max" in ftInfo && typeof ftInfo.max !== "undefined")
  4086. inputElem.max = String(ftInfo.max);
  4087. if (typeof initialVal !== "undefined")
  4088. inputElem.value = String(initialVal);
  4089. if (type === "text" && ftInfo.valueHidden) {
  4090. inputElem.type = "password";
  4091. inputElem.autocomplete = "off";
  4092. }
  4093. if (type === "number" || type === "slider" && step)
  4094. inputElem.step = String(step);
  4095. if (type === "toggle" && typeof initialVal !== "undefined")
  4096. inputElem.checked = Boolean(initialVal);
  4097. const getUnitTxt = (val) => ("unit" in ftInfo && typeof ftInfo.unit === "string"
  4098. ? ftInfo.unit
  4099. : ("unit" in ftInfo && typeof ftInfo.unit === "function"
  4100. ? ftInfo.unit(Number(val))
  4101. : ""));
  4102. let labelElem;
  4103. let lastDisplayedVal;
  4104. if (type === "slider") {
  4105. labelElem = document.createElement("label");
  4106. labelElem.classList.add("bytm-ftconf-label", "bytm-slider-label");
  4107. labelElem.textContent = `${fmtVal(initialVal, featKey)}${getUnitTxt(inputElem.value)}`;
  4108. inputElem.addEventListener("input", () => {
  4109. if (labelElem && lastDisplayedVal !== inputElem.value) {
  4110. labelElem.textContent = `${fmtVal(inputElem.value, featKey)}${getUnitTxt(inputElem.value)}`;
  4111. lastDisplayedVal = inputElem.value;
  4112. }
  4113. });
  4114. }
  4115. else if (type === "select") {
  4116. const ftOpts = typeof ftInfo.options === "function"
  4117. ? ftInfo.options()
  4118. : ftInfo.options;
  4119. for (const { value, label } of ftOpts) {
  4120. const optionElem = document.createElement("option");
  4121. optionElem.value = String(value);
  4122. optionElem.textContent = label;
  4123. if (value === initialVal)
  4124. optionElem.selected = true;
  4125. inputElem.appendChild(optionElem);
  4126. }
  4127. }
  4128. if (type === "text") {
  4129. let lastValue = inputElem.value && inputElem.value.length > 0 ? inputElem.value : ftInfo.default;
  4130. const textInputUpdate = () => {
  4131. let v = String(inputElem.value).trim();
  4132. if (type === "text" && ftInfo.normalize)
  4133. v = inputElem.value = ftInfo.normalize(String(v));
  4134. if (v === lastValue)
  4135. return;
  4136. lastValue = v;
  4137. if (v === "")
  4138. v = ftInfo.default;
  4139. if (typeof initialVal !== "undefined")
  4140. confChanged(featKey, initialVal, v);
  4141. };
  4142. const unsub = siteEvents.on("cfgMenuClosed", () => {
  4143. unsub();
  4144. textInputUpdate();
  4145. });
  4146. inputElem.addEventListener("blur", () => textInputUpdate());
  4147. inputElem.addEventListener("keydown", (e) => e.key === "Tab" && textInputUpdate());
  4148. }
  4149. else {
  4150. inputElem.addEventListener("input", () => {
  4151. let v = String(inputElem.value).trim();
  4152. if (["number", "slider"].includes(type) || v.match(/^-?\d+$/))
  4153. v = Number(v);
  4154. if (typeof initialVal !== "undefined")
  4155. confChanged(featKey, initialVal, (type !== "toggle" ? v : inputElem.checked));
  4156. });
  4157. }
  4158. if (labelElem) {
  4159. labelElem.id = `bytm-ftconf-${featKey}-label`;
  4160. labelElem.htmlFor = inputElemId;
  4161. ctrlElem.appendChild(labelElem);
  4162. }
  4163. inputElem.setAttribute("aria-describedby", `bytm-ftitem-text-${featKey}`);
  4164. inputElem.setAttribute("aria-labelledby", (_c = labelElem === null || labelElem === void 0 ? void 0 : labelElem.id) !== null && _c !== void 0 ? _c : `bytm-ftitem-text-${featKey}`);
  4165. ctrlElem.appendChild(inputElem);
  4166. }
  4167. else {
  4168. // custom input element:
  4169. let customInputEl;
  4170. switch (type) {
  4171. case "hotkey":
  4172. customInputEl = createHotkeyInput({
  4173. initialValue: typeof initialVal === "object" ? initialVal : undefined,
  4174. onChange: (hotkey) => confChanged(featKey, initialVal, hotkey),
  4175. createTitle: (value) => t("hotkey_input_click_to_change_tooltip", t(`feature_desc_${featKey}`), value),
  4176. });
  4177. break;
  4178. case "toggle":
  4179. customInputEl = await createToggleInput({
  4180. initialValue: Boolean(initialVal),
  4181. onChange: (checked) => confChanged(featKey, initialVal, checked),
  4182. id: `ftconf-${featKey}`,
  4183. labelPos: "left",
  4184. });
  4185. break;
  4186. case "button":
  4187. customInputEl = document.createElement("button");
  4188. customInputEl.classList.add("bytm-btn");
  4189. customInputEl.tabIndex = 0;
  4190. customInputEl.textContent = await hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action");
  4191. customInputEl.ariaLabel = customInputEl.title = t(`feature_desc_${featKey}`);
  4192. onInteraction(customInputEl, async () => {
  4193. if (customInputEl.disabled)
  4194. return;
  4195. const startTs = Date.now();
  4196. const res = ftInfo.click();
  4197. customInputEl.disabled = true;
  4198. customInputEl.classList.add("bytm-busy");
  4199. customInputEl.textContent = await hasKey(`feature_btn_${featKey}_running`) ? t(`feature_btn_${featKey}_running`) : t("trigger_btn_action_running");
  4200. if (res instanceof Promise)
  4201. await res;
  4202. const finalize = async () => {
  4203. customInputEl.disabled = false;
  4204. customInputEl.classList.remove("bytm-busy");
  4205. customInputEl.textContent = await hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action");
  4206. };
  4207. // artificial timeout ftw
  4208. const rTime = UserUtils.randRange(200, 400);
  4209. if (Date.now() - startTs < rTime)
  4210. setTimeout(finalize, rTime - (Date.now() - startTs));
  4211. else
  4212. finalize();
  4213. });
  4214. break;
  4215. }
  4216. if (customInputEl && !customInputEl.hasAttribute("aria-label"))
  4217. customInputEl.ariaLabel = t(`feature_desc_${featKey}`);
  4218. customInputEl === null || customInputEl === void 0 ? void 0 : customInputEl.setAttribute("aria-describedby", `bytm-ftitem-text-${featKey}`);
  4219. if ((customInputEl === null || customInputEl === void 0 ? void 0 : customInputEl.getAttribute("aria-labelledby")) === null) {
  4220. // try to find a label element to link to for a11y, else default to the text element
  4221. const lbl = customInputEl === null || customInputEl === void 0 ? void 0 : customInputEl.querySelector("label");
  4222. customInputEl === null || customInputEl === void 0 ? void 0 : customInputEl.setAttribute("aria-labelledby", lbl && lbl.id.length > 0 ? lbl.id : `bytm-ftitem-text-${featKey}`);
  4223. }
  4224. ctrlElem.appendChild(customInputEl);
  4225. }
  4226. ftConfElem.appendChild(ctrlElem);
  4227. }
  4228. featuresCont.appendChild(ftConfElem);
  4229. }
  4230. }
  4231. //#region reset inputs on external change
  4232. siteEvents.on("rebuildCfgMenu", (newConfig) => {
  4233. for (const ftKey in featInfo) {
  4234. const ftElem = document.querySelector(`#bytm-ftconf-${ftKey}-input`);
  4235. const labelElem = document.querySelector(`#bytm-ftconf-${ftKey}-label`);
  4236. if (!ftElem)
  4237. continue;
  4238. const ftInfo = featInfo[ftKey];
  4239. const value = newConfig[ftKey];
  4240. if (ftInfo.type === "toggle")
  4241. ftElem.checked = Boolean(value);
  4242. else
  4243. ftElem.value = String(value);
  4244. if (!labelElem)
  4245. continue;
  4246. const unitTxt = ("unit" in ftInfo && typeof ftInfo.unit === "string"
  4247. ? ftInfo.unit
  4248. : ("unit" in ftInfo && typeof ftInfo.unit === "function"
  4249. ? ftInfo.unit(Number(ftElem.value))
  4250. : ""));
  4251. if (ftInfo.type === "slider")
  4252. labelElem.textContent = `${fmtVal(Number(value), ftKey)}${unitTxt}`;
  4253. }
  4254. info("Rebuilt config menu");
  4255. });
  4256. //#region scroll indicator
  4257. const scrollIndicator = document.createElement("img");
  4258. scrollIndicator.id = "bytm-menu-scroll-indicator";
  4259. scrollIndicator.src = await getResourceUrl("icon-arrow_down");
  4260. scrollIndicator.role = "button";
  4261. scrollIndicator.ariaLabel = scrollIndicator.title = t("scroll_to_bottom");
  4262. featuresCont.appendChild(scrollIndicator);
  4263. scrollIndicator.addEventListener("click", () => {
  4264. const bottomAnchor = document.querySelector("#bytm-menu-bottom-anchor");
  4265. bottomAnchor === null || bottomAnchor === void 0 ? void 0 : bottomAnchor.scrollIntoView({
  4266. behavior: "smooth",
  4267. });
  4268. });
  4269. featuresCont.addEventListener("scroll", (evt) => {
  4270. var _a, _b;
  4271. const scrollPos = (_b = (_a = evt.target) === null || _a === void 0 ? void 0 : _a.scrollTop) !== null && _b !== void 0 ? _b : 0;
  4272. const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator");
  4273. if (!scrollIndicator)
  4274. return;
  4275. if (scrollIndicatorEnabled && scrollPos > scrollIndicatorOffsetThreshold && !scrollIndicator.classList.contains("bytm-hidden")) {
  4276. scrollIndicator.classList.add("bytm-hidden");
  4277. }
  4278. else if (scrollIndicatorEnabled && scrollPos <= scrollIndicatorOffsetThreshold && scrollIndicator.classList.contains("bytm-hidden")) {
  4279. scrollIndicator.classList.remove("bytm-hidden");
  4280. }
  4281. });
  4282. const bottomAnchor = document.createElement("div");
  4283. bottomAnchor.id = "bytm-menu-bottom-anchor";
  4284. featuresCont.appendChild(bottomAnchor);
  4285. //#region finalize
  4286. menuContainer.appendChild(headerElem);
  4287. menuContainer.appendChild(featuresCont);
  4288. const subtitleElemCont = document.createElement("div");
  4289. subtitleElemCont.id = "bytm-menu-subtitle-cont";
  4290. subtitleElemCont.classList.add("bytm-ellipsis");
  4291. const versionEl = document.createElement("a");
  4292. versionEl.id = "bytm-menu-version-anchor";
  4293. versionEl.classList.add("bytm-link", "bytm-ellipsis");
  4294. versionEl.role = "button";
  4295. versionEl.tabIndex = 0;
  4296. versionEl.ariaLabel = versionEl.title = t("version_tooltip", scriptInfo.version, buildNumber);
  4297. versionEl.textContent = `v${scriptInfo.version} (#${buildNumber})`;
  4298. onInteraction(versionEl, async (e) => {
  4299. e.preventDefault();
  4300. e.stopPropagation();
  4301. const dlg = await getChangelogDialog();
  4302. dlg.on("close", () => openCfgMenu());
  4303. await dlg.mount();
  4304. closeCfgMenu(undefined, false);
  4305. await dlg.open();
  4306. });
  4307. subtitleElemCont.appendChild(versionEl);
  4308. titleElem.appendChild(subtitleElemCont);
  4309. const modeItems = [];
  4310. mode === "development" && modeItems.push("dev_mode");
  4311. getFeature("advancedMode") && modeItems.push("advanced_mode");
  4312. if (modeItems.length > 0) {
  4313. const modeDisplayEl = document.createElement("span");
  4314. modeDisplayEl.id = "bytm-menu-mode-display";
  4315. modeDisplayEl.classList.add("bytm-ellipsis");
  4316. modeDisplayEl.textContent = `[${t("active_mode_display", arrayWithSeparators(modeItems.map(v => t(`${v}_short`)), ", ", " & "))}]`;
  4317. modeDisplayEl.ariaLabel = modeDisplayEl.title = tp("active_mode_tooltip", modeItems, arrayWithSeparators(modeItems.map(t), ", ", " & "));
  4318. subtitleElemCont.appendChild(modeDisplayEl);
  4319. }
  4320. menuContainer.appendChild(footerCont);
  4321. backgroundElem.appendChild(menuContainer);
  4322. ((_d = document.querySelector("#bytm-dialog-container")) !== null && _d !== void 0 ? _d : document.body).appendChild(backgroundElem);
  4323. window.addEventListener("resize", UserUtils.debounce(checkToggleScrollIndicator, 250));
  4324. log("Added menu element");
  4325. // ensure stuff is reset if menu was opened before being added
  4326. isCfgMenuOpen = false;
  4327. document.body.classList.remove("bytm-disable-scroll");
  4328. (_e = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _e === void 0 ? void 0 : _e.removeAttribute("inert");
  4329. backgroundElem.style.visibility = "hidden";
  4330. backgroundElem.style.display = "none";
  4331. siteEvents.on("recreateCfgMenu", async () => {
  4332. const bgElem = document.querySelector("#bytm-cfg-menu-bg");
  4333. if (!bgElem)
  4334. return;
  4335. closeCfgMenu();
  4336. bgElem.remove();
  4337. isCfgMenuMounted = isCfgMenuOpen = false;
  4338. await mountCfgMenu();
  4339. await openCfgMenu();
  4340. });
  4341. }
  4342. catch (err) {
  4343. error("Error while rendering config menu:", err);
  4344. closeCfgMenu();
  4345. }
  4346. }
  4347. //#region open & close
  4348. /** Closes the config menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  4349. function closeCfgMenu(evt, enableScroll = true) {
  4350. var _a, _b, _c;
  4351. if (!isCfgMenuOpen)
  4352. return;
  4353. isCfgMenuOpen = false;
  4354. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  4355. if (enableScroll) {
  4356. document.body.classList.remove("bytm-disable-scroll");
  4357. (_a = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert");
  4358. }
  4359. const menuBg = document.querySelector("#bytm-cfg-menu-bg");
  4360. clearTimeout(hiddenCopiedTxtTimeout);
  4361. UserUtils.openDialogs.splice(UserUtils.openDialogs.indexOf("cfg-menu"), 1);
  4362. setCurrentDialogId((_b = UserUtils.openDialogs === null || UserUtils.openDialogs === void 0 ? void 0 : UserUtils.openDialogs[0]) !== null && _b !== void 0 ? _b : null);
  4363. // since this menu doesn't have a BytmDialog instance, it's undefined here
  4364. emitInterface("bytm:dialogClosed", undefined);
  4365. emitInterface("bytm:dialogClosed:cfg-menu", undefined);
  4366. if (!menuBg)
  4367. 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.");
  4368. (_c = menuBg.querySelectorAll(".bytm-ftconf-adv-copy-hint")) === null || _c === void 0 ? void 0 : _c.forEach((el) => el.style.display = "none");
  4369. menuBg.style.visibility = "hidden";
  4370. menuBg.style.display = "none";
  4371. }
  4372. /** Opens the config menu if it is closed */
  4373. async function openCfgMenu() {
  4374. var _a;
  4375. try {
  4376. if (!isCfgMenuMounted)
  4377. await mountCfgMenu();
  4378. if (isCfgMenuOpen)
  4379. return;
  4380. isCfgMenuOpen = true;
  4381. document.body.classList.add("bytm-disable-scroll");
  4382. (_a = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true");
  4383. const menuBg = document.querySelector("#bytm-cfg-menu-bg");
  4384. setCurrentDialogId("cfg-menu");
  4385. UserUtils.openDialogs.unshift("cfg-menu");
  4386. // since this menu doesn't have a BytmDialog instance, it's undefined here
  4387. emitInterface("bytm:dialogOpened", undefined);
  4388. emitInterface("bytm:dialogOpened:cfg-menu", undefined);
  4389. if (!menuBg) {
  4390. 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.");
  4391. closeCfgMenu();
  4392. return;
  4393. }
  4394. menuBg.style.visibility = "visible";
  4395. menuBg.style.display = "block";
  4396. checkToggleScrollIndicator();
  4397. }
  4398. catch (err) {
  4399. error("Error while opening config menu:", err);
  4400. }
  4401. }
  4402. //#region chk scroll indicator
  4403. /** Checks if the features container is scrollable and toggles the scroll indicator accordingly */
  4404. function checkToggleScrollIndicator() {
  4405. const featuresCont = document.querySelector("#bytm-menu-opts");
  4406. const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator");
  4407. // disable scroll indicator if container doesn't scroll
  4408. if (featuresCont && scrollIndicator) {
  4409. const verticalScroll = UserUtils.isScrollable(featuresCont).vertical;
  4410. /** If true, the indicator's threshold is under the available scrollable space and so it should be disabled */
  4411. const underThreshold = featuresCont.scrollHeight - featuresCont.clientHeight <= scrollIndicatorOffsetThreshold;
  4412. if (!underThreshold && verticalScroll && !scrollIndicatorEnabled) {
  4413. scrollIndicatorEnabled = true;
  4414. scrollIndicator.classList.remove("bytm-hidden");
  4415. }
  4416. if ((!verticalScroll && scrollIndicatorEnabled) || underThreshold) {
  4417. scrollIndicatorEnabled = false;
  4418. scrollIndicator.classList.add("bytm-hidden");
  4419. }
  4420. }
  4421. }//#region cfg menu btns
  4422. let logoExchanged = false, improveLogoCalled = false, bytmLogoUrl;
  4423. /** Adds a watermark beneath the logo */
  4424. async function addWatermark() {
  4425. const watermarkEl = document.createElement("a");
  4426. watermarkEl.role = "button";
  4427. watermarkEl.id = "bytm-watermark";
  4428. watermarkEl.classList.add("style-scope", "ytmusic-nav-bar", "bytm-no-select");
  4429. watermarkEl.textContent = scriptInfo.name;
  4430. watermarkEl.ariaLabel = watermarkEl.title = t("open_menu_tooltip", scriptInfo.name);
  4431. watermarkEl.tabIndex = 0;
  4432. await improveLogo();
  4433. bytmLogoUrl = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  4434. UserUtils.preloadImages([bytmLogoUrl]);
  4435. const watermarkOpenMenu = (e) => {
  4436. e.stopImmediatePropagation();
  4437. if ((!e.shiftKey && !e.ctrlKey) || logoExchanged)
  4438. openCfgMenu();
  4439. if (!logoExchanged && (e.shiftKey || e.ctrlKey))
  4440. exchangeLogo();
  4441. };
  4442. onInteraction(watermarkEl, (e) => watermarkOpenMenu(e));
  4443. addSelectorListener("navBar", "ytmusic-logo a", {
  4444. listener: (logoElem) => logoElem.appendChild(watermarkEl),
  4445. });
  4446. log("Added watermark element");
  4447. }
  4448. /** Turns the regular `<img>`-based logo into inline SVG to be able to animate and modify parts of it */
  4449. async function improveLogo() {
  4450. try {
  4451. if (improveLogoCalled)
  4452. return;
  4453. improveLogoCalled = true;
  4454. const res = await UserUtils.fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg");
  4455. const svg = await res.text();
  4456. addSelectorListener("navBar", "ytmusic-logo > a", {
  4457. listener: (logoElem) => {
  4458. logoElem.classList.add("bytm-mod-logo", "bytm-no-select");
  4459. setInnerHtml(logoElem, svg);
  4460. logoElem.querySelectorAll("svg > g > path").forEach((el) => el.classList.add("bytm-mod-logo-remove"));
  4461. log("Swapped logo to inline SVG");
  4462. },
  4463. });
  4464. }
  4465. catch (err) {
  4466. error("Couldn't improve logo due to an error:", err);
  4467. }
  4468. }
  4469. /** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */
  4470. function exchangeLogo() {
  4471. addSelectorListener("navBar", ".bytm-mod-logo", {
  4472. listener: async (logoElem) => {
  4473. if (logoElem.classList.contains("bytm-logo-exchanged") || !bytmLogoUrl)
  4474. return;
  4475. logoExchanged = true;
  4476. logoElem.classList.add("bytm-logo-exchanged");
  4477. const newLogo = document.createElement("img");
  4478. newLogo.classList.add("bytm-mod-logo-img");
  4479. newLogo.src = bytmLogoUrl;
  4480. logoElem.insertBefore(newLogo, logoElem.querySelector("svg"));
  4481. bytmLogoUrl && document.head.querySelectorAll("link[rel=\"icon\"]").forEach((e) => e.href = bytmLogoUrl);
  4482. setTimeout(() => {
  4483. logoElem.querySelectorAll(".bytm-mod-logo-remove").forEach(e => e.remove());
  4484. }, 1000);
  4485. },
  4486. });
  4487. }
  4488. //#region cfg menu options
  4489. /** Called whenever the avatar popover menu exists on YTM to add a BYTM config menu button to the user menu popover */
  4490. async function addConfigMenuOptionYTM(container) {
  4491. const cfgOptElem = document.createElement("div");
  4492. cfgOptElem.classList.add("bytm-cfg-menu-option");
  4493. const cfgOptItemElem = document.createElement("div");
  4494. cfgOptItemElem.classList.add("bytm-cfg-menu-option-item");
  4495. cfgOptItemElem.role = "button";
  4496. cfgOptItemElem.tabIndex = 0;
  4497. cfgOptItemElem.ariaLabel = cfgOptItemElem.title = t("open_menu_tooltip", scriptInfo.name);
  4498. onInteraction(cfgOptItemElem, async (e) => {
  4499. const settingsBtnElem = document.querySelector("ytmusic-nav-bar ytmusic-settings-button button");
  4500. settingsBtnElem === null || settingsBtnElem === void 0 ? void 0 : settingsBtnElem.click();
  4501. if ((!e.shiftKey && !e.ctrlKey) || logoExchanged)
  4502. openCfgMenu();
  4503. if (!logoExchanged && (e.shiftKey || e.ctrlKey))
  4504. exchangeLogo();
  4505. });
  4506. const cfgOptIconElem = document.createElement("img");
  4507. cfgOptIconElem.classList.add("bytm-cfg-menu-option-icon");
  4508. cfgOptIconElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  4509. const cfgOptTextElem = document.createElement("div");
  4510. cfgOptTextElem.classList.add("bytm-cfg-menu-option-text");
  4511. cfgOptTextElem.textContent = t("config_menu_option", scriptInfo.name);
  4512. cfgOptItemElem.appendChild(cfgOptIconElem);
  4513. cfgOptItemElem.appendChild(cfgOptTextElem);
  4514. cfgOptElem.appendChild(cfgOptItemElem);
  4515. container.appendChild(cfgOptElem);
  4516. improveLogo();
  4517. log("Added BYTM-Configuration button to menu popover");
  4518. }
  4519. /** Called whenever the titlebar (masthead) exists on YT to add a BYTM config menu button */
  4520. async function addConfigMenuOptionYT(container) {
  4521. const cfgOptWrapperElem = document.createElement("div");
  4522. cfgOptWrapperElem.classList.add("bytm-yt-cfg-menu-option", "darkreader-ignore");
  4523. cfgOptWrapperElem.role = "button";
  4524. cfgOptWrapperElem.tabIndex = 0;
  4525. cfgOptWrapperElem.ariaLabel = cfgOptWrapperElem.title = t("open_menu_tooltip", scriptInfo.name);
  4526. const cfgOptElem = document.createElement("div");
  4527. cfgOptElem.classList.add("bytm-yt-cfg-menu-option-inner");
  4528. const cfgOptImgElem = document.createElement("img");
  4529. cfgOptImgElem.classList.add("bytm-yt-cfg-menu-option-icon");
  4530. cfgOptImgElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  4531. const cfgOptItemElem = document.createElement("div");
  4532. cfgOptItemElem.classList.add("bytm-yt-cfg-menu-option-item");
  4533. cfgOptItemElem.textContent = scriptInfo.name;
  4534. cfgOptElem.appendChild(cfgOptImgElem);
  4535. cfgOptElem.appendChild(cfgOptItemElem);
  4536. cfgOptWrapperElem.appendChild(cfgOptElem);
  4537. onInteraction(cfgOptWrapperElem, openCfgMenu);
  4538. const firstChild = container === null || container === void 0 ? void 0 : container.firstElementChild;
  4539. if (firstChild)
  4540. container.insertBefore(cfgOptWrapperElem, firstChild);
  4541. else
  4542. return error("Couldn't add config menu option to YT titlebar - couldn't find container element");
  4543. }
  4544. //#region anchor improvements
  4545. /** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
  4546. async function addAnchorImprovements() {
  4547. try {
  4548. await addStyleFromResource("css-anchor_improvements");
  4549. }
  4550. catch (err) {
  4551. error("Couldn't add anchor improvements CSS due to an error:", err);
  4552. }
  4553. //#region carousel shelves
  4554. try {
  4555. const preventDefault = (e) => e.preventDefault();
  4556. /** Adds anchor improvements to &lt;ytmusic-responsive-list-item-renderer&gt; */
  4557. const addListItemAnchors = (items) => {
  4558. var _a;
  4559. for (const item of items) {
  4560. if (item.classList.contains("bytm-anchor-improved"))
  4561. continue;
  4562. item.classList.add("bytm-anchor-improved");
  4563. const thumbnailElem = item.querySelector(".left-items");
  4564. const titleElem = item.querySelector(".title-column .title a");
  4565. if (!thumbnailElem || !titleElem)
  4566. continue;
  4567. const anchorElem = document.createElement("a");
  4568. anchorElem.classList.add("bytm-anchor", "bytm-carousel-shelf-anchor");
  4569. anchorElem.href = (_a = titleElem === null || titleElem === void 0 ? void 0 : titleElem.href) !== null && _a !== void 0 ? _a : "#";
  4570. anchorElem.target = "_self";
  4571. anchorElem.role = "button";
  4572. anchorElem.addEventListener("click", preventDefault);
  4573. UserUtils.addParent(thumbnailElem, anchorElem);
  4574. }
  4575. };
  4576. // home page
  4577. addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", {
  4578. continuous: true,
  4579. all: true,
  4580. listener: addListItemAnchors,
  4581. });
  4582. // related tab in /watch
  4583. addSelectorListener("body", "ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", {
  4584. continuous: true,
  4585. all: true,
  4586. listener: addListItemAnchors,
  4587. });
  4588. // playlists
  4589. addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", {
  4590. continuous: true,
  4591. all: true,
  4592. listener: addListItemAnchors,
  4593. });
  4594. // generic shelves
  4595. addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", {
  4596. continuous: true,
  4597. all: true,
  4598. listener: addListItemAnchors,
  4599. });
  4600. }
  4601. catch (err) {
  4602. error("Couldn't improve carousel shelf anchors due to an error:", err);
  4603. }
  4604. //#region sidebar
  4605. try {
  4606. const addSidebarAnchors = (sidebarCont) => {
  4607. const items = sidebarCont.parentNode.querySelectorAll("ytmusic-guide-entry-renderer tp-yt-paper-item");
  4608. improveSidebarAnchors(items);
  4609. return items.length;
  4610. };
  4611. addSelectorListener("sideBar", "#contentContainer #guide-content #items ytmusic-guide-entry-renderer", {
  4612. listener: (sidebarCont) => {
  4613. const itemsAmt = addSidebarAnchors(sidebarCont);
  4614. log(`Added anchors around ${itemsAmt} sidebar ${UserUtils.autoPlural("item", itemsAmt)}`);
  4615. },
  4616. });
  4617. addSelectorListener("sideBarMini", "ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", {
  4618. listener: (miniSidebarCont) => {
  4619. const itemsAmt = addSidebarAnchors(miniSidebarCont);
  4620. log(`Added anchors around ${itemsAmt} mini sidebar ${UserUtils.autoPlural("item", itemsAmt)}`);
  4621. },
  4622. });
  4623. }
  4624. catch (err) {
  4625. error("Couldn't add anchors to sidebar items due to an error:", err);
  4626. }
  4627. }
  4628. const sidebarPaths = [
  4629. "/",
  4630. "/explore",
  4631. "/library",
  4632. ];
  4633. /**
  4634. * Adds anchors to the sidebar items so they can be opened in a new tab
  4635. * @param sidebarItem
  4636. */
  4637. function improveSidebarAnchors(sidebarItems) {
  4638. sidebarItems.forEach((item, i) => {
  4639. var _a;
  4640. const anchorElem = document.createElement("a");
  4641. anchorElem.classList.add("bytm-anchor", "bytm-no-select");
  4642. anchorElem.role = "button";
  4643. anchorElem.target = "_self";
  4644. anchorElem.href = (_a = sidebarPaths[i]) !== null && _a !== void 0 ? _a : "#";
  4645. anchorElem.ariaLabel = anchorElem.title = t("middle_click_open_tab");
  4646. anchorElem.addEventListener("click", (e) => {
  4647. e.preventDefault();
  4648. });
  4649. UserUtils.addParent(item, anchorElem);
  4650. });
  4651. }
  4652. //#region share track param
  4653. /** Removes the ?si tracking parameter from share URLs */
  4654. async function initRemShareTrackParam() {
  4655. const removeSiParam = (inputElem) => {
  4656. try {
  4657. if (!inputElem.value.match(/(&|\?)si=/i))
  4658. return;
  4659. const url = new URL(inputElem.value);
  4660. url.searchParams.delete("si");
  4661. inputElem.value = String(url);
  4662. log(`Removed tracking parameter from share link -> ${url}`);
  4663. }
  4664. catch (err) {
  4665. warn("Couldn't remove tracking parameter from share link due to error:", err);
  4666. }
  4667. };
  4668. const [sharePanelSel, inputSel] = (() => {
  4669. switch (getDomain()) {
  4670. case "ytm": return ["tp-yt-paper-dialog ytmusic-unified-share-panel-renderer", "input#share-url"];
  4671. case "yt": return ["ytd-unified-share-panel-renderer", "input#share-url"];
  4672. }
  4673. })();
  4674. addSelectorListener("body", sharePanelSel, {
  4675. listener: (sharePanelEl) => {
  4676. const obs = new MutationObserver(() => {
  4677. const inputElem = sharePanelEl.querySelector(inputSel);
  4678. inputElem && removeSiParam(inputElem);
  4679. });
  4680. obs.observe(sharePanelEl, {
  4681. childList: true,
  4682. subtree: true,
  4683. characterData: true,
  4684. attributeFilter: ["aria-hidden", "aria-checked", "checked"],
  4685. });
  4686. },
  4687. });
  4688. }
  4689. //#region fix spacing
  4690. /** Applies global CSS to fix various spacings */
  4691. async function fixSpacing() {
  4692. if (!await addStyleFromResource("css-fix_spacing"))
  4693. error("Couldn't fix spacing");
  4694. }
  4695. //#region ab.queue btns
  4696. async function initAboveQueueBtns() {
  4697. const { scrollToActiveSongBtn, clearQueueBtn } = getFeatures();
  4698. if (!await addStyleFromResource("css-above_queue_btns"))
  4699. error("Couldn't add CSS for above queue buttons");
  4700. else if (getFeature("aboveQueueBtnsSticky"))
  4701. addStyleFromResource("css-above_queue_btns_sticky");
  4702. const contBtns = [
  4703. {
  4704. condition: scrollToActiveSongBtn,
  4705. id: "scroll-to-active",
  4706. resourceName: "icon-skip_to",
  4707. titleKey: "scroll_to_playing",
  4708. interaction: async (evt) => scrollToCurrentSongInQueue(evt),
  4709. },
  4710. {
  4711. condition: clearQueueBtn,
  4712. id: "clear-queue",
  4713. resourceName: "icon-clear_list",
  4714. titleKey: "clear_list",
  4715. async interaction(evt) {
  4716. try {
  4717. if (evt.shiftKey || await showPrompt({ type: "confirm", message: t("clear_list_confirm") })) {
  4718. const url = new URL(location.href);
  4719. url.searchParams.delete("list");
  4720. url.searchParams.set("time_continue", String(await getVideoTime(0)));
  4721. location.assign(url);
  4722. }
  4723. }
  4724. catch (err) {
  4725. error("Couldn't clear queue due to an error:", err);
  4726. }
  4727. },
  4728. },
  4729. ];
  4730. if (!contBtns.some(b => Boolean(b.condition)))
  4731. return;
  4732. addSelectorListener("sidePanel", "ytmusic-tab-renderer ytmusic-queue-header-renderer #buttons", {
  4733. async listener(rightBtnsEl) {
  4734. try {
  4735. const aboveQueueBtnCont = document.createElement("div");
  4736. aboveQueueBtnCont.id = "bytm-above-queue-btn-cont";
  4737. UserUtils.addParent(rightBtnsEl, aboveQueueBtnCont);
  4738. const headerEl = rightBtnsEl.closest("ytmusic-queue-header-renderer");
  4739. if (!headerEl)
  4740. return error("Couldn't find queue header element while adding above queue buttons");
  4741. siteEvents.on("fullscreenToggled", (isFullscreen) => {
  4742. headerEl.classList[isFullscreen ? "add" : "remove"]("hidden");
  4743. });
  4744. const wrapperElem = document.createElement("div");
  4745. wrapperElem.id = "bytm-above-queue-btn-wrapper";
  4746. for (const item of contBtns) {
  4747. if (Boolean(item.condition) === false)
  4748. continue;
  4749. const btnElem = await createCircularBtn({
  4750. resourceName: item.resourceName,
  4751. onClick: item.interaction,
  4752. title: t(item.titleKey),
  4753. });
  4754. btnElem.id = `bytm-${item.id}-btn`;
  4755. btnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-above-queue-btn");
  4756. wrapperElem.appendChild(btnElem);
  4757. }
  4758. rightBtnsEl.insertAdjacentElement("beforebegin", wrapperElem);
  4759. }
  4760. catch (err) {
  4761. error("Couldn't add above queue buttons due to an error:", err);
  4762. }
  4763. },
  4764. });
  4765. }
  4766. //#region thumb.overlay
  4767. /** To be changed when the toggle button is pressed - used to invert the state of "showOverlay" */
  4768. let invertOverlay = false;
  4769. async function initThumbnailOverlay() {
  4770. const toggleBtnShown = getFeature("thumbnailOverlayToggleBtnShown");
  4771. if (getFeature("thumbnailOverlayBehavior") === "never" && !toggleBtnShown)
  4772. return;
  4773. // so the script init doesn't keep waiting until a /watch page is loaded
  4774. waitVideoElementReady().then(() => {
  4775. const playerSelector = "ytmusic-player#player";
  4776. const playerEl = document.querySelector(playerSelector);
  4777. if (!playerEl)
  4778. return error("Couldn't find video player element while adding thumbnail overlay");
  4779. /** Checks and updates the overlay and toggle button states based on the current song type (yt video or ytm song) */
  4780. const updateOverlayVisibility = async () => {
  4781. if (!UserUtils.isDomLoaded())
  4782. return;
  4783. const behavior = getFeature("thumbnailOverlayBehavior");
  4784. let showOverlay = behavior === "always";
  4785. const isVideo = getCurrentMediaType() === "video";
  4786. if (behavior === "videosOnly" && isVideo)
  4787. showOverlay = true;
  4788. else if (behavior === "songsOnly" && !isVideo)
  4789. showOverlay = true;
  4790. showOverlay = invertOverlay ? !showOverlay : showOverlay;
  4791. const overlayElem = document.querySelector("#bytm-thumbnail-overlay");
  4792. const thumbElem = document.querySelector("#bytm-thumbnail-overlay-img");
  4793. const indicatorElem = document.querySelector("#bytm-thumbnail-overlay-indicator");
  4794. if (overlayElem)
  4795. overlayElem.style.display = showOverlay ? "block" : "none";
  4796. if (thumbElem)
  4797. thumbElem.ariaHidden = String(!showOverlay);
  4798. if (indicatorElem) {
  4799. indicatorElem.style.display = showOverlay ? "block" : "none";
  4800. indicatorElem.ariaHidden = String(!showOverlay);
  4801. }
  4802. if (getFeature("thumbnailOverlayToggleBtnShown")) {
  4803. addSelectorListener("playerBarMiddleButtons", "#bytm-thumbnail-overlay-toggle", {
  4804. async listener(toggleBtnElem) {
  4805. var _a;
  4806. const toggleBtnIconElem = toggleBtnElem.querySelector("svg");
  4807. if (toggleBtnIconElem) {
  4808. setInnerHtml(toggleBtnElem, await resourceAsString(`icon-image${showOverlay ? "_filled" : ""}`));
  4809. (_a = toggleBtnElem.querySelector("svg")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-generic-btn-img");
  4810. }
  4811. if (toggleBtnElem)
  4812. toggleBtnElem.ariaLabel = toggleBtnElem.title = t(`thumbnail_overlay_toggle_btn_tooltip${showOverlay ? "_hide" : "_show"}`);
  4813. },
  4814. });
  4815. }
  4816. };
  4817. const applyThumbUrl = async (watchId) => {
  4818. try {
  4819. const thumbUrl = await getBestThumbnailUrl(watchId);
  4820. if (thumbUrl) {
  4821. const toggleBtnElem = document.querySelector("#bytm-thumbnail-overlay-toggle");
  4822. const thumbImgElem = document.querySelector("#bytm-thumbnail-overlay-img");
  4823. if ((toggleBtnElem === null || toggleBtnElem === void 0 ? void 0 : toggleBtnElem.href) === thumbUrl && (thumbImgElem === null || thumbImgElem === void 0 ? void 0 : thumbImgElem.src) === thumbUrl)
  4824. return;
  4825. if (toggleBtnElem)
  4826. toggleBtnElem.href = thumbUrl;
  4827. if (thumbImgElem)
  4828. thumbImgElem.src = thumbUrl;
  4829. log("Applied thumbnail URL to overlay:", thumbUrl);
  4830. }
  4831. else
  4832. error("Couldn't get thumbnail URL for watch ID", watchId);
  4833. }
  4834. catch (err) {
  4835. error("Couldn't apply thumbnail URL to overlay due to an error:", err);
  4836. }
  4837. };
  4838. const unsubWatchIdChanged = siteEvents.on("watchIdChanged", (watchId) => {
  4839. unsubWatchIdChanged();
  4840. addSelectorListener("body", "#bytm-thumbnail-overlay", {
  4841. listener: () => {
  4842. applyThumbUrl(watchId);
  4843. updateOverlayVisibility();
  4844. },
  4845. });
  4846. });
  4847. const createElements = async () => {
  4848. var _a;
  4849. try {
  4850. // overlay
  4851. const overlayElem = document.createElement("div");
  4852. overlayElem.id = "bytm-thumbnail-overlay";
  4853. overlayElem.title = ""; // prevent child titles from propagating
  4854. overlayElem.classList.add("bytm-no-select");
  4855. overlayElem.style.display = "none";
  4856. let indicatorElem;
  4857. if (getFeature("thumbnailOverlayShowIndicator")) {
  4858. indicatorElem = document.createElement("img");
  4859. indicatorElem.id = "bytm-thumbnail-overlay-indicator";
  4860. indicatorElem.src = await getResourceUrl("icon-image");
  4861. indicatorElem.role = "presentation";
  4862. indicatorElem.title = indicatorElem.ariaLabel = t("thumbnail_overlay_indicator_tooltip");
  4863. indicatorElem.ariaHidden = "true";
  4864. indicatorElem.style.display = "none";
  4865. indicatorElem.style.opacity = String(getFeature("thumbnailOverlayIndicatorOpacity") / 100);
  4866. }
  4867. const thumbImgElem = document.createElement("img");
  4868. thumbImgElem.id = "bytm-thumbnail-overlay-img";
  4869. thumbImgElem.role = "presentation";
  4870. thumbImgElem.ariaHidden = "true";
  4871. thumbImgElem.style.objectFit = getFeature("thumbnailOverlayImageFit");
  4872. overlayElem.appendChild(thumbImgElem);
  4873. playerEl.appendChild(overlayElem);
  4874. indicatorElem && playerEl.appendChild(indicatorElem);
  4875. siteEvents.on("watchIdChanged", async (watchId) => {
  4876. invertOverlay = false;
  4877. applyThumbUrl(watchId);
  4878. updateOverlayVisibility();
  4879. });
  4880. const params = new URL(location.href).searchParams;
  4881. if (params.has("v")) {
  4882. applyThumbUrl(params.get("v"));
  4883. updateOverlayVisibility();
  4884. }
  4885. // toggle button
  4886. if (toggleBtnShown) {
  4887. const toggleBtnElem = createRipple(document.createElement("a"));
  4888. toggleBtnElem.id = "bytm-thumbnail-overlay-toggle";
  4889. toggleBtnElem.role = "button";
  4890. toggleBtnElem.tabIndex = 0;
  4891. toggleBtnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-no-select");
  4892. onInteraction(toggleBtnElem, (e) => {
  4893. if (e.shiftKey)
  4894. return openInTab(toggleBtnElem.href, false);
  4895. invertOverlay = !invertOverlay;
  4896. updateOverlayVisibility();
  4897. });
  4898. setInnerHtml(toggleBtnElem, await resourceAsString("icon-image"));
  4899. (_a = toggleBtnElem.querySelector("svg")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-generic-btn-img");
  4900. addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", {
  4901. listener: (likeContainer) => likeContainer.insertAdjacentElement("afterend", toggleBtnElem),
  4902. });
  4903. }
  4904. log("Added thumbnail overlay");
  4905. }
  4906. catch (err) {
  4907. error("Couldn't create thumbnail overlay elements due to an error:", err);
  4908. }
  4909. };
  4910. addSelectorListener("mainPanel", playerSelector, {
  4911. listener(playerEl) {
  4912. if (playerEl.getAttribute("player-ui-state") === "INACTIVE") {
  4913. const obs = new MutationObserver(() => {
  4914. if (playerEl.getAttribute("player-ui-state") === "INACTIVE")
  4915. return;
  4916. createElements();
  4917. obs.disconnect();
  4918. });
  4919. obs.observe(playerEl, {
  4920. attributes: true,
  4921. attributeFilter: ["player-ui-state"],
  4922. });
  4923. }
  4924. else
  4925. createElements();
  4926. },
  4927. });
  4928. });
  4929. }
  4930. //#region idle hide cursor
  4931. async function initHideCursorOnIdle() {
  4932. addSelectorListener("mainPanel", "ytmusic-player#player", {
  4933. listener(vidContainer) {
  4934. const overlaySelector = "ytmusic-player #song-media-window";
  4935. const overlayElem = document.querySelector(overlaySelector);
  4936. if (!overlayElem)
  4937. return warn("Couldn't find overlay element while initializing cursor hiding");
  4938. /** Timer after which the cursor is hidden */
  4939. let cursorHideTimer;
  4940. /** Timer for the opacity transition while switching to the hidden state */
  4941. let hideTransTimer;
  4942. const hide = () => {
  4943. if (!getFeature("hideCursorOnIdle"))
  4944. return;
  4945. if (vidContainer.classList.contains("bytm-cursor-hidden"))
  4946. return;
  4947. overlayElem.style.opacity = ".000001 !important";
  4948. hideTransTimer = setTimeout(() => {
  4949. overlayElem.style.display = "none";
  4950. vidContainer.style.cursor = "none";
  4951. vidContainer.classList.add("bytm-cursor-hidden");
  4952. hideTransTimer = undefined;
  4953. }, 200);
  4954. };
  4955. const show = () => {
  4956. hideTransTimer && clearTimeout(hideTransTimer);
  4957. if (!vidContainer.classList.contains("bytm-cursor-hidden"))
  4958. return;
  4959. vidContainer.classList.remove("bytm-cursor-hidden");
  4960. vidContainer.style.cursor = "initial";
  4961. overlayElem.style.display = "initial";
  4962. overlayElem.style.opacity = "1 !important";
  4963. };
  4964. const cursorHideTimerCb = () => cursorHideTimer = setTimeout(hide, getFeature("hideCursorOnIdleDelay") * 1000);
  4965. const onMove = () => {
  4966. cursorHideTimer && clearTimeout(cursorHideTimer);
  4967. show();
  4968. cursorHideTimerCb();
  4969. };
  4970. vidContainer.addEventListener("mouseenter", onMove);
  4971. vidContainer.addEventListener("mousemove", UserUtils.debounce(onMove, 200));
  4972. vidContainer.addEventListener("mouseleave", () => {
  4973. cursorHideTimer && clearTimeout(cursorHideTimer);
  4974. hideTransTimer && clearTimeout(hideTransTimer);
  4975. hide();
  4976. });
  4977. vidContainer.addEventListener("click", () => {
  4978. show();
  4979. cursorHideTimerCb();
  4980. setTimeout(hide, 3000);
  4981. });
  4982. log("Initialized cursor hiding on idle");
  4983. },
  4984. });
  4985. }
  4986. //#region fix HDR
  4987. /** Prevents visual issues when using HDR */
  4988. async function fixHdrIssues() {
  4989. if (!await addStyleFromResource("css-fix_hdr"))
  4990. error("Couldn't load stylesheet to fix HDR issues");
  4991. else
  4992. log("Fixed HDR issues");
  4993. }
  4994. //#region show vote nums
  4995. /** Shows the amount of likes and dislikes on the current song */
  4996. async function initShowVotes() {
  4997. addSelectorListener("playerBar", ".middle-controls-buttons ytmusic-like-button-renderer", {
  4998. async listener(voteCont) {
  4999. try {
  5000. const videoID = getWatchId();
  5001. if (!videoID) {
  5002. await siteEvents.once("watchIdChanged");
  5003. return initShowVotes();
  5004. }
  5005. const voteObj = await fetchVideoVotes(videoID);
  5006. if (!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj))
  5007. return error("Couldn't fetch votes from the Return YouTube Dislike API");
  5008. if (getFeature("showVotes")) {
  5009. addVoteNumbers(voteCont, voteObj);
  5010. siteEvents.on("watchIdChanged", async (watchId) => {
  5011. var _a, _b;
  5012. const labelLikes = document.querySelector("ytmusic-like-button-renderer .bytm-vote-label.likes");
  5013. const labelDislikes = document.querySelector("ytmusic-like-button-renderer .bytm-vote-label.dislikes");
  5014. if (!labelLikes || !labelDislikes)
  5015. return error("Couldn't find vote label elements while updating like and dislike counts");
  5016. if (labelLikes.dataset.watchId === watchId && labelDislikes.dataset.watchId === watchId)
  5017. return log("Vote labels already updated for this video");
  5018. const voteObj = await fetchVideoVotes(watchId);
  5019. if (!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj))
  5020. return error("Couldn't fetch votes from the Return YouTube Dislike API");
  5021. const likesLabelText = tp("vote_label_likes", voteObj.likes, formatNumber(voteObj.likes, "long"));
  5022. const dislikesLabelText = tp("vote_label_dislikes", voteObj.dislikes, formatNumber(voteObj.dislikes, "long"));
  5023. labelLikes.dataset.watchId = (_a = getWatchId()) !== null && _a !== void 0 ? _a : "";
  5024. labelLikes.textContent = formatNumber(voteObj.likes);
  5025. labelLikes.title = labelLikes.ariaLabel = likesLabelText;
  5026. labelDislikes.textContent = formatNumber(voteObj.dislikes);
  5027. labelDislikes.title = labelDislikes.ariaLabel = dislikesLabelText;
  5028. labelDislikes.dataset.watchId = (_b = getWatchId()) !== null && _b !== void 0 ? _b : "";
  5029. addSelectorListener("playerBar", "ytmusic-like-button-renderer#like-button-renderer", {
  5030. listener: (bar) => upsertVoteBtnLabels(bar, likesLabelText, dislikesLabelText),
  5031. });
  5032. });
  5033. }
  5034. }
  5035. catch (err) {
  5036. error("Couldn't initialize show votes feature due to an error:", err);
  5037. }
  5038. }
  5039. });
  5040. }
  5041. function addVoteNumbers(voteCont, voteObj) {
  5042. const likeBtn = voteCont.querySelector("#button-shape-like");
  5043. const dislikeBtn = voteCont.querySelector("#button-shape-dislike");
  5044. if (!likeBtn || !dislikeBtn)
  5045. return error("Couldn't find like or dislike button while adding vote numbers");
  5046. const createLabel = (amount, type) => {
  5047. var _a;
  5048. const label = document.createElement("span");
  5049. label.classList.add("bytm-vote-label", "bytm-no-select", type);
  5050. label.textContent = String(formatNumber(amount));
  5051. label.title = label.ariaLabel = tp(`vote_label_${type}`, amount, formatNumber(amount, "long"));
  5052. label.dataset.watchId = (_a = getWatchId()) !== null && _a !== void 0 ? _a : "";
  5053. label.addEventListener("click", (e) => {
  5054. var _a;
  5055. e.preventDefault();
  5056. e.stopPropagation();
  5057. (_a = (type === "likes" ? likeBtn : dislikeBtn).querySelector("button")) === null || _a === void 0 ? void 0 : _a.click();
  5058. });
  5059. return label;
  5060. };
  5061. addStyleFromResource("css-show_votes")
  5062. .catch((e) => error("Couldn't add CSS for show votes feature due to an error:", e));
  5063. const likeLblEl = createLabel(voteObj.likes, "likes");
  5064. likeBtn.insertAdjacentElement("afterend", likeLblEl);
  5065. const dislikeLblEl = createLabel(voteObj.dislikes, "dislikes");
  5066. dislikeBtn.insertAdjacentElement("afterend", dislikeLblEl);
  5067. upsertVoteBtnLabels(voteCont, likeLblEl.title, dislikeLblEl.title);
  5068. log("Added vote number labels to like and dislike buttons");
  5069. }
  5070. /** Updates or inserts the labels on the native like and dislike buttons */
  5071. function upsertVoteBtnLabels(parentEl, likesLabelText, dislikesLabelText) {
  5072. const likeBtn = parentEl.querySelector("#button-shape-like button");
  5073. const dislikeBtn = parentEl.querySelector("#button-shape-dislike button");
  5074. if (likeBtn)
  5075. likeBtn.title = likeBtn.ariaLabel = likesLabelText;
  5076. if (dislikeBtn)
  5077. dislikeBtn.title = dislikeBtn.ariaLabel = dislikesLabelText;
  5078. }
  5079. //#region watch page full size
  5080. /** Makes the watch page full size */
  5081. async function initWatchPageFullSize() {
  5082. if (!await addStyleFromResource("css-watch_page_full_size"))
  5083. error("Couldn't load stylesheet to make watch page full size");
  5084. else
  5085. log("Made watch page full size");
  5086. }async function initHotkeys() {
  5087. const promises = [];
  5088. if (getFeature("likeDislikeHotkeys"))
  5089. promises.push(initLikeDislikeHotkeys());
  5090. if (getFeature("switchBetweenSites"))
  5091. promises.push(initSiteSwitch());
  5092. return await Promise.allSettled(promises);
  5093. }
  5094. function keyPressed(e, hk) {
  5095. return e.code === hk.code && e.shiftKey === hk.shift && e.ctrlKey === hk.ctrl && e.altKey === hk.alt;
  5096. }
  5097. //#region site switch
  5098. /** switch sites only if current video time is greater than this value */
  5099. const videoTimeThreshold = 3;
  5100. let siteSwitchEnabled = true;
  5101. /** Initializes the site switch feature */
  5102. async function initSiteSwitch() {
  5103. const domain = getDomain();
  5104. document.addEventListener("keydown", (e) => {
  5105. var _a, _b;
  5106. if (!getFeature("switchBetweenSites"))
  5107. return;
  5108. if (inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : ""))
  5109. return;
  5110. if (siteSwitchEnabled && keyPressed(e, getFeature("switchSitesHotkey")))
  5111. switchSite(domain === "yt" ? "ytm" : "yt");
  5112. });
  5113. siteEvents.on("hotkeyInputActive", (state) => {
  5114. if (!getFeature("switchBetweenSites"))
  5115. return;
  5116. siteSwitchEnabled = !state;
  5117. });
  5118. log("Initialized site switch listener");
  5119. }
  5120. /** Switches to the other site (between YT and YTM) */
  5121. async function switchSite(newDomain) {
  5122. try {
  5123. if (!(["/watch", "/playlist"].some(v => location.pathname.startsWith(v))))
  5124. return warn("Not on a supported page, so the site switch is ignored");
  5125. let subdomain;
  5126. if (newDomain === "ytm")
  5127. subdomain = "music";
  5128. else if (newDomain === "yt")
  5129. subdomain = "www";
  5130. if (!subdomain)
  5131. throw new Error(`Unrecognized domain '${newDomain}'`);
  5132. enableDiscardBeforeUnload();
  5133. const { pathname, search, hash } = new URL(location.href);
  5134. const vt = await getVideoTime(0);
  5135. log(`Found video time of ${vt} seconds`);
  5136. const cleanSearch = search.split("&")
  5137. .filter((param) => !param.match(/^\??(t|time_continue)=/))
  5138. .join("&");
  5139. const newSearch = typeof vt === "number" && vt > videoTimeThreshold ?
  5140. cleanSearch.includes("?")
  5141. ? `${cleanSearch.startsWith("?")
  5142. ? cleanSearch
  5143. : "?" + cleanSearch}&time_continue=${vt}`
  5144. : `?time_continue=${vt}`
  5145. : cleanSearch;
  5146. const newUrl = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`;
  5147. info(`Switching to domain '${newDomain}' at ${newUrl}`);
  5148. location.assign(newUrl);
  5149. }
  5150. catch (err) {
  5151. error("Error while switching site:", err);
  5152. }
  5153. }
  5154. //#region like/dislike
  5155. async function initLikeDislikeHotkeys() {
  5156. document.addEventListener("keydown", (e) => {
  5157. var _a, _b;
  5158. if (!getFeature("likeDislikeHotkeys"))
  5159. return;
  5160. if (inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : ""))
  5161. return;
  5162. const { likeBtn, dislikeBtn } = getLikeDislikeBtns();
  5163. if (keyPressed(e, getFeature("likeHotkey")))
  5164. likeBtn === null || likeBtn === void 0 ? void 0 : likeBtn.click();
  5165. else if (keyPressed(e, getFeature("dislikeHotkey")))
  5166. dislikeBtn === null || dislikeBtn === void 0 ? void 0 : dislikeBtn.click();
  5167. });
  5168. }//#region Dark Reader
  5169. /** Disables Dark Reader if it is present */
  5170. async function disableDarkReader() {
  5171. if (getFeature("disableDarkReaderSites") !== getDomain() && getFeature("disableDarkReaderSites") !== "all")
  5172. return;
  5173. const metaElem = document.createElement("meta");
  5174. metaElem.name = "darkreader-lock";
  5175. metaElem.id = "bytm-disable-dark-reader";
  5176. document.head.appendChild(metaElem);
  5177. info("Disabled Dark Reader");
  5178. }
  5179. //#region SponsorBlock
  5180. /** Fixes the z-index of the SponsorBlock panel */
  5181. async function fixSponsorBlock() {
  5182. try {
  5183. return addStyleFromResource("css-fix_sponsorblock");
  5184. }
  5185. catch (err) {
  5186. error("Failed to fix SponsorBlock styling:", err);
  5187. }
  5188. }
  5189. //#region ThemeSong
  5190. /** Adjust the BetterYTM styles if ThemeSong is ***not*** used */
  5191. async function fixPlayerPageTheming() {
  5192. try {
  5193. return addStyleFromResource("css-fix_playerpage_theming");
  5194. }
  5195. catch (err) {
  5196. error("Failed to fix BetterYTM player page theming:", err);
  5197. }
  5198. }
  5199. /** Sets the lightness of the theme color used by BYTM according to the configured lightness value */
  5200. async function fixThemeSong() {
  5201. try {
  5202. const cssVarName = (() => {
  5203. switch (getFeature("themeSongLightness")) {
  5204. default:
  5205. case "darker":
  5206. return "--ts-palette-darkmuted-hex";
  5207. case "normal":
  5208. return "--ts-palette-muted-hex";
  5209. case "lighter":
  5210. return "--ts-palette-lightmuted-hex";
  5211. }
  5212. ;
  5213. })();
  5214. document.documentElement.style.setProperty("--bytm-themesong-bg-accent-col", `var(${cssVarName})`);
  5215. }
  5216. catch (err) {
  5217. error("Failed to set ThemeSong integration color lightness:", err);
  5218. }
  5219. }/** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */
  5220. const geniUrlRatelimitTimeframe = 30;
  5221. //#region media control bar
  5222. let currentSongTitle = "";
  5223. /** Adds a lyrics button to the player bar */
  5224. async function addPlayerBarLyricsBtn() {
  5225. addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", { listener: addActualLyricsBtn });
  5226. }
  5227. /** Actually adds the lyrics button after the like button renderer has been verified to exist */
  5228. async function addActualLyricsBtn(likeContainer) {
  5229. const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
  5230. if (!songTitleElem)
  5231. return warn("Couldn't find song title element");
  5232. currentSongTitle = songTitleElem.title;
  5233. const onMutation = async (mutations) => {
  5234. var _a, e_1, _b, _c;
  5235. var _d, _e, _f;
  5236. try {
  5237. for (var _g = true, mutations_1 = __asyncValues(mutations), mutations_1_1; mutations_1_1 = await mutations_1.next(), _a = mutations_1_1.done, !_a; _g = true) {
  5238. _c = mutations_1_1.value;
  5239. _g = false;
  5240. const mut = _c;
  5241. const newTitle = mut.target.title;
  5242. if (newTitle !== currentSongTitle && newTitle.length > 0) {
  5243. const lyricsBtn = document.querySelector("#bytm-player-bar-lyrics-btn");
  5244. if (!lyricsBtn)
  5245. continue;
  5246. lyricsBtn.style.cursor = "wait";
  5247. lyricsBtn.style.pointerEvents = "none";
  5248. setInnerHtml(lyricsBtn, await resourceAsString("icon-spinner"));
  5249. (_d = lyricsBtn.querySelector("svg")) === null || _d === void 0 ? void 0 : _d.classList.add("bytm-generic-btn-img", "bytm-spinner");
  5250. currentSongTitle = newTitle;
  5251. const url = await getCurrentLyricsUrl(); // can take a second or two
  5252. setInnerHtml(lyricsBtn, await resourceAsString("icon-lyrics"));
  5253. (_e = lyricsBtn.querySelector("svg")) === null || _e === void 0 ? void 0 : _e.classList.add("bytm-generic-btn-img");
  5254. if (!url) {
  5255. let artist, song;
  5256. if ("mediaSession" in navigator && navigator.mediaSession.metadata) {
  5257. artist = navigator.mediaSession.metadata.artist;
  5258. song = navigator.mediaSession.metadata.title;
  5259. }
  5260. const query = artist && song ? "?q=" + encodeURIComponent(sanitizeArtists(artist) + " - " + sanitizeSong(song)) : "";
  5261. setInnerHtml(lyricsBtn, await resourceAsString("icon-error"));
  5262. (_f = lyricsBtn.querySelector("svg")) === null || _f === void 0 ? void 0 : _f.classList.add("bytm-generic-btn-img");
  5263. lyricsBtn.ariaLabel = lyricsBtn.title = t("lyrics_not_found_click_open_search");
  5264. lyricsBtn.style.cursor = "pointer";
  5265. lyricsBtn.style.pointerEvents = "all";
  5266. lyricsBtn.style.display = "inline-flex";
  5267. lyricsBtn.style.visibility = "visible";
  5268. lyricsBtn.href = `https://genius.com/search${query}`;
  5269. continue;
  5270. }
  5271. lyricsBtn.href = url;
  5272. lyricsBtn.ariaLabel = lyricsBtn.title = t("open_current_lyrics");
  5273. lyricsBtn.style.cursor = "pointer";
  5274. lyricsBtn.style.visibility = "visible";
  5275. lyricsBtn.style.display = "inline-flex";
  5276. lyricsBtn.style.pointerEvents = "initial";
  5277. }
  5278. }
  5279. }
  5280. catch (e_1_1) { e_1 = { error: e_1_1 }; }
  5281. finally {
  5282. try {
  5283. if (!_g && !_a && (_b = mutations_1.return)) await _b.call(mutations_1);
  5284. }
  5285. finally { if (e_1) throw e_1.error; }
  5286. }
  5287. };
  5288. // 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
  5289. const obs = new MutationObserver(onMutation);
  5290. obs.observe(songTitleElem, { attributes: true, attributeFilter: ["title"] });
  5291. const lyricsBtnElem = await createLyricsBtn(undefined);
  5292. lyricsBtnElem.id = "bytm-player-bar-lyrics-btn";
  5293. // run parallel so the element is inserted as soon as possible
  5294. getCurrentLyricsUrl().then(url => {
  5295. url && addGeniusUrlToLyricsBtn(lyricsBtnElem, url);
  5296. });
  5297. log("Inserted lyrics button into media controls bar");
  5298. const thumbToggleElem = document.querySelector("#bytm-thumbnail-overlay-toggle");
  5299. if (thumbToggleElem)
  5300. thumbToggleElem.insertAdjacentElement("afterend", lyricsBtnElem);
  5301. else
  5302. likeContainer.insertAdjacentElement("afterend", lyricsBtnElem);
  5303. }
  5304. //#region lyrics utils
  5305. /** Removes everything in parentheses from the passed song name */
  5306. function sanitizeSong(songName) {
  5307. if (typeof songName !== "string")
  5308. return songName;
  5309. const parensRegex = /\(.+\)/gmi;
  5310. const squareParensRegex = /\[.+\]/gmi;
  5311. // trim right after the song name:
  5312. const sanitized = songName
  5313. .replace(parensRegex, "")
  5314. .replace(squareParensRegex, "");
  5315. return sanitized.trim();
  5316. }
  5317. /** Removes the secondary artist (if it exists) from the passed artists string */
  5318. function sanitizeArtists(artists) {
  5319. artists = artists.split(/\s*\u2022\s*/gmiu)[0]; // split at &bull; [•] character
  5320. if (artists.match(/&/))
  5321. artists = artists.split(/\s*&\s*/gm)[0];
  5322. if (artists.match(/,/))
  5323. artists = artists.split(/,\s*/gm)[0];
  5324. if (artists.match(/(f(ea)?t\.?|Remix|Edit|Flip|Cover|Night\s?Core|Bass\s?Boost|pro?d\.?)/i)) {
  5325. const parensRegex = /\(.+\)/gmi;
  5326. const squareParensRegex = /\[.+\]/gmi;
  5327. artists = artists
  5328. .replace(parensRegex, "")
  5329. .replace(squareParensRegex, "");
  5330. }
  5331. return artists.trim();
  5332. }
  5333. /** Returns the lyrics URL from genius for the currently selected song */
  5334. async function getCurrentLyricsUrl() {
  5335. try {
  5336. // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
  5337. const isVideo = getCurrentMediaType() === "video";
  5338. const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
  5339. const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string :first-child");
  5340. if (!songTitleElem || !songMetaElem)
  5341. return undefined;
  5342. const songNameRaw = songTitleElem.title;
  5343. let songName = songNameRaw;
  5344. let artistName = songMetaElem.textContent;
  5345. if (isVideo) {
  5346. // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
  5347. if (songName.includes("-")) {
  5348. const split = splitVideoTitle(songName);
  5349. songName = split.song;
  5350. artistName = split.artist;
  5351. }
  5352. }
  5353. if (!artistName)
  5354. return undefined;
  5355. const url = await fetchLyricsUrlTop(sanitizeArtists(artistName), sanitizeSong(songName));
  5356. if (url) {
  5357. emitInterface("bytm:lyricsLoaded", {
  5358. type: "current",
  5359. artists: artistName,
  5360. title: songName,
  5361. url,
  5362. });
  5363. }
  5364. return url;
  5365. }
  5366. catch (err) {
  5367. getFeature("errorOnLyricsNotFound") && error("Couldn't resolve lyrics URL:", err);
  5368. return undefined;
  5369. }
  5370. }
  5371. /** Fetches the top lyrics URL result from geniURL - **the passed parameters need to be sanitized first!** */
  5372. async function fetchLyricsUrlTop(artist, song) {
  5373. var _a, _b;
  5374. try {
  5375. return (_b = (_a = (await fetchLyricsUrls(artist, song))) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.url;
  5376. }
  5377. catch (err) {
  5378. getFeature("errorOnLyricsNotFound") && error("Couldn't get lyrics URL due to error:", err);
  5379. return undefined;
  5380. }
  5381. }
  5382. /**
  5383. * Fetches the 5 best matching lyrics URLs from geniURL using a combo exact-ish and fuzzy search
  5384. * **the passed parameters need to be sanitized first!**
  5385. */
  5386. async function fetchLyricsUrls(artist, song) {
  5387. var _a, _b, _c;
  5388. try {
  5389. const cacheEntry = getLyricsCacheEntry(artist, song);
  5390. if (cacheEntry) {
  5391. info(`Found lyrics URL in cache: ${cacheEntry.url}`);
  5392. return [cacheEntry];
  5393. }
  5394. const fetchUrl = constructUrl(`${getFeature("geniUrlBase")}/search`, {
  5395. disableFuzzy: null,
  5396. utm_source: `${scriptInfo.name} v${scriptInfo.version}${mode === "development" ? "-pre" : ""}`,
  5397. q: `${artist} ${song}`,
  5398. });
  5399. log("Requesting lyrics from geniURL:", fetchUrl);
  5400. const token = getFeature("geniUrlToken");
  5401. const fetchRes = await UserUtils.fetchAdvanced(fetchUrl, Object.assign({}, (token ? {
  5402. headers: {
  5403. Authorization: `Bearer ${token}`,
  5404. },
  5405. } : {})));
  5406. if (fetchRes.status === 429) {
  5407. const waitSeconds = Number((_a = fetchRes.headers.get("retry-after")) !== null && _a !== void 0 ? _a : geniUrlRatelimitTimeframe);
  5408. await showPrompt({ type: "alert", message: tp("lyrics_rate_limited", waitSeconds, waitSeconds) });
  5409. return undefined;
  5410. }
  5411. else if (fetchRes.status < 200 || fetchRes.status >= 300) {
  5412. getFeature("errorOnLyricsNotFound") && error(new LyricsError(`Couldn't fetch lyrics URLs from geniURL - status: ${fetchRes.status} - response: ${(_c = (_b = (await fetchRes.json()).message) !== null && _b !== void 0 ? _b : await fetchRes.text()) !== null && _c !== void 0 ? _c : "(none)"}`));
  5413. return undefined;
  5414. }
  5415. const result = await fetchRes.json();
  5416. if (typeof result === "object" && result.error || !result || !result.all) {
  5417. getFeature("errorOnLyricsNotFound") && error(new LyricsError(`Couldn't fetch lyrics URLs from geniURL: ${result.message}`));
  5418. return undefined;
  5419. }
  5420. const allResults = result.all;
  5421. if (allResults.length === 0) {
  5422. warn("No lyrics URL found for the provided song");
  5423. return undefined;
  5424. }
  5425. const allResultsSan = allResults
  5426. .filter(({ meta, url }) => (meta.title || meta.fullTitle) && meta.artists && url)
  5427. .map(({ meta, url }) => {
  5428. var _a;
  5429. return ({
  5430. meta: Object.assign(Object.assign({}, meta), { title: sanitizeSong(String((_a = meta.title) !== null && _a !== void 0 ? _a : meta.fullTitle)), artists: sanitizeArtists(String(meta.artists)) }),
  5431. url,
  5432. });
  5433. });
  5434. const topRes = allResultsSan[0];
  5435. topRes && addLyricsCacheEntryBest(topRes.meta.artists, topRes.meta.title, topRes.url);
  5436. return allResultsSan.map(r => ({
  5437. artist: r.meta.primaryArtist.name,
  5438. song: r.meta.title,
  5439. url: r.url,
  5440. }));
  5441. }
  5442. catch (err) {
  5443. getFeature("errorOnLyricsNotFound") && error("Couldn't get lyrics URL due to error:", err);
  5444. return undefined;
  5445. }
  5446. }
  5447. /** Adds the genius URL to the passed lyrics button element if it was previously instantiated with an undefined URL */
  5448. async function addGeniusUrlToLyricsBtn(btnElem, geniusUrl) {
  5449. btnElem.href = geniusUrl;
  5450. btnElem.ariaLabel = btnElem.title = t("open_lyrics");
  5451. btnElem.style.visibility = "visible";
  5452. btnElem.style.display = "inline-flex";
  5453. }
  5454. /** Creates the base lyrics button element */
  5455. async function createLyricsBtn(geniusUrl, hideIfLoading = true) {
  5456. var _a;
  5457. const linkElem = document.createElement("a");
  5458. linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn");
  5459. linkElem.ariaLabel = linkElem.title = t("lyrics_loading");
  5460. linkElem.role = "button";
  5461. linkElem.target = "_blank";
  5462. linkElem.rel = "noopener noreferrer";
  5463. linkElem.style.visibility = "hidden";
  5464. linkElem.style.display = "none";
  5465. onInteraction(linkElem, (e) => {
  5466. var _a;
  5467. const url = (_a = linkElem.href) !== null && _a !== void 0 ? _a : geniusUrl;
  5468. if (!url || e instanceof MouseEvent)
  5469. return;
  5470. if (!e.ctrlKey && !e.altKey)
  5471. openInTab(url);
  5472. }, {
  5473. preventDefault: false,
  5474. stopPropagation: false,
  5475. });
  5476. setInnerHtml(linkElem, await resourceAsString("icon-lyrics"));
  5477. (_a = linkElem.querySelector("svg")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-generic-btn-img");
  5478. onInteraction(linkElem, async (e) => {
  5479. if (e.ctrlKey || e.altKey) {
  5480. e.preventDefault();
  5481. e.stopImmediatePropagation();
  5482. const search = await showPrompt({ type: "prompt", message: t("open_lyrics_search_prompt") });
  5483. if (search && search.length > 0)
  5484. openInTab(`https://genius.com/search?q=${encodeURIComponent(search)}`);
  5485. }
  5486. }, {
  5487. preventDefault: false,
  5488. stopPropagation: false,
  5489. });
  5490. return linkElem;
  5491. }
  5492. /** Splits a video title that contains a hyphen into an artist and song */
  5493. function splitVideoTitle(title) {
  5494. const [artist, ...rest] = title.split("-").map((v, i) => i < 2 ? v.trim() : v);
  5495. return { artist, song: rest.join("-") };
  5496. }//#region init queue btns
  5497. /** Initializes the queue buttons */
  5498. async function initQueueButtons() {
  5499. const addCurrentQueueBtns = (evt) => {
  5500. let amt = 0;
  5501. for (const queueItm of evt.childNodes) {
  5502. if (!queueItm.classList.contains("bytm-has-queue-btns")) {
  5503. addQueueButtons(queueItm, undefined, "currentQueue");
  5504. amt++;
  5505. }
  5506. }
  5507. if (amt > 0)
  5508. log(`Added buttons to ${amt} new queue ${UserUtils.autoPlural("item", amt)}`);
  5509. };
  5510. // current queue
  5511. siteEvents.on("queueChanged", addCurrentQueueBtns);
  5512. siteEvents.on("autoplayQueueChanged", addCurrentQueueBtns);
  5513. const queueItems = document.querySelectorAll("#contents.ytmusic-player-queue > ytmusic-player-queue-item");
  5514. if (queueItems.length > 0) {
  5515. queueItems.forEach(itm => addQueueButtons(itm, undefined, "currentQueue"));
  5516. log(`Added buttons to ${queueItems.length} existing "current song queue" ${UserUtils.autoPlural("item", queueItems)}`);
  5517. }
  5518. // generic lists
  5519. const addGenericListQueueBtns = (listElem) => {
  5520. const queueItems = listElem.querySelectorAll("ytmusic-responsive-list-item-renderer");
  5521. if (queueItems.length === 0)
  5522. return;
  5523. let addedBtnsCount = 0;
  5524. queueItems.forEach(itm => {
  5525. if (itm.classList.contains("bytm-has-btns"))
  5526. return;
  5527. itm.classList.add("bytm-has-btns");
  5528. addQueueButtons(itm, ".flex-columns", "genericList", ["bytm-generic-list-queue-btn-container"], "afterParent");
  5529. addedBtnsCount++;
  5530. });
  5531. addedBtnsCount > 0 &&
  5532. log(`Added buttons to ${addedBtnsCount} new "generic song list" ${UserUtils.autoPlural("item", addedBtnsCount)} in list`, listElem);
  5533. };
  5534. const listSelector = `\
  5535. ytmusic-playlist-shelf-renderer #contents,
  5536. ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_ALBUM"] ytmusic-shelf-renderer #contents,
  5537. ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_ARTIST"] ytmusic-shelf-renderer #contents,
  5538. ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_PLAYLIST"] ytmusic-shelf-renderer #contents\
  5539. `;
  5540. if (getFeature("listButtonsPlacement") === "everywhere") {
  5541. const checkAddGenericBtns = (songLists) => {
  5542. for (const list of songLists)
  5543. addGenericListQueueBtns(list);
  5544. };
  5545. addSelectorListener("body", listSelector, {
  5546. all: true,
  5547. continuous: true,
  5548. debounce: 150,
  5549. listener: checkAddGenericBtns,
  5550. });
  5551. siteEvents.on("pathChanged", () => {
  5552. const songLists = document.querySelectorAll(listSelector);
  5553. if (songLists.length > 0)
  5554. checkAddGenericBtns(songLists);
  5555. });
  5556. }
  5557. }
  5558. //#region add queue btns
  5559. /**
  5560. * Adds the buttons to each item in the current song queue.
  5561. * Also observes for changes to add new buttons to new items in the queue.
  5562. * @param queueItem The element with tagname `ytmusic-player-queue-item` or `ytmusic-responsive-list-item-renderer` to add queue buttons to
  5563. * @param listType The type of list the queue item is in
  5564. * @param classes Extra CSS classes to apply to the container
  5565. * @param insertPosition Where to insert the button container in relation to the parent element
  5566. */
  5567. async function addQueueButtons(queueItem, containerParentSelector = ".song-info", listType = "currentQueue", classes = [], insertPosition = "child") {
  5568. const queueBtnsCont = document.createElement("div");
  5569. queueBtnsCont.classList.add(...["bytm-queue-btn-container", ...classes]);
  5570. const lyricsIconUrl = await getResourceUrl("icon-lyrics");
  5571. const deleteIconUrl = await getResourceUrl("icon-delete");
  5572. const spinnerIconUrl = await getResourceUrl("icon-spinner");
  5573. await UserUtils.preloadImages([lyricsIconUrl, deleteIconUrl, spinnerIconUrl]);
  5574. //#region lyrics btn
  5575. let lyricsBtnElem;
  5576. if (getFeature("lyricsQueueButton")) {
  5577. lyricsBtnElem = await createLyricsBtn(undefined, false);
  5578. lyricsBtnElem.classList.add("bytm-song-list-item-btn");
  5579. lyricsBtnElem.ariaLabel = lyricsBtnElem.title = t("open_lyrics");
  5580. lyricsBtnElem.style.display = "inline-flex";
  5581. lyricsBtnElem.style.visibility = "initial";
  5582. lyricsBtnElem.style.pointerEvents = "initial";
  5583. lyricsBtnElem.role = "link";
  5584. lyricsBtnElem.tabIndex = 0;
  5585. onInteraction(lyricsBtnElem, async (e) => {
  5586. var _a, _b;
  5587. e.preventDefault();
  5588. e.stopImmediatePropagation();
  5589. let song, artist;
  5590. if (listType === "currentQueue") {
  5591. const songInfo = queueItem.querySelector(".song-info");
  5592. if (!songInfo)
  5593. return error("Couldn't find song info element in queue item", queueItem);
  5594. const [songEl, artistEl] = songInfo.querySelectorAll("yt-formatted-string");
  5595. song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent;
  5596. artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
  5597. }
  5598. else if (listType === "genericList") {
  5599. const songEl = queueItem.querySelector(".title-column yt-formatted-string a");
  5600. let artistEl = null;
  5601. if (location.pathname.startsWith("/playlist"))
  5602. artistEl = document.querySelector("ytmusic-detail-header-renderer .metadata .subtitle-container yt-formatted-string a");
  5603. if (!artistEl || !artistEl.textContent)
  5604. artistEl = queueItem.querySelector(".secondary-flex-columns yt-formatted-string:first-child a");
  5605. song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent;
  5606. artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
  5607. if (!artist) {
  5608. // new playlist design
  5609. artistEl = document.querySelector("ytmusic-responsive-header-renderer .strapline a.yt-formatted-string[href]");
  5610. artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
  5611. }
  5612. }
  5613. else
  5614. return error("Invalid list type:", listType);
  5615. if (!song || !artist)
  5616. return error("Couldn't get song or artist name from queue item - song:", song, "- artist:", artist);
  5617. let lyricsUrl;
  5618. const artistsSan = sanitizeArtists(artist);
  5619. const songSan = sanitizeSong(song);
  5620. const splitTitle = splitVideoTitle(songSan);
  5621. const cachedLyricsEntry = songSan.includes("-")
  5622. ? getLyricsCacheEntry(splitTitle.artist, splitTitle.song)
  5623. : getLyricsCacheEntry(artistsSan, songSan);
  5624. if (cachedLyricsEntry)
  5625. lyricsUrl = cachedLyricsEntry.url;
  5626. else if (!queueItem.hasAttribute("data-bytm-loading")) {
  5627. const imgEl = lyricsBtnElem === null || lyricsBtnElem === void 0 ? void 0 : lyricsBtnElem.querySelector("img, svg");
  5628. if (!cachedLyricsEntry) {
  5629. queueItem.setAttribute("data-bytm-loading", "");
  5630. if (imgEl) {
  5631. if (imgEl.tagName === "IMG") {
  5632. imgEl.src = await getResourceUrl("icon-spinner");
  5633. imgEl === null || imgEl === void 0 ? void 0 : imgEl.classList.add("bytm-spinner");
  5634. }
  5635. else if (lyricsBtnElem) {
  5636. setInnerHtml(lyricsBtnElem, await resourceAsString("icon-spinner"));
  5637. (_a = lyricsBtnElem.querySelector("svg")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-generic-btn-img", "bytm-spinner");
  5638. }
  5639. }
  5640. }
  5641. lyricsUrl = (_b = cachedLyricsEntry === null || cachedLyricsEntry === void 0 ? void 0 : cachedLyricsEntry.url) !== null && _b !== void 0 ? _b : await fetchLyricsUrlTop(artistsSan, songSan);
  5642. if (lyricsUrl) {
  5643. emitInterface("bytm:lyricsLoaded", {
  5644. type: "queue",
  5645. artists: artist,
  5646. title: song,
  5647. url: lyricsUrl,
  5648. });
  5649. }
  5650. const resetImgElem = async () => {
  5651. var _a;
  5652. if (imgEl) {
  5653. if (imgEl.tagName === "IMG") {
  5654. imgEl.src = lyricsIconUrl;
  5655. imgEl === null || imgEl === void 0 ? void 0 : imgEl.classList.remove("bytm-spinner");
  5656. }
  5657. else if (lyricsBtnElem) {
  5658. setInnerHtml(lyricsBtnElem, await resourceAsString("icon-lyrics"));
  5659. (_a = lyricsBtnElem.querySelector("svg")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-generic-btn-img");
  5660. }
  5661. }
  5662. };
  5663. if (!cachedLyricsEntry) {
  5664. queueItem.removeAttribute("data-bytm-loading");
  5665. // so the new image doesn't "blink"
  5666. setTimeout(resetImgElem, 100);
  5667. }
  5668. if (!lyricsUrl) {
  5669. resetImgElem();
  5670. if (await showPrompt({ type: "confirm", message: t("lyrics_not_found_confirm_open_search") }))
  5671. openInTab(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} - ${songSan}`)}`);
  5672. return;
  5673. }
  5674. }
  5675. lyricsUrl && openInTab(lyricsUrl);
  5676. });
  5677. }
  5678. //#region delete btn
  5679. let deleteBtnElem;
  5680. if (getFeature("deleteFromQueueButton")) {
  5681. deleteBtnElem = document.createElement("a");
  5682. deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("remove_from_queue") : t("delete_from_list"));
  5683. deleteBtnElem.classList.add("ytmusic-player-bar", "bytm-delete-from-queue", "bytm-generic-btn", "bytm-song-list-item-btn");
  5684. deleteBtnElem.role = "button";
  5685. deleteBtnElem.tabIndex = 0;
  5686. deleteBtnElem.style.visibility = "initial";
  5687. const delImgElem = document.createElement("img");
  5688. delImgElem.classList.add("bytm-generic-btn-img");
  5689. delImgElem.src = deleteIconUrl;
  5690. onInteraction(deleteBtnElem, async (e) => {
  5691. e.preventDefault();
  5692. e.stopImmediatePropagation();
  5693. delImgElem.src = spinnerIconUrl;
  5694. delImgElem.classList.add("bytm-spinner");
  5695. // container of the queue item popup menu - element gets reused for every queue item
  5696. let queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
  5697. try {
  5698. // three dots button to open the popup menu of a queue item
  5699. const dotsBtnElem = queueItem.querySelector("ytmusic-menu-renderer yt-button-shape[id=\"button-shape\"] button");
  5700. if (dotsBtnElem) {
  5701. if (queuePopupCont)
  5702. queuePopupCont.setAttribute("data-bytm-hidden", "true");
  5703. dotsBtnElem.click();
  5704. }
  5705. else {
  5706. info("Couldn't find three dots button in queue item, trying to open the context menu manually");
  5707. queueItem.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: false }));
  5708. }
  5709. queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
  5710. queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.setAttribute("data-bytm-hidden", "true");
  5711. await UserUtils.pauseFor(15);
  5712. delImgElem.src = deleteIconUrl;
  5713. delImgElem.classList.remove("bytm-spinner");
  5714. const removeFromQueueBtn = queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.querySelector("tp-yt-paper-listbox ytmusic-menu-service-item-renderer:nth-of-type(3)");
  5715. removeFromQueueBtn === null || removeFromQueueBtn === void 0 ? void 0 : removeFromQueueBtn.click();
  5716. // queue items aren't removed automatically outside of the current queue
  5717. if (removeFromQueueBtn && listType === "genericList") {
  5718. await UserUtils.pauseFor(200);
  5719. clearInner(queueItem);
  5720. queueItem.remove();
  5721. }
  5722. if (!removeFromQueueBtn) {
  5723. 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.");
  5724. dotsBtnElem === null || dotsBtnElem === void 0 ? void 0 : dotsBtnElem.click();
  5725. delImgElem.src = await getResourceUrl("icon-error");
  5726. if (deleteBtnElem)
  5727. deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("couldnt_remove_from_queue") : t("couldnt_delete_from_list"));
  5728. }
  5729. }
  5730. catch (err) {
  5731. error("Couldn't remove song from queue due to error:", err);
  5732. }
  5733. finally {
  5734. queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.removeAttribute("data-bytm-hidden");
  5735. }
  5736. });
  5737. deleteBtnElem.appendChild(delImgElem);
  5738. }
  5739. lyricsBtnElem && queueBtnsCont.appendChild(createRipple(lyricsBtnElem));
  5740. deleteBtnElem && queueBtnsCont.appendChild(createRipple(deleteBtnElem));
  5741. const parentEl = queueItem.querySelector(containerParentSelector);
  5742. if (insertPosition === "child")
  5743. parentEl === null || parentEl === void 0 ? void 0 : parentEl.appendChild(queueBtnsCont);
  5744. else if (insertPosition === "beforeParent")
  5745. parentEl === null || parentEl === void 0 ? void 0 : parentEl.before(queueBtnsCont);
  5746. else if (insertPosition === "afterParent")
  5747. parentEl === null || parentEl === void 0 ? void 0 : parentEl.after(queueBtnsCont);
  5748. queueItem.classList.add("bytm-has-queue-btns");
  5749. }//#region init vol features
  5750. /** Initializes all volume-related features */
  5751. async function initVolumeFeatures() {
  5752. let listenerOnce = false;
  5753. // sliderElem is not technically an input element but behaves pretty much the same
  5754. const listener = async (type, sliderElem) => {
  5755. const volSliderCont = document.createElement("div");
  5756. volSliderCont.classList.add("bytm-vol-slider-cont");
  5757. if (getFeature("volumeSliderScrollStep") !== featInfo.volumeSliderScrollStep.default)
  5758. initScrollStep(volSliderCont, sliderElem);
  5759. UserUtils.addParent(sliderElem, volSliderCont);
  5760. if (getFeature("volumeSliderLabel"))
  5761. await addVolumeSliderLabel(type, sliderElem, volSliderCont);
  5762. setVolSliderStep(sliderElem);
  5763. if (getFeature("volumeSharedBetweenTabs"))
  5764. sliderElem.addEventListener("change", () => sharedVolumeChanged(Number(sliderElem.value)));
  5765. if (listenerOnce)
  5766. return;
  5767. listenerOnce = true;
  5768. // the following are only run once:
  5769. setInitialTabVolume(sliderElem);
  5770. if (typeof getFeature("volumeSliderSize") === "number")
  5771. setVolSliderSize();
  5772. if (getFeature("volumeSharedBetweenTabs"))
  5773. checkSharedVolume();
  5774. };
  5775. addSelectorListener("playerBarRightControls", "tp-yt-paper-slider#volume-slider", {
  5776. listener: (el) => listener("normal", el),
  5777. });
  5778. let sizeSmOnce = false;
  5779. const onResize = () => {
  5780. if (sizeSmOnce || window.innerWidth >= 1150)
  5781. return;
  5782. sizeSmOnce = true;
  5783. addSelectorListener("playerBarRightControls", "ytmusic-player-expanding-menu tp-yt-paper-slider#expand-volume-slider", {
  5784. listener: (el) => listener("expand", el),
  5785. });
  5786. };
  5787. window.addEventListener("resize", UserUtils.debounce(onResize, 150));
  5788. waitVideoElementReady().then(onResize);
  5789. onResize();
  5790. }
  5791. //#region scroll step
  5792. /** Initializes the volume slider scroll step feature */
  5793. function initScrollStep(volSliderCont, sliderElem) {
  5794. for (const evtName of ["wheel", "scroll", "mousewheel", "DOMMouseScroll"]) {
  5795. volSliderCont.addEventListener(evtName, (e) => {
  5796. var _a, _b;
  5797. e.preventDefault();
  5798. // cancels all the other events that would be fired
  5799. e.stopImmediatePropagation();
  5800. const delta = Number((_b = (_a = e.deltaY) !== null && _a !== void 0 ? _a : e === null || e === void 0 ? void 0 : e.detail) !== null && _b !== void 0 ? _b : 1);
  5801. if (isNaN(delta))
  5802. return warn("Invalid scroll delta:", delta);
  5803. const volumeDir = -Math.sign(delta);
  5804. const newVolume = String(Number(sliderElem.value) + (getFeature("volumeSliderScrollStep") * volumeDir));
  5805. sliderElem.value = newVolume;
  5806. sliderElem.setAttribute("aria-valuenow", newVolume);
  5807. // make the site actually change the volume
  5808. sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
  5809. }, {
  5810. // takes precedence over the slider's own event listener
  5811. capture: true,
  5812. });
  5813. }
  5814. }
  5815. //#region volume slider label
  5816. /** Adds a percentage label to the volume slider and tooltip */
  5817. async function addVolumeSliderLabel(type, sliderElem, sliderContainer) {
  5818. const labelContElem = document.createElement("div");
  5819. labelContElem.classList.add("bytm-vol-slider-label");
  5820. labelContElem.style.display = "none";
  5821. labelContElem.setAttribute("aria-hidden", "true");
  5822. const volShared = getFeature("volumeSharedBetweenTabs");
  5823. if (volShared) {
  5824. const linkIconHtml = await resourceAsString("icon-link");
  5825. if (linkIconHtml) {
  5826. const linkIconElem = document.createElement("div");
  5827. linkIconElem.classList.add("bytm-vol-slider-shared");
  5828. setInnerHtml(linkIconElem, linkIconHtml);
  5829. linkIconElem.role = "alert";
  5830. linkIconElem.ariaLive = "polite";
  5831. linkIconElem.title = linkIconElem.ariaLabel = t("volume_shared_tooltip");
  5832. labelContElem.classList.add("has-icon");
  5833. labelContElem.appendChild(linkIconElem);
  5834. }
  5835. }
  5836. const getLabel = (value) => `${value}%`;
  5837. const labelElem = document.createElement("div");
  5838. labelElem.classList.add("label");
  5839. labelElem.textContent = getLabel(sliderElem.value);
  5840. labelContElem.appendChild(labelElem);
  5841. // prevent video from minimizing
  5842. labelContElem.addEventListener("click", (e) => e.stopPropagation());
  5843. labelContElem.addEventListener("keydown", (e) => ["Enter", "Space", " "].includes(e.key) && e.stopPropagation());
  5844. const getLabelText = (slider) => { var _a; return t("volume_tooltip", slider.value, (_a = getFeature("volumeSliderStep")) !== null && _a !== void 0 ? _a : slider.step); };
  5845. const labelFull = getLabelText(sliderElem);
  5846. sliderContainer.setAttribute("title", labelFull);
  5847. sliderElem.setAttribute("title", labelFull);
  5848. sliderElem.setAttribute("aria-valuetext", labelFull);
  5849. const updateLabel = () => {
  5850. const labelFull = getLabelText(sliderElem);
  5851. sliderContainer.setAttribute("title", labelFull);
  5852. sliderElem.setAttribute("title", labelFull);
  5853. sliderElem.setAttribute("aria-valuetext", labelFull);
  5854. const labelElem2 = document.querySelectorAll(".bytm-vol-slider-label div.label");
  5855. for (const el of labelElem2)
  5856. el.textContent = getLabel(sliderElem.value);
  5857. };
  5858. sliderElem.addEventListener("change", updateLabel);
  5859. siteEvents.on("configChanged", updateLabel);
  5860. addSelectorListener("playerBarRightControls", type === "normal" ? ".bytm-vol-slider-cont" : "ytmusic-player-expanding-menu .bytm-vol-slider-cont", {
  5861. listener: (volumeCont) => volumeCont.appendChild(labelContElem),
  5862. });
  5863. let lastSliderVal = Number(sliderElem.value);
  5864. // show label if hovering over slider or slider is focused
  5865. const sliderHoverObserver = new MutationObserver(() => {
  5866. if (sliderElem.classList.contains("on-hover") || document.activeElement === sliderElem) {
  5867. labelContElem.style.display = "initial";
  5868. labelContElem.setAttribute("aria-hidden", "false");
  5869. labelContElem.classList.add("bytm-visible");
  5870. }
  5871. else if (labelContElem.classList.contains("bytm-visible") || document.activeElement !== sliderElem) {
  5872. labelContElem.addEventListener("transitionend", () => {
  5873. labelContElem.style.display = "none";
  5874. labelContElem.setAttribute("aria-hidden", "true");
  5875. }, { once: true });
  5876. labelContElem.classList.remove("bytm-visible");
  5877. }
  5878. if (Number(sliderElem.value) !== lastSliderVal) {
  5879. lastSliderVal = Number(sliderElem.value);
  5880. updateLabel();
  5881. }
  5882. });
  5883. sliderHoverObserver.observe(sliderElem, {
  5884. attributes: true,
  5885. });
  5886. }
  5887. //#region volume slider size
  5888. /** Sets the volume slider to a set size */
  5889. function setVolSliderSize() {
  5890. const size = getFeature("volumeSliderSize");
  5891. if (typeof size !== "number" || isNaN(Number(size)))
  5892. return error("Invalid volume slider size:", size);
  5893. setGlobalCssVar("vol-slider-size", `${size}px`);
  5894. addStyleFromResource("css-vol_slider_size");
  5895. }
  5896. //#region volume slider step
  5897. /** Sets the `step` attribute of the volume slider */
  5898. function setVolSliderStep(sliderElem) {
  5899. sliderElem.setAttribute("step", String(getFeature("volumeSliderStep")));
  5900. }
  5901. //#region shared volume
  5902. /** Saves the shared volume level to persistent storage */
  5903. async function sharedVolumeChanged(vol) {
  5904. try {
  5905. await GM.setValue("bytm-shared-volume", String(lastCheckedSharedVolume = ignoreVal = vol));
  5906. }
  5907. catch (err) {
  5908. error("Couldn't save shared volume level due to an error:", err);
  5909. }
  5910. }
  5911. let ignoreVal = -1;
  5912. let lastCheckedSharedVolume = -1;
  5913. /** Only call once as this calls itself after a timeout! - Checks if the shared volume has changed and updates the volume slider accordingly */
  5914. async function checkSharedVolume() {
  5915. try {
  5916. const vol = await GM.getValue("bytm-shared-volume");
  5917. if (vol && lastCheckedSharedVolume !== Number(vol)) {
  5918. if (ignoreVal === Number(vol))
  5919. return;
  5920. lastCheckedSharedVolume = Number(vol);
  5921. const sliderElem = document.querySelector("tp-yt-paper-slider#volume-slider");
  5922. if (sliderElem) {
  5923. sliderElem.value = String(vol);
  5924. sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
  5925. }
  5926. }
  5927. setTimeout(checkSharedVolume, 333);
  5928. }
  5929. catch (err) {
  5930. error("Couldn't check for shared volume level due to an error:", err);
  5931. }
  5932. }
  5933. //#region initial volume
  5934. /** Sets the volume slider to a set volume level when the session starts */
  5935. async function setInitialTabVolume(sliderElem) {
  5936. const reloadTabVol = Number(await GM.getValue("bytm-reload-tab-volume", 0));
  5937. GM.deleteValue("bytm-reload-tab-volume").catch(() => void 0);
  5938. if ((isNaN(reloadTabVol) || reloadTabVol === 0) && !getFeature("setInitialTabVolume"))
  5939. return;
  5940. await waitVideoElementReady();
  5941. const initialVol = Math.round(!isNaN(reloadTabVol) && reloadTabVol > 0 ? reloadTabVol : getFeature("initialTabVolumeLevel"));
  5942. if (isNaN(initialVol) || initialVol < 0 || initialVol > 100)
  5943. return;
  5944. if (getFeature("volumeSharedBetweenTabs")) {
  5945. lastCheckedSharedVolume = ignoreVal = initialVol;
  5946. if (getFeature("volumeSharedBetweenTabs"))
  5947. GM.setValue("bytm-shared-volume", String(initialVol)).catch((err) => error("Couldn't save shared volume level due to an error:", err));
  5948. }
  5949. sliderElem.value = String(initialVol);
  5950. sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
  5951. log(`Set initial tab volume to ${initialVol}%${reloadTabVol > 0 ? " (from GM storage)" : " (from configuration)"}`);
  5952. }//#region misc
  5953. /** No-operation function used when `reloadRequired` is set to `false` to explicitly indicate that no `enable` function is needed */
  5954. const noop = () => void 0;
  5955. /** Creates an HTML string for the given adornment properties */
  5956. const getAdornHtml = async (className, title, resource, extraAttributes) => {
  5957. var _a;
  5958. title = title ? await UserUtils.consumeStringGen(title) : undefined;
  5959. extraAttributes = extraAttributes ? await UserUtils.consumeStringGen(extraAttributes) : undefined;
  5960. return `<span class="${className} bytm-adorn-icon" ${title ? `title="${title}" aria-label="${title}"` : ""}${extraAttributes ? ` ${extraAttributes}` : ""}>${(_a = await resourceAsString(resource)) !== null && _a !== void 0 ? _a : ""}</span>`;
  5961. };
  5962. /** Combines multiple async functions or promises that resolve with an adornment HTML string into a single string */
  5963. const combineAdornments = (adornments) => new Promise(async (resolve) => {
  5964. const sortedAdornments = adornments.sort((a, b) => {
  5965. const aIndex = adornmentOrder.get(a) ? adornmentOrder.get(a) : -1;
  5966. const bIndex = adornmentOrder.has(b) ? adornmentOrder.get(b) : -1;
  5967. return aIndex - bIndex;
  5968. });
  5969. const html = [];
  5970. for (const adornment of sortedAdornments) {
  5971. const val = typeof adornment === "function"
  5972. ? await adornment()
  5973. : await adornment;
  5974. val && html.push(val);
  5975. }
  5976. resolve(html.join(""));
  5977. });
  5978. /** Decoration elements that can be added next to the label */
  5979. const adornments = {
  5980. advanced: async () => getAdornHtml("bytm-advanced-mode-icon", t("advanced_feature"), "icon-advanced_mode"),
  5981. experimental: async () => getAdornHtml("bytm-experimental-icon", t("experimental_feature"), "icon-experimental"),
  5982. globe: async () => getAdornHtml("bytm-locale-icon", undefined, "icon-globe_small"),
  5983. alert: async (title) => getAdornHtml("bytm-warning-icon", title, "icon-error", "role=\"alert\""),
  5984. reload: async () => getFeature("advancedMode") ? getAdornHtml("bytm-reload-icon", t("feature_requires_reload"), "icon-reload") : undefined,
  5985. ytmOnly: async () => getAdornHtml("bytm-ytm-only-icon", t("feature_only_works_on_ytm"), "icon-ytm"),
  5986. };
  5987. /** Order of adornment elements in the {@linkcode combineAdornments()} function */
  5988. const adornmentOrder = new Map();
  5989. adornmentOrder.set(adornments.alert, 0);
  5990. adornmentOrder.set(adornments.experimental, 1);
  5991. adornmentOrder.set(adornments.ytmOnly, 2);
  5992. adornmentOrder.set(adornments.globe, 3);
  5993. adornmentOrder.set(adornments.advanced, 4);
  5994. adornmentOrder.set(adornments.reload, 5);
  5995. /** Common options for config items of type "select" */
  5996. const options = {
  5997. siteSelection: () => [
  5998. { value: "all", label: t("site_selection_both_sites") },
  5999. { value: "yt", label: t("site_selection_only_yt") },
  6000. { value: "ytm", label: t("site_selection_only_ytm") },
  6001. ],
  6002. siteSelectionOrNone: () => [
  6003. { value: "all", label: t("site_selection_both_sites") },
  6004. { value: "yt", label: t("site_selection_only_yt") },
  6005. { value: "ytm", label: t("site_selection_only_ytm") },
  6006. { value: "none", label: t("site_selection_none") },
  6007. ],
  6008. locale: () => Object.entries(locales)
  6009. .reduce((a, [locale, { name }]) => {
  6010. return [...a, {
  6011. value: locale,
  6012. label: name,
  6013. }];
  6014. }, [])
  6015. .sort((a, b) => a.label.localeCompare(b.label)),
  6016. colorLightness: () => [
  6017. { value: "darker", label: t("color_lightness_darker") },
  6018. { value: "normal", label: t("color_lightness_normal") },
  6019. { value: "lighter", label: t("color_lightness_lighter") },
  6020. ],
  6021. };
  6022. //#region renderers
  6023. /** Renders a long number with a thousands separator */
  6024. function renderNumberVal(val, maximumFractionDigits = 0) {
  6025. return Number(val).toLocaleString(getLocale().replace(/_/g, "-"), {
  6026. style: "decimal",
  6027. maximumFractionDigits,
  6028. });
  6029. }
  6030. //#region # features
  6031. /**
  6032. * Contains all possible features with their default values and other configuration.
  6033. *
  6034. * **Required props:**
  6035. * <!------------------------------------------------------------------------------------------------------------------------------------------------------------------>
  6036. * | Property | Description |
  6037. * | :----------------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
  6038. * | `type: string` | Type of the feature configuration element - use autocomplete or check `FeatureTypeProps` in `src/types.ts` |
  6039. * | `category: string` | Category of the feature - use autocomplete or check `FeatureCategory` in `src/types.ts` |
  6040. * | `default: unknown` | Default value of the feature - type of the value depends on the given `type` |
  6041. * | `enable(value: unknown): void` | (required if reloadRequired = false) - function that will be called when the feature is enabled / initialized for the first time |
  6042. * | `supportedSites: Domain[]` | On which sites the feature is available - values can be `"yt"` or `"ytm"` |
  6043. * <!------------------------------------------------------------------------------------------------------------------------------------------------------------------>
  6044. *
  6045. *
  6046. * **Optional props:**
  6047. * <!------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------->
  6048. * | Property | Description |
  6049. * | :----------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------|
  6050. * | `disable(newValue: unknown): void` | For type `toggle` only - function that will be called when the feature is disabled - can be a synchronous or asynchronous function |
  6051. * | `change(key: string, prevValue: unknown, newValue: unknown): void` | For types `number`, `select`, `slider` and `hotkey` only - function that will be called when the value is changed |
  6052. * | `click(): void` | For type `button` only - function that will be called when the button is clicked |
  6053. * | `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 |
  6054. * | `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 |
  6055. * | `unit: string \| (val: number) => string` | For types `number` or `slider` only - The unit text that is displayed next to the input element, i.e. " px" - a leading space need to be added too! |
  6056. * | `min: number` | For types `number` or `slider` only - Overwrites the default of the `min` property of the HTML input element |
  6057. * | `max: number` | For types `number` or `slider` only - Overwrites the default of the `max` property of the HTML input element |
  6058. * | `step: number` | For types `number` or `slider` only - Overwrites the default of the `step` property of the HTML input element |
  6059. * | `options: SelectOption[] \| () => SelectOption[]` | For type `select` only - function that returns an array of objects with `value` and `label` properties |
  6060. * | `reloadRequired: boolean` | If true (default), the page needs to be reloaded for the changes to take effect - if false, `enable()` needs to be provided |
  6061. * | `advanced: boolean` | If true, the feature will only be shown if the advanced mode feature has been turned on |
  6062. * | `hidden: boolean` | If true, the feature will not be shown in the settings - default is undefined (false) |
  6063. * | `valueHidden: boolean` | If true, the value of the feature will be hidden in the settings and via the plugin interface - default is undefined (false) |
  6064. * | `normalize(val: unknown): unknown` | Function that will be called to normalize the value before it is saved - useful for trimming strings or other simple operations |
  6065. * | `renderValue(val: string): string` | If provided, is used to render the value's label in the config menu |
  6066. * <!------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------->
  6067. *
  6068. * TODO: go through all features and set as many as possible to reloadRequired = false
  6069. */
  6070. const featInfo = {
  6071. //#region cat:layout
  6072. watermarkEnabled: {
  6073. type: "toggle",
  6074. category: "layout",
  6075. supportedSites: ["ytm"],
  6076. default: true,
  6077. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6078. },
  6079. removeShareTrackingParam: {
  6080. type: "toggle",
  6081. category: "layout",
  6082. supportedSites: ["ytm", "yt"],
  6083. default: true,
  6084. textAdornment: adornments.reload,
  6085. },
  6086. removeShareTrackingParamSites: {
  6087. type: "select",
  6088. category: "layout",
  6089. supportedSites: ["ytm", "yt"],
  6090. options: options.siteSelection,
  6091. default: "all",
  6092. advanced: true,
  6093. textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
  6094. },
  6095. fixSpacing: {
  6096. type: "toggle",
  6097. category: "layout",
  6098. supportedSites: ["ytm"],
  6099. default: true,
  6100. advanced: true,
  6101. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced, adornments.reload]),
  6102. },
  6103. thumbnailOverlayBehavior: {
  6104. type: "select",
  6105. category: "layout",
  6106. supportedSites: ["ytm"],
  6107. options: () => [
  6108. { value: "songsOnly", label: t("thumbnail_overlay_behavior_songs_only") },
  6109. { value: "videosOnly", label: t("thumbnail_overlay_behavior_videos_only") },
  6110. { value: "always", label: t("thumbnail_overlay_behavior_always") },
  6111. { value: "never", label: t("thumbnail_overlay_behavior_never") },
  6112. ],
  6113. default: "songsOnly",
  6114. reloadRequired: false,
  6115. enable: noop,
  6116. textAdornment: adornments.ytmOnly,
  6117. },
  6118. thumbnailOverlayToggleBtnShown: {
  6119. type: "toggle",
  6120. category: "layout",
  6121. supportedSites: ["ytm"],
  6122. default: true,
  6123. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6124. },
  6125. thumbnailOverlayShowIndicator: {
  6126. type: "toggle",
  6127. category: "layout",
  6128. supportedSites: ["ytm"],
  6129. default: true,
  6130. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6131. },
  6132. thumbnailOverlayIndicatorOpacity: {
  6133. type: "slider",
  6134. category: "layout",
  6135. supportedSites: ["ytm"],
  6136. min: 5,
  6137. max: 100,
  6138. step: 5,
  6139. default: 40,
  6140. unit: "%",
  6141. advanced: true,
  6142. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced, adornments.reload]),
  6143. },
  6144. thumbnailOverlayImageFit: {
  6145. type: "select",
  6146. category: "layout",
  6147. supportedSites: ["ytm"],
  6148. options: () => [
  6149. { value: "cover", label: t("thumbnail_overlay_image_fit_crop") },
  6150. { value: "contain", label: t("thumbnail_overlay_image_fit_full") },
  6151. { value: "fill", label: t("thumbnail_overlay_image_fit_stretch") },
  6152. ],
  6153. default: "cover",
  6154. advanced: true,
  6155. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced, adornments.reload]),
  6156. },
  6157. hideCursorOnIdle: {
  6158. type: "toggle",
  6159. category: "layout",
  6160. supportedSites: ["ytm"],
  6161. default: true,
  6162. reloadRequired: false,
  6163. enable: noop,
  6164. textAdornment: adornments.ytmOnly,
  6165. },
  6166. hideCursorOnIdleDelay: {
  6167. type: "slider",
  6168. category: "layout",
  6169. supportedSites: ["ytm"],
  6170. min: 0.5,
  6171. max: 10,
  6172. step: 0.25,
  6173. default: 2,
  6174. unit: "s",
  6175. advanced: true,
  6176. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced]),
  6177. reloadRequired: false,
  6178. enable: noop,
  6179. },
  6180. fixHdrIssues: {
  6181. type: "toggle",
  6182. category: "layout",
  6183. supportedSites: ["ytm"],
  6184. default: true,
  6185. advanced: true,
  6186. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced, adornments.reload]),
  6187. },
  6188. showVotes: {
  6189. type: "toggle",
  6190. category: "layout",
  6191. supportedSites: ["ytm"],
  6192. default: true,
  6193. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6194. },
  6195. watchPageFullSize: {
  6196. type: "toggle",
  6197. category: "layout",
  6198. supportedSites: ["ytm"],
  6199. default: true,
  6200. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6201. },
  6202. // archived idea for future version (shows a bar under the like/dislike buttons that shows the ratio of likes to dislikes):
  6203. // showVoteRatio: {
  6204. // type: "select",
  6205. // category: "layout",
  6206. // supportedSites: ["ytm"],
  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.reload,
  6214. // },
  6215. //#region cat:volume
  6216. volumeSliderLabel: {
  6217. type: "toggle",
  6218. category: "volume",
  6219. supportedSites: ["ytm"],
  6220. default: true,
  6221. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6222. },
  6223. volumeSliderSize: {
  6224. type: "number",
  6225. category: "volume",
  6226. supportedSites: ["ytm"],
  6227. min: 50,
  6228. max: 500,
  6229. step: 5,
  6230. default: 150,
  6231. unit: "px",
  6232. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6233. },
  6234. volumeSliderStep: {
  6235. type: "slider",
  6236. category: "volume",
  6237. supportedSites: ["ytm"],
  6238. min: 1,
  6239. max: 25,
  6240. default: 2,
  6241. unit: "%",
  6242. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6243. },
  6244. volumeSliderScrollStep: {
  6245. type: "slider",
  6246. category: "volume",
  6247. supportedSites: ["ytm"],
  6248. min: 1,
  6249. max: 25,
  6250. default: 4,
  6251. unit: "%",
  6252. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6253. },
  6254. volumeSharedBetweenTabs: {
  6255. type: "toggle",
  6256. category: "volume",
  6257. supportedSites: ["ytm"],
  6258. default: false,
  6259. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6260. },
  6261. setInitialTabVolume: {
  6262. type: "toggle",
  6263. category: "volume",
  6264. supportedSites: ["ytm"],
  6265. default: false,
  6266. textAdornment: () => getFeature("volumeSharedBetweenTabs")
  6267. ? combineAdornments([adornments.ytmOnly, adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reload])
  6268. : combineAdornments([adornments.ytmOnly, adornments.reload]),
  6269. },
  6270. initialTabVolumeLevel: {
  6271. type: "slider",
  6272. category: "volume",
  6273. supportedSites: ["ytm"],
  6274. min: 0,
  6275. max: 100,
  6276. step: 1,
  6277. default: 100,
  6278. unit: "%",
  6279. textAdornment: () => getFeature("volumeSharedBetweenTabs")
  6280. ? combineAdornments([adornments.ytmOnly, adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reload])
  6281. : combineAdornments([adornments.ytmOnly, adornments.reload]),
  6282. reloadRequired: false,
  6283. enable: noop,
  6284. },
  6285. //#region cat:song lists
  6286. lyricsQueueButton: {
  6287. type: "toggle",
  6288. category: "songLists",
  6289. supportedSites: ["ytm"],
  6290. default: true,
  6291. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6292. },
  6293. deleteFromQueueButton: {
  6294. type: "toggle",
  6295. category: "songLists",
  6296. supportedSites: ["ytm"],
  6297. default: true,
  6298. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6299. },
  6300. listButtonsPlacement: {
  6301. type: "select",
  6302. category: "songLists",
  6303. supportedSites: ["ytm"],
  6304. options: () => [
  6305. { value: "queueOnly", label: t("list_button_placement_queue_only") },
  6306. { value: "everywhere", label: t("list_button_placement_everywhere") },
  6307. ],
  6308. default: "everywhere",
  6309. advanced: true,
  6310. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced, adornments.reload]),
  6311. },
  6312. scrollToActiveSongBtn: {
  6313. type: "toggle",
  6314. category: "songLists",
  6315. supportedSites: ["ytm"],
  6316. default: true,
  6317. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6318. },
  6319. clearQueueBtn: {
  6320. type: "toggle",
  6321. category: "songLists",
  6322. supportedSites: ["ytm"],
  6323. default: true,
  6324. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6325. },
  6326. aboveQueueBtnsSticky: {
  6327. type: "toggle",
  6328. category: "songLists",
  6329. supportedSites: ["ytm"],
  6330. default: true,
  6331. advanced: true,
  6332. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced, adornments.reload]),
  6333. },
  6334. //#region cat:behavior
  6335. disableBeforeUnloadPopup: {
  6336. type: "toggle",
  6337. category: "behavior",
  6338. supportedSites: ["ytm", "yt"],
  6339. default: false,
  6340. textAdornment: adornments.reload,
  6341. },
  6342. closeToastsTimeout: {
  6343. type: "number",
  6344. category: "behavior",
  6345. supportedSites: ["ytm", "yt"],
  6346. min: 0,
  6347. max: 30,
  6348. step: 0.5,
  6349. default: 3,
  6350. unit: "s",
  6351. reloadRequired: false,
  6352. enable: noop,
  6353. },
  6354. rememberSongTime: {
  6355. type: "toggle",
  6356. category: "behavior",
  6357. supportedSites: ["ytm", "yt"],
  6358. default: true,
  6359. helpText: () => tp("feature_helptext_rememberSongTime", getFeature("rememberSongTimeMinPlayTime"), getFeature("rememberSongTimeMinPlayTime")),
  6360. textAdornment: adornments.reload,
  6361. },
  6362. rememberSongTimeSites: {
  6363. type: "select",
  6364. category: "behavior",
  6365. supportedSites: ["ytm", "yt"],
  6366. options: options.siteSelection,
  6367. default: "all",
  6368. textAdornment: adornments.reload,
  6369. },
  6370. rememberSongTimeDuration: {
  6371. type: "number",
  6372. category: "behavior",
  6373. supportedSites: ["ytm", "yt"],
  6374. min: 1,
  6375. max: 60 * 60 * 24 * 7,
  6376. step: 1,
  6377. default: 60,
  6378. unit: "s",
  6379. advanced: true,
  6380. textAdornment: adornments.advanced,
  6381. reloadRequired: false,
  6382. enable: noop,
  6383. },
  6384. rememberSongTimeReduction: {
  6385. type: "number",
  6386. category: "behavior",
  6387. supportedSites: ["ytm", "yt"],
  6388. min: 0,
  6389. max: 30,
  6390. step: 0.05,
  6391. default: 0.2,
  6392. unit: "s",
  6393. advanced: true,
  6394. textAdornment: adornments.advanced,
  6395. reloadRequired: false,
  6396. enable: noop,
  6397. },
  6398. rememberSongTimeMinPlayTime: {
  6399. type: "slider",
  6400. category: "behavior",
  6401. supportedSites: ["ytm", "yt"],
  6402. min: 3,
  6403. max: 30,
  6404. step: 0.5,
  6405. default: 10,
  6406. unit: "s",
  6407. advanced: true,
  6408. textAdornment: adornments.advanced,
  6409. reloadRequired: false,
  6410. enable: noop,
  6411. },
  6412. autoScrollToActiveSongMode: {
  6413. type: "select",
  6414. category: "behavior",
  6415. supportedSites: ["ytm"],
  6416. options: () => [
  6417. { value: "never", label: t("auto_scroll_to_active_song_mode_never") },
  6418. { value: "initialPageLoad", label: t("auto_scroll_to_active_song_mode_initial_page_load") },
  6419. { value: "videoChangeAll", label: t("auto_scroll_to_active_song_mode_video_change_all") },
  6420. { value: "videoChangeManual", label: t("auto_scroll_to_active_song_mode_video_change_manual") },
  6421. { value: "videoChangeAuto", label: t("auto_scroll_to_active_song_mode_video_change_auto") },
  6422. ],
  6423. default: "videoChangeManual",
  6424. reloadRequired: false,
  6425. enable: noop,
  6426. textAdornment: adornments.ytmOnly,
  6427. },
  6428. //#region cat:input
  6429. arrowKeySupport: {
  6430. type: "toggle",
  6431. category: "input",
  6432. supportedSites: ["ytm"],
  6433. default: true,
  6434. reloadRequired: false,
  6435. enable: noop,
  6436. textAdornment: adornments.ytmOnly,
  6437. },
  6438. arrowKeySkipBy: {
  6439. type: "slider",
  6440. category: "input",
  6441. supportedSites: ["ytm"],
  6442. min: 0.5,
  6443. max: 30,
  6444. step: 0.5,
  6445. default: 5,
  6446. unit: "s",
  6447. reloadRequired: false,
  6448. enable: noop,
  6449. textAdornment: adornments.ytmOnly,
  6450. },
  6451. arrowKeyVolumeStep: {
  6452. type: "slider",
  6453. category: "input",
  6454. supportedSites: ["ytm"],
  6455. min: 1,
  6456. max: 25,
  6457. step: 1,
  6458. default: 2,
  6459. unit: "%",
  6460. reloadRequired: false,
  6461. enable: noop,
  6462. textAdornment: adornments.ytmOnly,
  6463. },
  6464. frameSkip: {
  6465. type: "toggle",
  6466. category: "input",
  6467. supportedSites: ["ytm"],
  6468. default: true,
  6469. reloadRequired: false,
  6470. enable: noop,
  6471. textAdornment: adornments.ytmOnly,
  6472. },
  6473. frameSkipWhilePlaying: {
  6474. type: "toggle",
  6475. category: "input",
  6476. supportedSites: ["ytm"],
  6477. default: false,
  6478. reloadRequired: false,
  6479. enable: noop,
  6480. advanced: true,
  6481. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced]),
  6482. },
  6483. frameSkipAmount: {
  6484. type: "number",
  6485. category: "input",
  6486. supportedSites: ["ytm"],
  6487. min: 0,
  6488. max: 1,
  6489. step: 0.0001,
  6490. default: 0.0417,
  6491. reloadRequired: false,
  6492. enable: noop,
  6493. advanced: true,
  6494. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced]),
  6495. },
  6496. anchorImprovements: {
  6497. type: "toggle",
  6498. category: "input",
  6499. supportedSites: ["ytm"],
  6500. default: true,
  6501. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6502. },
  6503. numKeysSkipToTime: {
  6504. type: "toggle",
  6505. category: "input",
  6506. supportedSites: ["ytm"],
  6507. default: true,
  6508. reloadRequired: false,
  6509. enable: noop,
  6510. textAdornment: adornments.ytmOnly,
  6511. },
  6512. autoLikeChannels: {
  6513. type: "toggle",
  6514. category: "input",
  6515. supportedSites: ["ytm", "yt"],
  6516. default: true,
  6517. textAdornment: adornments.reload,
  6518. },
  6519. autoLikeChannelToggleBtn: {
  6520. type: "toggle",
  6521. category: "input",
  6522. supportedSites: ["ytm", "yt"],
  6523. default: true,
  6524. reloadRequired: false,
  6525. enable: noop,
  6526. advanced: true,
  6527. textAdornment: adornments.advanced,
  6528. },
  6529. // TODO(v2.2):
  6530. // autoLikePlayerBarToggleBtn: {
  6531. // type: "toggle",
  6532. // category: "input",
  6533. // default: false,
  6534. // textAdornment: adornments.reload,
  6535. // },
  6536. autoLikeTimeout: {
  6537. type: "slider",
  6538. category: "input",
  6539. supportedSites: ["ytm", "yt"],
  6540. min: 3,
  6541. max: 30,
  6542. step: 0.5,
  6543. default: 5,
  6544. unit: "s",
  6545. advanced: true,
  6546. reloadRequired: false,
  6547. enable: noop,
  6548. textAdornment: adornments.advanced,
  6549. },
  6550. autoLikeShowToast: {
  6551. type: "toggle",
  6552. category: "input",
  6553. supportedSites: ["ytm", "yt"],
  6554. default: true,
  6555. reloadRequired: false,
  6556. advanced: true,
  6557. enable: noop,
  6558. textAdornment: adornments.advanced,
  6559. },
  6560. autoLikeOpenMgmtDialog: {
  6561. type: "button",
  6562. category: "input",
  6563. supportedSites: ["ytm", "yt"],
  6564. click: () => getAutoLikeDialog().then(d => d.open()),
  6565. },
  6566. //#region cat:hotkeys
  6567. switchBetweenSites: {
  6568. type: "toggle",
  6569. category: "hotkeys",
  6570. supportedSites: ["ytm", "yt"],
  6571. default: true,
  6572. reloadRequired: false,
  6573. enable: noop,
  6574. },
  6575. switchSitesHotkey: {
  6576. type: "hotkey",
  6577. category: "hotkeys",
  6578. supportedSites: ["ytm", "yt"],
  6579. default: {
  6580. code: "F9",
  6581. shift: false,
  6582. ctrl: false,
  6583. alt: false,
  6584. },
  6585. reloadRequired: false,
  6586. enable: noop,
  6587. },
  6588. likeDislikeHotkeys: {
  6589. type: "toggle",
  6590. category: "hotkeys",
  6591. supportedSites: ["ytm", "yt"],
  6592. default: true,
  6593. reloadRequired: false,
  6594. enable: noop,
  6595. },
  6596. likeHotkey: {
  6597. type: "hotkey",
  6598. category: "hotkeys",
  6599. supportedSites: ["ytm", "yt"],
  6600. default: {
  6601. code: "KeyL",
  6602. shift: false,
  6603. ctrl: false,
  6604. alt: true,
  6605. },
  6606. reloadRequired: false,
  6607. enable: noop,
  6608. },
  6609. dislikeHotkey: {
  6610. type: "hotkey",
  6611. category: "hotkeys",
  6612. supportedSites: ["ytm", "yt"],
  6613. default: {
  6614. code: "KeyL",
  6615. shift: false,
  6616. ctrl: true,
  6617. alt: true,
  6618. },
  6619. reloadRequired: false,
  6620. enable: noop,
  6621. },
  6622. //#region cat:lyrics
  6623. geniusLyrics: {
  6624. type: "toggle",
  6625. category: "lyrics",
  6626. supportedSites: ["ytm"],
  6627. default: true,
  6628. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6629. },
  6630. errorOnLyricsNotFound: {
  6631. type: "toggle",
  6632. category: "lyrics",
  6633. supportedSites: ["ytm"],
  6634. default: false,
  6635. reloadRequired: false,
  6636. enable: noop,
  6637. textAdornment: adornments.ytmOnly,
  6638. },
  6639. geniUrlBase: {
  6640. type: "text",
  6641. category: "lyrics",
  6642. supportedSites: ["ytm"],
  6643. default: "https://api.sv443.net/geniurl",
  6644. normalize: (val) => val.trim().replace(/\/+$/, ""),
  6645. advanced: true,
  6646. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced]),
  6647. reloadRequired: false,
  6648. enable: noop,
  6649. },
  6650. geniUrlToken: {
  6651. type: "text",
  6652. category: "lyrics",
  6653. supportedSites: ["ytm"],
  6654. valueHidden: true,
  6655. default: "",
  6656. normalize: (val) => val.trim(),
  6657. advanced: true,
  6658. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced]),
  6659. reloadRequired: false,
  6660. enable: noop,
  6661. },
  6662. lyricsCacheMaxSize: {
  6663. type: "slider",
  6664. category: "lyrics",
  6665. supportedSites: ["ytm"],
  6666. default: 5000,
  6667. min: 1000,
  6668. max: 50000,
  6669. step: 500,
  6670. unit: (val) => ` ${tp("unit_entries", val)}`,
  6671. renderValue: renderNumberVal,
  6672. advanced: true,
  6673. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced]),
  6674. reloadRequired: false,
  6675. enable: noop,
  6676. },
  6677. lyricsCacheTTL: {
  6678. type: "slider",
  6679. category: "lyrics",
  6680. supportedSites: ["ytm"],
  6681. default: 21,
  6682. min: 1,
  6683. max: 100,
  6684. step: 1,
  6685. unit: (val) => " " + tp("unit_days", val),
  6686. advanced: true,
  6687. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced]),
  6688. reloadRequired: false,
  6689. enable: noop,
  6690. },
  6691. clearLyricsCache: {
  6692. type: "button",
  6693. category: "lyrics",
  6694. supportedSites: ["ytm"],
  6695. async click() {
  6696. const entries = getLyricsCache().length;
  6697. const formattedEntries = entries.toLocaleString(getLocale(), { style: "decimal", maximumFractionDigits: 0 });
  6698. if (await showPrompt({ type: "confirm", message: tp("lyrics_clear_cache_confirm_prompt", entries, formattedEntries) })) {
  6699. await clearLyricsCache();
  6700. await showPrompt({ type: "alert", message: t("lyrics_clear_cache_success") });
  6701. }
  6702. },
  6703. advanced: true,
  6704. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced]),
  6705. },
  6706. // advancedLyricsFilter: {
  6707. // type: "toggle",
  6708. // category: "lyrics",
  6709. // default: false,
  6710. // change: () => setTimeout(async () => await showPrompt({ type: "confirm", message: t("lyrics_cache_changed_clear_confirm") }) && clearLyricsCache(), 200),
  6711. // advanced: true,
  6712. // textAdornment: adornments.experimental,
  6713. // reloadRequired: false,
  6714. // enable: noop,
  6715. // },
  6716. //#region cat:integrations
  6717. disableDarkReaderSites: {
  6718. type: "select",
  6719. category: "integrations",
  6720. supportedSites: ["ytm", "yt"],
  6721. options: options.siteSelectionOrNone,
  6722. default: "all",
  6723. advanced: true,
  6724. textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
  6725. },
  6726. sponsorBlockIntegration: {
  6727. type: "toggle",
  6728. category: "integrations",
  6729. supportedSites: ["ytm"],
  6730. default: true,
  6731. advanced: true,
  6732. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.advanced, adornments.reload]),
  6733. },
  6734. themeSongIntegration: {
  6735. type: "toggle",
  6736. category: "integrations",
  6737. supportedSites: ["ytm"],
  6738. default: false,
  6739. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6740. },
  6741. themeSongLightness: {
  6742. type: "select",
  6743. category: "integrations",
  6744. supportedSites: ["ytm"],
  6745. options: options.colorLightness,
  6746. default: "darker",
  6747. textAdornment: () => combineAdornments([adornments.ytmOnly, adornments.reload]),
  6748. },
  6749. //#region cat:plugins
  6750. openPluginList: {
  6751. type: "button",
  6752. category: "plugins",
  6753. supportedSites: ["ytm", "yt"],
  6754. default: undefined,
  6755. click: () => getPluginListDialog().then(d => d.open()),
  6756. },
  6757. initTimeout: {
  6758. type: "number",
  6759. category: "plugins",
  6760. supportedSites: ["ytm", "yt"],
  6761. min: 3,
  6762. max: 30,
  6763. default: 8,
  6764. step: 0.1,
  6765. unit: "s",
  6766. advanced: true,
  6767. textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
  6768. },
  6769. //#region cat:general
  6770. locale: {
  6771. type: "select",
  6772. category: "general",
  6773. supportedSites: ["ytm", "yt"],
  6774. options: options.locale,
  6775. default: getPreferredLocale(),
  6776. textAdornment: () => combineAdornments([adornments.globe, adornments.reload]),
  6777. },
  6778. localeFallback: {
  6779. type: "toggle",
  6780. category: "general",
  6781. supportedSites: ["ytm", "yt"],
  6782. default: true,
  6783. advanced: true,
  6784. textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
  6785. },
  6786. versionCheck: {
  6787. type: "toggle",
  6788. category: "general",
  6789. supportedSites: ["ytm", "yt"],
  6790. default: true,
  6791. textAdornment: adornments.reload,
  6792. },
  6793. checkVersionNow: {
  6794. type: "button",
  6795. category: "general",
  6796. supportedSites: ["ytm", "yt"],
  6797. click: () => doVersionCheck(true),
  6798. },
  6799. numbersFormat: {
  6800. type: "select",
  6801. category: "general",
  6802. supportedSites: ["ytm", "yt"],
  6803. options: () => [
  6804. { value: "long", label: `${formatNumber(12345678, "long")} (${t("votes_format_long")})` },
  6805. { value: "short", label: `${formatNumber(12345678, "short")} (${t("votes_format_short")})` },
  6806. ],
  6807. default: "short",
  6808. reloadRequired: false,
  6809. enable: noop,
  6810. },
  6811. toastDuration: {
  6812. type: "slider",
  6813. category: "general",
  6814. supportedSites: ["ytm", "yt"],
  6815. min: 0,
  6816. max: 15,
  6817. default: 4,
  6818. step: 0.5,
  6819. unit: "s",
  6820. reloadRequired: false,
  6821. advanced: true,
  6822. textAdornment: adornments.advanced,
  6823. enable: noop,
  6824. change: () => showIconToast({
  6825. message: t("example_toast"),
  6826. iconSrc: getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`),
  6827. }),
  6828. },
  6829. showToastOnGenericError: {
  6830. type: "toggle",
  6831. category: "general",
  6832. supportedSites: ["ytm", "yt"],
  6833. default: true,
  6834. advanced: true,
  6835. textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
  6836. },
  6837. resetConfig: {
  6838. type: "button",
  6839. category: "general",
  6840. supportedSites: ["ytm", "yt"],
  6841. click: promptResetConfig,
  6842. textAdornment: adornments.reload,
  6843. },
  6844. resetEverything: {
  6845. type: "button",
  6846. category: "general",
  6847. supportedSites: ["ytm", "yt"],
  6848. click: async () => {
  6849. if (await showPrompt({
  6850. type: "confirm",
  6851. message: t("reset_everything_confirm"),
  6852. })) {
  6853. await getStoreSerializer().resetStoresData();
  6854. const gmKeys = await GM.listValues();
  6855. await Promise.allSettled(gmKeys.map(key => GM.deleteValue(key)));
  6856. await reloadTab();
  6857. }
  6858. },
  6859. advanced: true,
  6860. textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
  6861. },
  6862. logLevel: {
  6863. type: "select",
  6864. category: "general",
  6865. supportedSites: ["ytm", "yt"],
  6866. options: () => [
  6867. { value: LogLevel.Debug, label: t("log_level_debug") },
  6868. { value: LogLevel.Info, label: t("log_level_info") },
  6869. ],
  6870. default: LogLevel.Info,
  6871. advanced: true,
  6872. textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
  6873. },
  6874. advancedMode: {
  6875. type: "toggle",
  6876. category: "general",
  6877. supportedSites: ["ytm", "yt"],
  6878. default: false,
  6879. textAdornment: () => getFeature("advancedMode") ? adornments.advanced() : undefined,
  6880. change: (_key, prevValue, newValue) => prevValue !== newValue &&
  6881. emitSiteEvent("recreateCfgMenu"),
  6882. },
  6883. };/** If this number is incremented, the features object data will be migrated to the new format */
  6884. const formatVersion = 10;
  6885. const defaultData = UserUtils.purifyObj(Object.keys(featInfo)
  6886. // @ts-ignore
  6887. .filter((ftKey) => { var _a; return ((_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[ftKey]) === null || _a === void 0 ? void 0 : _a.default) !== undefined; })
  6888. .reduce((acc, key) => {
  6889. var _a;
  6890. // @ts-ignore
  6891. acc[key] = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default;
  6892. return acc;
  6893. }, {}));
  6894. /** Config data format migration dictionary */
  6895. const migrations = {
  6896. // 1 -> 2 (<=v1.0)
  6897. 2: (oldData) => {
  6898. if (typeof oldData !== "object" || oldData === null)
  6899. return defaultData;
  6900. const queueBtnsEnabled = Boolean(oldData.queueButtons);
  6901. delete oldData.queueButtons;
  6902. return Object.assign(Object.assign({}, oldData), { deleteFromQueueButton: queueBtnsEnabled, lyricsQueueButton: queueBtnsEnabled });
  6903. },
  6904. // 2 -> 3 (v1.0)
  6905. 3: (oldData) => useDefaultConfig(oldData, [
  6906. "removeShareTrackingParam", "numKeysSkipToTime",
  6907. "fixSpacing", "scrollToActiveSongBtn", "logLevel",
  6908. ]),
  6909. // 3 -> 4 (v1.1)
  6910. 4: (oldData) => {
  6911. var _a, _b, _c, _d;
  6912. const oldSwitchSitesHotkey = oldData.switchSitesHotkey;
  6913. return Object.assign(Object.assign({}, useDefaultConfig(oldData, [
  6914. "rememberSongTime", "rememberSongTimeSites",
  6915. "volumeSliderScrollStep", "locale", "versionCheck",
  6916. ])), { arrowKeySkipBy: 10, switchSitesHotkey: {
  6917. code: (_a = oldSwitchSitesHotkey.key) !== null && _a !== void 0 ? _a : "F9",
  6918. shift: Boolean((_b = oldSwitchSitesHotkey.shift) !== null && _b !== void 0 ? _b : false),
  6919. ctrl: Boolean((_c = oldSwitchSitesHotkey.ctrl) !== null && _c !== void 0 ? _c : false),
  6920. alt: Boolean((_d = oldSwitchSitesHotkey.meta) !== null && _d !== void 0 ? _d : false),
  6921. }, listButtonsPlacement: "queueOnly" });
  6922. },
  6923. // 4 -> 5 (v2.0)
  6924. 5: (oldData) => useDefaultConfig(oldData, [
  6925. "localeFallback", "geniUrlBase", "geniUrlToken",
  6926. "lyricsCacheMaxSize", "lyricsCacheTTL",
  6927. "clearLyricsCache", "advancedMode",
  6928. "checkVersionNow", "advancedLyricsFilter",
  6929. "rememberSongTimeDuration", "rememberSongTimeReduction",
  6930. "rememberSongTimeMinPlayTime", "volumeSharedBetweenTabs",
  6931. "setInitialTabVolume", "initialTabVolumeLevel",
  6932. "thumbnailOverlayBehavior", "thumbnailOverlayToggleBtnShown",
  6933. "thumbnailOverlayShowIndicator", "thumbnailOverlayIndicatorOpacity",
  6934. "thumbnailOverlayImageFit", "removeShareTrackingParamSites",
  6935. "fixHdrIssues", "clearQueueBtn",
  6936. "closeToastsTimeout", "disableDarkReaderSites",
  6937. ]),
  6938. // 5 -> 6 (v2.1)
  6939. 6: (oldData) => {
  6940. const newData = useNewDefaultIfUnchanged(useDefaultConfig(oldData, [
  6941. "autoLikeChannels", "autoLikeChannelToggleBtn",
  6942. "autoLikeTimeout", "autoLikeShowToast",
  6943. "autoLikeOpenMgmtDialog", "showVotes",
  6944. "numbersFormat", "toastDuration",
  6945. "initTimeout",
  6946. // forgot to add this to the migration when adding the feature way before so now will have to do:
  6947. "volumeSliderLabel",
  6948. ]), [
  6949. { key: "rememberSongTimeSites", oldDefault: "ytm" },
  6950. { key: "volumeSliderScrollStep", oldDefault: 10 },
  6951. ]);
  6952. "removeUpgradeTab" in newData && delete newData.removeUpgradeTab;
  6953. "advancedLyricsFilter" in newData && delete newData.advancedLyricsFilter;
  6954. return newData;
  6955. },
  6956. // TODO(v2.2): use default for "autoLikePlayerBarToggleBtn"
  6957. // TODO(v2.2): set autoLikeChannels to true on migration once feature is fully implemented
  6958. // 6 -> 7 (v2.1-dev)
  6959. 7: (oldData) => {
  6960. const newData = useNewDefaultIfUnchanged(useDefaultConfig(oldData, [
  6961. "showToastOnGenericError", "sponsorBlockIntegration",
  6962. "themeSongIntegration", "themeSongLightness",
  6963. "errorOnLyricsNotFound", "openPluginList",
  6964. ]), [
  6965. { key: "toastDuration", oldDefault: 3 },
  6966. ]);
  6967. newData.arrowKeySkipBy = UserUtils.clamp(newData.arrowKeySkipBy, 0.5, 30);
  6968. return newData;
  6969. },
  6970. // 7 -> 8 (v2.1)
  6971. 8: (oldData) => {
  6972. if ("showVotesFormat" in oldData) {
  6973. oldData.numbersFormat = oldData.showVotesFormat;
  6974. delete oldData.showVotesFormat;
  6975. }
  6976. return useDefaultConfig(oldData, [
  6977. "autoLikeChannels"
  6978. ]);
  6979. },
  6980. // 8 -> 9 (v2.2)
  6981. 9: (oldData) => {
  6982. oldData.locale = oldData.locale.replace("_", "-");
  6983. if (oldData.locale === "ja-JA")
  6984. oldData.locale = "ja-JP";
  6985. if (oldData.locale === "en-GB")
  6986. oldData.locale = "en-GB";
  6987. return useDefaultConfig(oldData, [
  6988. "resetEverything",
  6989. // TODO(V2.2):
  6990. // "autoLikePlayerBarToggleBtn",
  6991. ]);
  6992. },
  6993. // 9 -> 10 (v2.3.0)
  6994. 10: (oldData) => useNewDefaultIfUnchanged(useDefaultConfig(oldData, [
  6995. "aboveQueueBtnsSticky", "autoScrollToActiveSongMode",
  6996. "frameSkip", "frameSkipWhilePlaying",
  6997. "frameSkipAmount", "watchPageFullSize",
  6998. "arrowKeyVolumeStep", "likeDislikeHotkeys",
  6999. "likeHotkey", "dislikeHotkey",
  7000. ]), [
  7001. { key: "lyricsCacheMaxSize", oldDefault: 2000 },
  7002. ]),
  7003. };
  7004. /** 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 */
  7005. function useDefaultConfig(baseData, resetKeys) {
  7006. var _a;
  7007. const newData = Object.assign(Object.assign({}, defaultData), (baseData !== null && baseData !== void 0 ? baseData : {}));
  7008. for (const key of resetKeys) // @ts-ignore
  7009. newData[key] = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default; // typescript funny moments
  7010. return newData;
  7011. }
  7012. /**
  7013. * 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.
  7014. * 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.
  7015. * Returns a copy of the object.
  7016. */
  7017. function useNewDefaultIfUnchanged(oldData, defaults) {
  7018. var _a;
  7019. const newData = Object.assign({}, oldData);
  7020. for (const { key, oldDefault } of defaults) {
  7021. // @ts-ignore
  7022. const defaultVal = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default;
  7023. if (newData[key] === oldDefault)
  7024. newData[key] = defaultVal; // we love TS
  7025. }
  7026. return newData;
  7027. }
  7028. let canCompress = true;
  7029. const configStore = new UserUtils.DataStore({
  7030. id: "bytm-config",
  7031. formatVersion,
  7032. defaultData,
  7033. migrations,
  7034. encodeData: (data) => canCompress ? UserUtils.compress(data, compressionFormat, "string") : data,
  7035. decodeData: (data) => canCompress ? UserUtils.decompress(data, compressionFormat, "string") : data,
  7036. });
  7037. /** Initializes the DataStore instance and loads persistent data into memory. Returns a copy of the config object. */
  7038. async function initConfig() {
  7039. canCompress = await compressionSupported();
  7040. const oldFmtVer = Number(await GM.getValue(`_uucfgver-${configStore.id}`, NaN));
  7041. // remove extraneous keys
  7042. let data = fixCfgKeys(await configStore.loadData());
  7043. await configStore.setData(data);
  7044. log(`Initialized feature config DataStore with version ${configStore.formatVersion}`);
  7045. if (isNaN(oldFmtVer))
  7046. info(" !- Config data was initialized with default values");
  7047. else if (oldFmtVer !== configStore.formatVersion) {
  7048. try {
  7049. await configStore.setData(data = fixCfgKeys(data));
  7050. info(` !- Config data was migrated from version ${oldFmtVer} to ${configStore.formatVersion}`);
  7051. }
  7052. catch (err) {
  7053. error(" !- Config data migration failed, falling back to default data:", err);
  7054. await configStore.setData(data = configStore.defaultData);
  7055. }
  7056. }
  7057. emitInterface("bytm:configReady");
  7058. return Object.assign({}, data);
  7059. }
  7060. /**
  7061. * Fixes missing keys in the passed config object with their default values or removes extraneous keys and returns a copy of the fixed object.
  7062. * Returns a copy of the originally passed object if nothing needs to be fixed.
  7063. */
  7064. function fixCfgKeys(cfg) {
  7065. const newCfg = Object.assign({}, cfg);
  7066. const passedKeys = Object.keys(cfg);
  7067. const defaultKeys = Object.keys(defaultData);
  7068. const missingKeys = defaultKeys.filter(k => !passedKeys.includes(k));
  7069. if (missingKeys.length > 0) {
  7070. for (const key of missingKeys)
  7071. newCfg[key] = defaultData[key];
  7072. }
  7073. const extraKeys = passedKeys.filter(k => !defaultKeys.includes(k));
  7074. if (extraKeys.length > 0) {
  7075. for (const key of extraKeys)
  7076. delete newCfg[key];
  7077. }
  7078. return newCfg;
  7079. }
  7080. /** Returns the current feature config from the in-memory cache as a copy */
  7081. function getFeatures() {
  7082. return configStore.getData();
  7083. }
  7084. /** Returns the value of the feature with the given key from the in-memory cache, as a copy */
  7085. function getFeature(key) {
  7086. return configStore.getData()[key];
  7087. }
  7088. /** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */
  7089. function setFeatures(featureConf) {
  7090. const res = configStore.setData(featureConf);
  7091. emitSiteEvent("configChanged", configStore.getData());
  7092. info("Saved new feature config:", featureConf);
  7093. return res;
  7094. }
  7095. /** Saves the default feature config synchronously to the in-memory cache and asynchronously to persistent storage */
  7096. function setDefaultFeatures() {
  7097. const res = configStore.saveDefaultData();
  7098. emitSiteEvent("configChanged", configStore.getData());
  7099. info("Reset feature config to its default values");
  7100. return res;
  7101. }
  7102. async function promptResetConfig() {
  7103. if (await showPrompt({ type: "confirm", message: t("reset_config_confirm") })) {
  7104. closeCfgMenu();
  7105. enableDiscardBeforeUnload();
  7106. await setDefaultFeatures();
  7107. if (location.pathname.startsWith("/watch")) {
  7108. const videoTime = await getVideoTime(0);
  7109. const url = new URL(location.href);
  7110. url.searchParams.delete("t");
  7111. if (videoTime)
  7112. url.searchParams.set("time_continue", String(videoTime));
  7113. location.replace(url.href);
  7114. }
  7115. else
  7116. await reloadTab();
  7117. }
  7118. }
  7119. /** 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 */
  7120. async function clearConfig() {
  7121. await configStore.deleteData();
  7122. info("Deleted config from persistent storage");
  7123. }const { autoPlural, getUnsafeWindow, purifyObj, randomId, NanoEmitter } = UserUtils__namespace;
  7124. /**
  7125. * All functions that can be called on the BYTM interface using `unsafeWindow.BYTM.functionName();` (or `const { functionName } = unsafeWindow.BYTM;`)
  7126. * If prefixed with /\*🔒\*\/, the function is authenticated and requires a token to be passed as the first argument.
  7127. */
  7128. const globalFuncs = purifyObj({
  7129. // meta:
  7130. /*🔒*/ getPluginInfo,
  7131. // bytm-specific:
  7132. getDomain,
  7133. getResourceUrl,
  7134. getSessionId,
  7135. reloadTab,
  7136. // dom:
  7137. setInnerHtml,
  7138. addSelectorListener,
  7139. onInteraction,
  7140. getVideoTime,
  7141. getThumbnailUrl,
  7142. getBestThumbnailUrl,
  7143. waitVideoElementReady,
  7144. getVideoElement,
  7145. getVideoSelector,
  7146. getCurrentMediaType,
  7147. getLikeDislikeBtns,
  7148. // translations:
  7149. /*🔒*/ setLocale: setLocaleInterface,
  7150. getLocale,
  7151. hasKey,
  7152. hasKeyFor,
  7153. t,
  7154. tp,
  7155. tl,
  7156. tlp,
  7157. // feature config:
  7158. /*🔒*/ getFeatures: getFeaturesInterface,
  7159. /*🔒*/ saveFeatures: saveFeaturesInterface,
  7160. getDefaultFeatures: () => JSON.parse(JSON.stringify(defaultData)),
  7161. // lyrics:
  7162. fetchLyricsUrlTop,
  7163. getLyricsCacheEntry,
  7164. sanitizeArtists,
  7165. sanitizeSong,
  7166. // auto-like:
  7167. /*🔒*/ getAutoLikeData: getAutoLikeDataInterface,
  7168. /*🔒*/ saveAutoLikeData: saveAutoLikeDataInterface,
  7169. fetchVideoVotes,
  7170. // components:
  7171. createHotkeyInput,
  7172. createToggleInput,
  7173. createCircularBtn,
  7174. createRipple,
  7175. showToast,
  7176. showIconToast,
  7177. showPrompt,
  7178. // other:
  7179. formatNumber,
  7180. });
  7181. /** Initializes the BYTM interface */
  7182. function initInterface() {
  7183. const props = Object.assign(Object.assign(Object.assign({
  7184. // meta / constants
  7185. mode,
  7186. branch,
  7187. host,
  7188. buildNumber,
  7189. initialParams,
  7190. compressionFormat,
  7191. sessionStorageAvailable }, scriptInfo), globalFuncs), {
  7192. // classes
  7193. NanoEmitter,
  7194. BytmDialog,
  7195. ExImDialog,
  7196. MarkdownDialog,
  7197. // libraries
  7198. UserUtils: UserUtils__namespace,
  7199. compareVersions: compareVersions__namespace });
  7200. for (const [key, value] of Object.entries(props))
  7201. setGlobalProp(key, value);
  7202. log("Initialized BYTM interface");
  7203. }
  7204. /** Sets a global property on the unsafeWindow.BYTM object - ⚠️ use with caution as these props can be accessed by any script on the page! */
  7205. function setGlobalProp(key, value) {
  7206. // use unsafeWindow so the properties are available to plugins outside of the userscript's scope
  7207. const win = getUnsafeWindow();
  7208. if (typeof win.BYTM !== "object")
  7209. win.BYTM = purifyObj({});
  7210. win.BYTM[key] = value;
  7211. }
  7212. /** Emits an event on the BYTM interface */
  7213. function emitInterface(type, ...detail) {
  7214. var _a;
  7215. try {
  7216. getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: (_a = detail === null || detail === void 0 ? void 0 : detail[0]) !== null && _a !== void 0 ? _a : undefined }));
  7217. //@ts-ignore
  7218. emitOnPlugins(type, undefined, ...detail);
  7219. log(`Emitted interface event '${type}'${detail.length > 0 && (detail === null || detail === void 0 ? void 0 : detail[0]) ? " with data:" : ""}`, ...detail);
  7220. }
  7221. catch (err) {
  7222. error(`Couldn't emit interface event '${type}' due to an error:\n`, err);
  7223. }
  7224. }
  7225. //#region register plugins
  7226. /** Map of plugin ID and all registered plugins */
  7227. const registeredPlugins = new Map();
  7228. /** Map of plugin ID to auth token for plugins that have been registered */
  7229. const registeredPluginTokens = new Map();
  7230. let pluginsInitialized = false;
  7231. /** Initializes plugins that have been registered already. Needs to be run after `bytm:ready`! */
  7232. function initPlugins() {
  7233. emitInterface("bytm:registerPlugin", (def) => registerPlugin(def));
  7234. getUnsafeWindow().addEventListener("bytm:ready", () => {
  7235. pluginsInitialized = true;
  7236. if (registeredPlugins.size > 0)
  7237. log(`Registered ${registeredPlugins.size} ${autoPlural("plugin", registeredPlugins.size)}`);
  7238. }, { once: true });
  7239. }
  7240. /** Registers a plugin on the BYTM interface. */
  7241. function registerPlugin(def) {
  7242. var _a, _b;
  7243. // TODO: check perms and ask user for initial activation
  7244. try {
  7245. if (pluginsInitialized)
  7246. throw new PluginError(`Failed to register plugin '${getPluginKey(def)}': BYTM interface has already been initialized - plugins can only be registered after the 'bytm:registerPlugin' event and before the 'bytm:ready' event`);
  7247. const plKey = getPluginKey(def);
  7248. if (registeredPlugins.has(plKey))
  7249. throw new PluginError(`Failed to register plugin '${plKey}': Plugin with the same name and namespace is already registered`);
  7250. const validationErrors = validatePluginDef(def);
  7251. if (validationErrors)
  7252. throw new PluginError(`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- ")}`);
  7253. const events = new NanoEmitter({ publicEmit: true });
  7254. const token = randomId(16, 36, true, true);
  7255. registeredPlugins.set(plKey, {
  7256. def: def,
  7257. events,
  7258. });
  7259. registeredPluginTokens.set(plKey, token);
  7260. info(`Successfully registered plugin '${plKey}'`);
  7261. setTimeout(() => emitOnPlugins("pluginRegistered", (d) => sameDef(d, def), pluginDefToInfo(def)), 1);
  7262. return {
  7263. info: getPluginInfo(token, def),
  7264. events,
  7265. token,
  7266. };
  7267. }
  7268. catch (err) {
  7269. error(`Failed to register plugin '${getPluginKey(def)}':`, err instanceof PluginError ? err : new PluginError(String(err)));
  7270. throw err;
  7271. }
  7272. }
  7273. /** Returns the registered plugins as an array of tuples with the items `[id: string, item: PluginItem]` */
  7274. function getRegisteredPlugins() {
  7275. return [...registeredPlugins.entries()];
  7276. }
  7277. /** Returns the key for a given plugin definition */
  7278. function getPluginKey(plugin) {
  7279. return `${plugin.plugin.namespace}/${plugin.plugin.name}`;
  7280. }
  7281. /** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) or undefined, if undefined is passed */
  7282. function pluginDefToInfo(plugin) {
  7283. return plugin
  7284. ? {
  7285. name: plugin.plugin.name,
  7286. namespace: plugin.plugin.namespace,
  7287. version: plugin.plugin.version,
  7288. }
  7289. : undefined;
  7290. }
  7291. /** Checks whether two plugins are the same, given their resolvable definition objects */
  7292. function sameDef(def1, def2) {
  7293. return getPluginKey(def1) === getPluginKey(def2);
  7294. }
  7295. /** Emits an event on all plugins that match the predicate (all plugins by default) */
  7296. function emitOnPlugins(event, predicate = true, ...data) {
  7297. for (const { def, events } of registeredPlugins.values())
  7298. if (typeof predicate === "boolean" ? predicate : predicate(def))
  7299. events.emit(event, ...data);
  7300. }
  7301. /**
  7302. * Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered.
  7303. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  7304. * @public Intended for general use in plugins.
  7305. */
  7306. function getPluginInfo(...args) {
  7307. var _a;
  7308. if (resolveToken(args[0]) === undefined)
  7309. return undefined;
  7310. return pluginDefToInfo((_a = registeredPlugins.get(typeof args[1] === "string" && typeof args[2] === "undefined"
  7311. ? args[1]
  7312. : args.length === 2
  7313. ? `${args[2]}/${args[1]}`
  7314. : getPluginKey(args[1]))) === null || _a === void 0 ? void 0 : _a.def);
  7315. }
  7316. /** Validates the passed PluginDef object and returns an array of errors - returns undefined if there were no errors - never returns an empty array */
  7317. function validatePluginDef(pluginDef) {
  7318. const errors = [];
  7319. const addNoPropErr = (jsonPath, type) => errors.push(t("plugin_validation_error_no_property", jsonPath, type));
  7320. const addInvalidPropErr = (jsonPath, value, examples) => errors.push(tp("plugin_validation_error_invalid_property", examples, jsonPath, value, `'${examples.join("', '")}'`));
  7321. // def.plugin and its properties:
  7322. typeof pluginDef.plugin !== "object" && addNoPropErr("plugin", "object");
  7323. const { plugin } = pluginDef;
  7324. !(plugin === null || plugin === void 0 ? void 0 : plugin.name) && addNoPropErr("plugin.name", "string");
  7325. !(plugin === null || plugin === void 0 ? void 0 : plugin.namespace) && addNoPropErr("plugin.namespace", "string");
  7326. if (typeof (plugin === null || plugin === void 0 ? void 0 : plugin.version) !== "string")
  7327. addNoPropErr("plugin.version", "MAJOR.MINOR.PATCH");
  7328. else if (!compareVersions__namespace.validateStrict(plugin.version))
  7329. addInvalidPropErr("plugin.version", plugin.version, ["0.0.1", "2.5.21-rc.1"]);
  7330. return errors.length > 0 ? errors : undefined;
  7331. }
  7332. /** Checks whether the passed token is a valid auth token for any registered plugin and returns the plugin ID, else returns undefined */
  7333. function resolveToken(token) {
  7334. var _a, _b;
  7335. return typeof token === "string" && token.length > 0
  7336. ? (_b = (_a = [...registeredPluginTokens.entries()]
  7337. .find(([k, t]) => registeredPlugins.has(k) && token === t)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : undefined
  7338. : undefined;
  7339. }
  7340. //#region proxy funcs
  7341. /**
  7342. * Sets the new locale on the BYTM interface
  7343. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  7344. */
  7345. function setLocaleInterface(token, locale) {
  7346. const pluginId = resolveToken(token);
  7347. if (pluginId === undefined)
  7348. return;
  7349. setLocale(locale);
  7350. emitInterface("bytm:setLocale", { pluginId, locale });
  7351. }
  7352. /**
  7353. * Returns the current feature config, with sensitive values replaced by `undefined`
  7354. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  7355. */
  7356. function getFeaturesInterface(token) {
  7357. if (resolveToken(token) === undefined)
  7358. return undefined;
  7359. const features = getFeatures();
  7360. for (const ftKey of Object.keys(features)) {
  7361. const info = featInfo[ftKey];
  7362. if (info && info.valueHidden) // @ts-ignore
  7363. features[ftKey] = undefined;
  7364. }
  7365. return features;
  7366. }
  7367. /**
  7368. * Saves the passed feature config synchronously to the in-memory cache and asynchronously to the persistent storage.
  7369. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  7370. */
  7371. function saveFeaturesInterface(token, features) {
  7372. if (resolveToken(token) === undefined)
  7373. return;
  7374. setFeatures(features);
  7375. }
  7376. /**
  7377. * Returns the auto-like data.
  7378. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  7379. */
  7380. function getAutoLikeDataInterface(token) {
  7381. if (resolveToken(token) === undefined)
  7382. return;
  7383. return autoLikeStore.getData();
  7384. }
  7385. /**
  7386. * Saves new auto-like data, synchronously to the in-memory cache and asynchronously to the persistent storage.
  7387. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  7388. */
  7389. function saveAutoLikeDataInterface(token, data) {
  7390. if (resolveToken(token) === undefined)
  7391. return;
  7392. return autoLikeStore.setData(data);
  7393. }//#region globals
  7394. /** Options that are applied to every SelectorObserver instance */
  7395. const defaultObserverOptions = {
  7396. disableOnNoListeners: false,
  7397. enableOnAddListener: false,
  7398. defaultDebounce: 150,
  7399. };
  7400. /** Global SelectorObserver instances usable throughout the script for improved performance */
  7401. const globservers = {};
  7402. /** Whether all observers have been initialized */
  7403. let globserversReady = false;
  7404. //#region add listener func
  7405. /**
  7406. * Interface function for adding listeners to the {@linkcode globservers}
  7407. * If the observers haven't been initialized yet, the function will queue calls until the `bytm:observersReady` event is emitted
  7408. * @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!
  7409. * @param options Options for the listener
  7410. * @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.
  7411. * @template TDomain This restricts which observers are available with the current domain
  7412. */
  7413. function addSelectorListener(observerName, selector, options) {
  7414. try {
  7415. if (!globserversReady) {
  7416. window.addEventListener("bytm:observersReady", () => addSelectorListener(observerName, selector, options), { once: true });
  7417. return;
  7418. }
  7419. globservers[observerName].addListener(selector, options);
  7420. }
  7421. catch (err) {
  7422. error(`Couldn't add listener to globserver '${observerName}':`, err);
  7423. }
  7424. }
  7425. //#region init
  7426. /** Call after DOM load to initialize all SelectorObserver instances */
  7427. function initObservers() {
  7428. try {
  7429. //#region both sites
  7430. //#region body
  7431. // -> the entire <body> element - use sparingly due to performance impacts!
  7432. // enabled immediately
  7433. globservers.body = new UserUtils.SelectorObserver(document.body, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 150, subtree: false }));
  7434. globservers.body.enable();
  7435. //#region bytmDialogContainer
  7436. // -> the container for all BytmDialog instances
  7437. // enabled immediately
  7438. const bytmDialogContainerSelector = "#bytm-dialog-container";
  7439. globservers.bytmDialogContainer = new UserUtils.SelectorObserver(bytmDialogContainerSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 100, subtree: true }));
  7440. globservers.bytmDialogContainer.enable();
  7441. switch (getDomain()) {
  7442. case "ytm": {
  7443. //#region YTM
  7444. //#region browseResponse
  7445. // -> for example the /channel/UC... page#
  7446. // enabled by "body"
  7447. const browseResponseSelector = "ytmusic-browse-response";
  7448. globservers.browseResponse = new UserUtils.SelectorObserver(browseResponseSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 75, subtree: true }));
  7449. globservers.body.addListener(browseResponseSelector, {
  7450. listener: () => globservers.browseResponse.enable(),
  7451. });
  7452. //#region navBar
  7453. // -> the navigation / title bar at the top of the page
  7454. // enabled by "body"
  7455. const navBarSelector = "ytmusic-nav-bar";
  7456. globservers.navBar = new UserUtils.SelectorObserver(navBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: false }));
  7457. globservers.body.addListener(navBarSelector, {
  7458. listener: () => globservers.navBar.enable(),
  7459. });
  7460. //#region mainPanel
  7461. // -> the main content panel - includes things like the video element
  7462. // enabled by "body"
  7463. const mainPanelSelector = "ytmusic-player-page #main-panel";
  7464. globservers.mainPanel = new UserUtils.SelectorObserver(mainPanelSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7465. globservers.body.addListener(mainPanelSelector, {
  7466. listener: () => globservers.mainPanel.enable(),
  7467. });
  7468. //#region sideBar
  7469. // -> the sidebar on the left side of the page
  7470. // enabled by "body"
  7471. const sidebarSelector = "ytmusic-app-layout tp-yt-app-drawer";
  7472. globservers.sideBar = new UserUtils.SelectorObserver(sidebarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7473. globservers.body.addListener(sidebarSelector, {
  7474. listener: () => globservers.sideBar.enable(),
  7475. });
  7476. //#region sideBarMini
  7477. // -> the minimized sidebar on the left side of the page
  7478. // enabled by "body"
  7479. const sideBarMiniSelector = "ytmusic-app-layout #mini-guide";
  7480. globservers.sideBarMini = new UserUtils.SelectorObserver(sideBarMiniSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7481. globservers.body.addListener(sideBarMiniSelector, {
  7482. listener: () => globservers.sideBarMini.enable(),
  7483. });
  7484. //#region sidePanel
  7485. // -> the side panel on the right side of the /watch page
  7486. // enabled by "body"
  7487. const sidePanelSelector = "#side-panel";
  7488. globservers.sidePanel = new UserUtils.SelectorObserver(sidePanelSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7489. globservers.body.addListener(sidePanelSelector, {
  7490. listener: () => globservers.sidePanel.enable(),
  7491. });
  7492. //#region playerBar
  7493. // -> media controls bar at the bottom of the page
  7494. // enabled by "body"
  7495. const playerBarSelector = "ytmusic-app-layout ytmusic-player-bar.ytmusic-app";
  7496. globservers.playerBar = new UserUtils.SelectorObserver(playerBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 200 }));
  7497. globservers.body.addListener(playerBarSelector, {
  7498. listener: () => {
  7499. globservers.playerBar.enable();
  7500. },
  7501. });
  7502. //#region playerBarInfo
  7503. // -> song title, artist, album, etc. inside the player bar
  7504. // enabled by "playerBar"
  7505. const playerBarInfoSelector = `${playerBarSelector} .middle-controls .content-info-wrapper`;
  7506. globservers.playerBarInfo = new UserUtils.SelectorObserver(playerBarInfoSelector, Object.assign(Object.assign({}, defaultObserverOptions), { attributes: true, attributeFilter: ["title"] }));
  7507. globservers.playerBar.addListener(playerBarInfoSelector, {
  7508. listener: () => globservers.playerBarInfo.enable(),
  7509. });
  7510. //#region playerBarMiddleButtons
  7511. // -> the buttons inside the player bar (like, dislike, lyrics, etc.)
  7512. // enabled by "playerBar"
  7513. const playerBarMiddleButtonsSelector = ".middle-controls .middle-controls-buttons";
  7514. globservers.playerBarMiddleButtons = new UserUtils.SelectorObserver(playerBarMiddleButtonsSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7515. globservers.playerBar.addListener(playerBarMiddleButtonsSelector, {
  7516. listener: () => globservers.playerBarMiddleButtons.enable(),
  7517. });
  7518. //#region playerBarRightControls
  7519. // -> the controls on the right side of the player bar (volume, repeat, shuffle, etc.)
  7520. // enabled by "playerBar"
  7521. const playerBarRightControls = "#right-controls";
  7522. globservers.playerBarRightControls = new UserUtils.SelectorObserver(playerBarRightControls, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7523. globservers.playerBar.addListener(playerBarRightControls, {
  7524. listener: () => globservers.playerBarRightControls.enable(),
  7525. });
  7526. //#region popupContainer
  7527. // -> the container for popups (e.g. the queue popup)
  7528. // enabled by "body"
  7529. const popupContainerSelector = "ytmusic-app ytmusic-popup-container";
  7530. globservers.popupContainer = new UserUtils.SelectorObserver(popupContainerSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7531. globservers.body.addListener(popupContainerSelector, {
  7532. listener: () => globservers.popupContainer.enable(),
  7533. });
  7534. break;
  7535. }
  7536. case "yt": {
  7537. //#region YT
  7538. //#region ytGuide
  7539. // -> the left sidebar menu
  7540. // enabled by "body"
  7541. const ytGuideSelector = "#content tp-yt-app-drawer#guide #guide-inner-content";
  7542. globservers.ytGuide = new UserUtils.SelectorObserver(ytGuideSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7543. globservers.body.addListener(ytGuideSelector, {
  7544. listener: () => globservers.ytGuide.enable(),
  7545. });
  7546. //#region ytdBrowse
  7547. // -> channel pages for example
  7548. // enabled by "body"
  7549. const ytdBrowseSelector = "ytd-app ytd-page-manager ytd-browse";
  7550. globservers.ytdBrowse = new UserUtils.SelectorObserver(ytdBrowseSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7551. globservers.body.addListener(ytdBrowseSelector, {
  7552. listener: () => globservers.ytdBrowse.enable(),
  7553. });
  7554. //#region ytAppHeader
  7555. // -> header of the page
  7556. // enabled by "ytdBrowse"
  7557. const ytAppHeaderSelector = "#header tp-yt-app-header";
  7558. globservers.ytAppHeader = new UserUtils.SelectorObserver(ytAppHeaderSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 75, subtree: true }));
  7559. globservers.ytdBrowse.addListener(ytAppHeaderSelector, {
  7560. listener: () => globservers.ytAppHeader.enable(),
  7561. });
  7562. //#region ytWatchFlexy
  7563. // -> the main content of the /watch page
  7564. // enabled by "body"
  7565. const ytWatchFlexySelector = "ytd-app ytd-watch-flexy";
  7566. globservers.ytWatchFlexy = new UserUtils.SelectorObserver(ytWatchFlexySelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7567. globservers.body.addListener(ytWatchFlexySelector, {
  7568. listener: () => globservers.ytWatchFlexy.enable(),
  7569. });
  7570. //#region ytWatchMetadata
  7571. // -> the metadata section of the /watch page (title, channel, views, description, buttons, etc. but not comments)
  7572. // enabled by "ytWatchFlexy"
  7573. const ytWatchMetadataSelector = "#columns #primary-inner ytd-watch-metadata";
  7574. globservers.ytWatchMetadata = new UserUtils.SelectorObserver(ytWatchMetadataSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7575. globservers.ytWatchFlexy.addListener(ytWatchMetadataSelector, {
  7576. listener: () => globservers.ytWatchMetadata.enable(),
  7577. });
  7578. //#region ytMasthead
  7579. // -> the masthead (title bar) at the top of the page
  7580. // enabled by "body"
  7581. const mastheadSelector = "#content ytd-masthead#masthead";
  7582. globservers.ytMasthead = new UserUtils.SelectorObserver(mastheadSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
  7583. globservers.body.addListener(mastheadSelector, {
  7584. listener: () => globservers.ytMasthead.enable(),
  7585. });
  7586. }
  7587. }
  7588. //#region finalize
  7589. globserversReady = true;
  7590. emitInterface("bytm:observersReady");
  7591. }
  7592. catch (err) {
  7593. error("Failed to initialize observers:", err);
  7594. }
  7595. }//#region vid elem
  7596. /** Returns the video element selector string based on the current domain */
  7597. function getVideoSelector() {
  7598. return getDomain() === "ytm"
  7599. ? "ytmusic-player video"
  7600. : "#player-container ytd-player video";
  7601. }
  7602. /** Returns the video element based on the current domain */
  7603. function getVideoElement() {
  7604. return document.querySelector(getVideoSelector());
  7605. }
  7606. let vidElemReady = false;
  7607. //#region vid time
  7608. /**
  7609. * Returns the current video time in seconds, with the given {@linkcode precision} (2 decimal digits by default).
  7610. * Rounds down if the precision is set to 0. The maximum average available precision on YTM is 6.
  7611. * 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)
  7612. * @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
  7613. */
  7614. function getVideoTime(precision = 2) {
  7615. return new Promise(async (res) => {
  7616. if (!vidElemReady) {
  7617. await waitVideoElementReady();
  7618. vidElemReady = true;
  7619. }
  7620. const resolveWithVal = (time) => res(time && !isNaN(time)
  7621. ? Number(precision <= 0 ? Math.floor(time) : time.toFixed(precision))
  7622. : null);
  7623. try {
  7624. if (getDomain() === "ytm") {
  7625. const vidElem = getVideoElement();
  7626. if (vidElem && vidElem.readyState > 0)
  7627. return resolveWithVal(vidElem.currentTime);
  7628. addSelectorListener("playerBar", "tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar", {
  7629. listener: (pbEl) => resolveWithVal(!isNaN(Number(pbEl.value)) ? Math.floor(Number(pbEl.value)) : null)
  7630. });
  7631. }
  7632. else if (getDomain() === "yt") {
  7633. const vidElem = getVideoElement();
  7634. if (vidElem && vidElem.readyState > 0)
  7635. return resolveWithVal(vidElem.currentTime);
  7636. // YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it)
  7637. ytForceShowVideoTime();
  7638. const pbSelector = ".ytp-chrome-bottom div.ytp-progress-bar[role=\"slider\"]";
  7639. let videoTime = -1;
  7640. const mut = new MutationObserver(() => {
  7641. // .observe() is only called when the element exists - no need to check for null
  7642. videoTime = Number(document.querySelector(pbSelector).getAttribute("aria-valuenow"));
  7643. });
  7644. const observe = (progElem) => {
  7645. mut.observe(progElem, {
  7646. attributes: true,
  7647. attributeFilter: ["aria-valuenow"],
  7648. });
  7649. if (videoTime >= 0 && !isNaN(videoTime)) {
  7650. resolveWithVal(Math.floor(videoTime));
  7651. mut.disconnect();
  7652. }
  7653. else
  7654. setTimeout(() => {
  7655. resolveWithVal(videoTime >= 0 && !isNaN(videoTime) ? Math.floor(videoTime) : null);
  7656. mut.disconnect();
  7657. }, 500);
  7658. };
  7659. addSelectorListener("body", pbSelector, { listener: observe });
  7660. }
  7661. }
  7662. catch (err) {
  7663. error("Couldn't get video time due to error:", err);
  7664. res(null);
  7665. }
  7666. });
  7667. }
  7668. /**
  7669. * Sends events that force the video controls to become visible for about 3 seconds.
  7670. * This only works once (for some reason), then the page needs to be reloaded!
  7671. */
  7672. function ytForceShowVideoTime() {
  7673. const player = document.querySelector("#movie_player");
  7674. if (!player)
  7675. return false;
  7676. const defaultProps = {
  7677. // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
  7678. view: UserUtils.getUnsafeWindow(),
  7679. bubbles: true,
  7680. cancelable: false,
  7681. };
  7682. player.dispatchEvent(new MouseEvent("mouseenter", defaultProps));
  7683. const { x, y, width, height } = player.getBoundingClientRect();
  7684. const screenY = Math.round(y + height / 2);
  7685. const screenX = x + Math.min(50, Math.round(width / 3));
  7686. player.dispatchEvent(new MouseEvent("mousemove", Object.assign(Object.assign({}, defaultProps), { screenY,
  7687. screenX, movementX: 5, movementY: 0 })));
  7688. return true;
  7689. }
  7690. //#region vid ready
  7691. /**
  7692. * Waits for the video element to be in its readyState 4 / canplay state and returns it.
  7693. * Could take a very long time to resolve if the `/watch` page isn't open.
  7694. * Resolves immediately if the video element is already ready.
  7695. */
  7696. function waitVideoElementReady() {
  7697. return new Promise(async (res, rej) => {
  7698. var _a;
  7699. try {
  7700. const vidEl = getVideoElement();
  7701. if (vidEl && ((_a = vidEl === null || vidEl === void 0 ? void 0 : vidEl.readyState) !== null && _a !== void 0 ? _a : 0) > 0)
  7702. return res(vidEl);
  7703. if (!location.pathname.startsWith("/watch"))
  7704. await siteEvents.once("watchIdChanged");
  7705. addSelectorListener("body", getVideoSelector(), {
  7706. listener(vidElem) {
  7707. // this is just after YT has finished doing their own shenanigans with the video time and volume
  7708. if (vidElem.readyState === 4)
  7709. res(vidElem);
  7710. else
  7711. vidElem.addEventListener("canplay", () => res(vidElem), { once: true });
  7712. },
  7713. });
  7714. }
  7715. catch (err) {
  7716. rej(err);
  7717. }
  7718. });
  7719. }
  7720. //#region like/dislike btns
  7721. /**
  7722. * Returns the like/dislike button elements based on the current domain and the current like state ("LIKE" / "DISLIKE" / "INDIFFERENT").
  7723. * The btnRenderer element is a parent of both buttons.
  7724. */
  7725. function getLikeDislikeBtns() {
  7726. var _a, _b, _c, _d, _e, _f, _g;
  7727. let btnRenderer;
  7728. let likeBtn;
  7729. let dislikeBtn;
  7730. let likeState;
  7731. switch (getDomain()) {
  7732. case "ytm": {
  7733. btnRenderer = (_a = document.querySelector(".middle-controls-buttons ytmusic-like-button-renderer")) !== null && _a !== void 0 ? _a : undefined;
  7734. likeBtn = (_b = btnRenderer === null || btnRenderer === void 0 ? void 0 : btnRenderer.querySelector("#button-shape-like button")) !== null && _b !== void 0 ? _b : undefined;
  7735. dislikeBtn = (_c = btnRenderer === null || btnRenderer === void 0 ? void 0 : btnRenderer.querySelector("#button-shape-dislike button")) !== null && _c !== void 0 ? _c : undefined;
  7736. const likeStateRaw = (_d = btnRenderer === null || btnRenderer === void 0 ? void 0 : btnRenderer.getAttribute("like-status")) === null || _d === void 0 ? void 0 : _d.toUpperCase();
  7737. likeState = ["LIKE", "DISLIKE", "INDIFFERENT"].includes(likeStateRaw !== null && likeStateRaw !== void 0 ? likeStateRaw : "") ? likeStateRaw : "INDIFFERENT";
  7738. break;
  7739. }
  7740. case "yt": {
  7741. btnRenderer = (_e = document.querySelector("ytd-watch-metadata segmented-like-dislike-button-view-model")) !== null && _e !== void 0 ? _e : undefined;
  7742. likeBtn = (_f = btnRenderer === null || btnRenderer === void 0 ? void 0 : btnRenderer.querySelector("like-button-view-model button")) !== null && _f !== void 0 ? _f : undefined;
  7743. dislikeBtn = (_g = btnRenderer === null || btnRenderer === void 0 ? void 0 : btnRenderer.querySelector("dislike-button-view-model button")) !== null && _g !== void 0 ? _g : undefined;
  7744. if ((likeBtn === null || likeBtn === void 0 ? void 0 : likeBtn.getAttribute("aria-pressed")) === "true")
  7745. likeState = "LIKE";
  7746. else if ((dislikeBtn === null || dislikeBtn === void 0 ? void 0 : dislikeBtn.getAttribute("aria-pressed")) === "true")
  7747. likeState = "DISLIKE";
  7748. else if (likeBtn || dislikeBtn)
  7749. likeState = "INDIFFERENT";
  7750. break;
  7751. }
  7752. }
  7753. return {
  7754. likeBtn,
  7755. dislikeBtn,
  7756. btnRenderer,
  7757. likeState,
  7758. };
  7759. }
  7760. //#region css utils
  7761. /**
  7762. * Adds a style element to the DOM at runtime.
  7763. * @param css The CSS stylesheet to add
  7764. * @param ref A reference string to identify the style element - defaults to a random 5-character string
  7765. * @param transform A function to transform the CSS before adding it to the DOM
  7766. */
  7767. async function addStyle(css, ref, transform = (c) => c) {
  7768. if (!UserUtils.isDomLoaded())
  7769. throw new Error("DOM has not finished loading yet");
  7770. const elem = UserUtils.addGlobalStyle(await transform(await UserUtils.consumeStringGen(css)));
  7771. elem.id = `bytm-style-${ref !== null && ref !== void 0 ? ref : UserUtils.randomId(6, 36)}`;
  7772. return elem;
  7773. }
  7774. /**
  7775. * Adds a global style element with the contents fetched from the specified resource starting with `css-`
  7776. * The CSS can be transformed using the provided function before being added to the DOM.
  7777. */
  7778. async function addStyleFromResource(key, transform = (c) => c) {
  7779. const css = await fetchCss(key);
  7780. if (css) {
  7781. await addStyle(String(transform(css)), key.slice(4));
  7782. return true;
  7783. }
  7784. return false;
  7785. }
  7786. /** Sets a global CSS variable on the &lt;document&gt; element with the name `--bytm-global-${name}` */
  7787. function setGlobalCssVar(name, value) {
  7788. document.documentElement.style.setProperty(`--bytm-global-${name.toLowerCase().trim()}`, String(value));
  7789. }
  7790. /** Sets multiple global CSS variables on the &lt;document&gt; element with the name `--bytm-global-${name}` */
  7791. function setGlobalCssVars(vars) {
  7792. for (const [name, value] of Object.entries(vars))
  7793. setGlobalCssVar(name, value);
  7794. }
  7795. //#region other
  7796. /** Removes all child nodes of an element without invoking the slow-ish HTML parser */
  7797. function clearInner(element) {
  7798. while (element.hasChildNodes())
  7799. clearNode(element.firstChild);
  7800. }
  7801. /** Removes all child nodes of an element recursively and also removes the element itself */
  7802. function clearNode(element) {
  7803. while (element.hasChildNodes())
  7804. clearNode(element.firstChild);
  7805. element.parentNode.removeChild(element);
  7806. }
  7807. /**
  7808. * Returns an identifier for the currently playing media type on YTM ("song" or "video").
  7809. * Only works on YTM and will throw if {@linkcode waitVideoElementReady} hasn't been awaited yet.
  7810. * On YT, it will always return "video".
  7811. */
  7812. function getCurrentMediaType() {
  7813. if (getDomain() === "yt")
  7814. return "video";
  7815. const songImgElem = document.querySelector("ytmusic-player #song-image");
  7816. if (!songImgElem)
  7817. throw new Error("Couldn't find the song image element. Use this function only after awaiting `waitVideoElementReady()`!");
  7818. return window.getComputedStyle(songImgElem).display !== "none" ? "song" : "video";
  7819. }
  7820. /** Copies the provided text to the clipboard and shows an error message for manual copying if the grant `GM.setClipboard` is not given. */
  7821. function copyToClipboard(text) {
  7822. try {
  7823. GM.setClipboard(String(text));
  7824. }
  7825. catch (_a) {
  7826. showPrompt({ type: "alert", message: t("copy_to_clipboard_error", String(text)) });
  7827. }
  7828. }
  7829. let ttPolicy;
  7830. // workaround for supporting `target="_blank"` links without compromising security:
  7831. const tempTargetAttrName = `data-tmp-target-${UserUtils.randomId(6, 36)}`;
  7832. DOMPurify.addHook("beforeSanitizeAttributes", (node) => {
  7833. if (node.tagName === "A") {
  7834. if (!node.hasAttribute("target"))
  7835. node.setAttribute("target", "_self");
  7836. if (node.hasAttribute("target"))
  7837. node.setAttribute(tempTargetAttrName, node.getAttribute("target"));
  7838. }
  7839. });
  7840. DOMPurify.addHook("afterSanitizeAttributes", (node) => {
  7841. if (node.tagName === "A" && node.hasAttribute(tempTargetAttrName)) {
  7842. node.setAttribute("target", node.getAttribute(tempTargetAttrName));
  7843. node.removeAttribute(tempTargetAttrName);
  7844. if (node.getAttribute("target") === "_blank")
  7845. node.setAttribute("rel", "noopener noreferrer");
  7846. }
  7847. });
  7848. /**
  7849. * Sets innerHTML directly on Firefox and Safari, while on Chromium a [Trusted Types policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) is used to set the HTML.
  7850. * If no HTML string is given, the element's innerHTML will be set to an empty string.
  7851. */
  7852. function setInnerHtml(element, html) {
  7853. var _a, _b;
  7854. if (!html)
  7855. html = "";
  7856. if (!ttPolicy && ((_a = window === null || window === void 0 ? void 0 : window.trustedTypes) === null || _a === void 0 ? void 0 : _a.createPolicy)) {
  7857. ttPolicy = window.trustedTypes.createPolicy("bytm-sanitize-html", {
  7858. createHTML: (dirty) => DOMPurify.sanitize(dirty, {
  7859. RETURN_TRUSTED_TYPE: true,
  7860. }),
  7861. });
  7862. }
  7863. element.innerHTML = (_b = ttPolicy === null || ttPolicy === void 0 ? void 0 : ttPolicy.createHTML(String(html))) !== null && _b !== void 0 ? _b : DOMPurify.sanitize(String(html), { RETURN_TRUSTED_TYPE: false });
  7864. }
  7865. /** Creates an invisible link element and clicks it to download the provided string or Blob data as a file */
  7866. function downloadFile(fileName, data, mimeType = "text/plain") {
  7867. const blob = data instanceof Blob ? data : new Blob([data], { type: mimeType });
  7868. const a = document.createElement("a");
  7869. a.classList.add("bytm-hidden");
  7870. a.href = URL.createObjectURL(blob);
  7871. a.download = fileName;
  7872. document.body.appendChild(a);
  7873. a.click();
  7874. setTimeout(() => a.remove(), 1);
  7875. }/**
  7876. * Constructs a URL from a base URL and a record of query parameters.
  7877. * If a value is null, the parameter will be valueless. If a value is undefined, the parameter will be omitted.
  7878. * All values will be stringified using their `toString()` method and then URI-encoded.
  7879. * @returns Returns a string instead of a URL object
  7880. */
  7881. function constructUrlString(baseUrl, params) {
  7882. return `${baseUrl}?${Object.entries(params)
  7883. .filter(([, v]) => v !== undefined)
  7884. .map(([key, val]) => `${key}${val === null ? "" : `=${encodeURIComponent(String(val))}`}`)
  7885. .join("&")}`;
  7886. }
  7887. /**
  7888. * Constructs a URL object from a base URL and a record of query parameters.
  7889. * If a value is null, the parameter will be valueless. If a value is undefined, the parameter will be omitted.
  7890. * All values will be stringified and then URI-encoded.
  7891. * @returns Returns a URL object instead of a string
  7892. */
  7893. function constructUrl(base, params) {
  7894. return new URL(constructUrlString(base, params));
  7895. }
  7896. /**
  7897. * Sends a request with the specified parameters and returns the response as a Promise.
  7898. * Ignores [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS), contrary to fetch and fetchAdvanced.
  7899. */
  7900. function sendRequest(details) {
  7901. return new Promise((resolve, reject) => {
  7902. GM.xmlHttpRequest(Object.assign(Object.assign({ timeout: 10000 }, details), { onload: resolve, onerror: reject, ontimeout: reject, onabort: reject }));
  7903. });
  7904. }
  7905. /** Fetches a CSS file from the specified resource with a key starting with `css-` */
  7906. async function fetchCss(key) {
  7907. try {
  7908. const css = await (await UserUtils.fetchAdvanced(await getResourceUrl(key))).text();
  7909. return css !== null && css !== void 0 ? css : undefined;
  7910. }
  7911. catch (err) {
  7912. error("Couldn't fetch CSS due to an error:", err);
  7913. return undefined;
  7914. }
  7915. }
  7916. /** Cache for the vote data of YouTube videos to prevent some unnecessary requests */
  7917. const voteCache = new Map();
  7918. /** Time-to-live for the vote cache in milliseconds */
  7919. const voteCacheTTL = 1000 * 60 * 10;
  7920. /**
  7921. * Fetches the votes object for a YouTube video from the [Return YouTube Dislike API.](https://returnyoutubedislike.com/docs)
  7922. * @param videoID The video ID of the video
  7923. */
  7924. async function fetchVideoVotes(videoID) {
  7925. try {
  7926. if (voteCache.has(videoID)) {
  7927. const cached = voteCache.get(videoID);
  7928. if (Date.now() - cached.timestamp < voteCacheTTL) {
  7929. info(`Returning cached video votes for video ID '${videoID}':`, cached);
  7930. return cached;
  7931. }
  7932. else
  7933. voteCache.delete(videoID);
  7934. }
  7935. const votesRaw = JSON.parse((await sendRequest({
  7936. method: "GET",
  7937. url: `https://returnyoutubedislikeapi.com/votes?videoId=${videoID}`,
  7938. })).response);
  7939. if (!("id" in votesRaw) || !("likes" in votesRaw) || !("dislikes" in votesRaw) || !("rating" in votesRaw)) {
  7940. error("Couldn't parse video votes due to an error:", votesRaw);
  7941. return undefined;
  7942. }
  7943. const votesObj = {
  7944. id: votesRaw.id,
  7945. likes: votesRaw.likes,
  7946. dislikes: votesRaw.dislikes,
  7947. rating: votesRaw.rating,
  7948. timestamp: Date.now(),
  7949. };
  7950. voteCache.set(votesObj.id, votesObj);
  7951. info(`Fetched video votes for watch ID '${videoID}':`, votesObj);
  7952. return votesObj;
  7953. }
  7954. catch (err) {
  7955. error("Couldn't fetch video votes due to an error:", err);
  7956. return undefined;
  7957. }
  7958. }let welcomeDialog = null;
  7959. /** Creates and/or returns the import dialog */
  7960. async function getWelcomeDialog() {
  7961. if (!welcomeDialog) {
  7962. welcomeDialog = new BytmDialog({
  7963. id: "welcome",
  7964. width: 700,
  7965. height: 500,
  7966. closeBtnEnabled: true,
  7967. closeOnBgClick: false,
  7968. closeOnEscPress: true,
  7969. destroyOnClose: true,
  7970. renderHeader,
  7971. renderBody,
  7972. renderFooter,
  7973. });
  7974. welcomeDialog.on("render", retranslateWelcomeMenu);
  7975. }
  7976. return welcomeDialog;
  7977. }
  7978. async function renderHeader() {
  7979. const titleWrapperElem = document.createElement("div");
  7980. titleWrapperElem.id = "bytm-welcome-menu-title-wrapper";
  7981. const titleLogoElem = document.createElement("img");
  7982. titleLogoElem.id = "bytm-welcome-menu-title-logo";
  7983. titleLogoElem.classList.add("bytm-no-select");
  7984. titleLogoElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  7985. const titleElem = document.createElement("h2");
  7986. titleElem.id = "bytm-welcome-menu-title";
  7987. titleElem.classList.add("bytm-dialog-title");
  7988. titleElem.role = "heading";
  7989. titleElem.ariaLevel = "1";
  7990. titleElem.tabIndex = 0;
  7991. titleWrapperElem.appendChild(titleLogoElem);
  7992. titleWrapperElem.appendChild(titleElem);
  7993. return titleWrapperElem;
  7994. }
  7995. async function renderBody() {
  7996. const contentWrapper = document.createElement("div");
  7997. contentWrapper.id = "bytm-welcome-menu-content-wrapper";
  7998. // locale switcher
  7999. const localeCont = document.createElement("div");
  8000. localeCont.id = "bytm-welcome-menu-locale-cont";
  8001. const localeImg = document.createElement("img");
  8002. localeImg.id = "bytm-welcome-menu-locale-img";
  8003. localeImg.classList.add("bytm-no-select");
  8004. localeImg.src = await getResourceUrl("icon-globe");
  8005. const localeSelectElem = document.createElement("select");
  8006. localeSelectElem.id = "bytm-welcome-menu-locale-select";
  8007. for (const [locale, { name }] of Object.entries(locales)) {
  8008. const localeOptionElem = document.createElement("option");
  8009. localeOptionElem.value = locale;
  8010. localeOptionElem.textContent = name;
  8011. localeSelectElem.appendChild(localeOptionElem);
  8012. }
  8013. localeSelectElem.value = getFeature("locale");
  8014. localeSelectElem.addEventListener("change", async () => {
  8015. const selectedLocale = localeSelectElem.value;
  8016. const feats = Object.assign({}, getFeatures());
  8017. feats.locale = selectedLocale;
  8018. setFeatures(feats);
  8019. await initTranslations(selectedLocale);
  8020. setLocale(selectedLocale);
  8021. retranslateWelcomeMenu();
  8022. });
  8023. localeCont.appendChild(localeImg);
  8024. localeCont.appendChild(localeSelectElem);
  8025. contentWrapper.appendChild(localeCont);
  8026. // text
  8027. const textCont = document.createElement("div");
  8028. textCont.id = "bytm-welcome-menu-text-cont";
  8029. const textElem = document.createElement("p");
  8030. textElem.id = "bytm-welcome-menu-text";
  8031. const textElems = [];
  8032. const line1Elem = document.createElement("span");
  8033. line1Elem.id = "bytm-welcome-text-line1";
  8034. line1Elem.tabIndex = 0;
  8035. textElems.push(line1Elem);
  8036. const br1Elem = document.createElement("br");
  8037. textElems.push(br1Elem);
  8038. const line2Elem = document.createElement("span");
  8039. line2Elem.id = "bytm-welcome-text-line2";
  8040. line2Elem.tabIndex = 0;
  8041. textElems.push(line2Elem);
  8042. const br2Elem = document.createElement("br");
  8043. textElems.push(br2Elem);
  8044. const br3Elem = document.createElement("br");
  8045. textElems.push(br3Elem);
  8046. const line3Elem = document.createElement("span");
  8047. line3Elem.id = "bytm-welcome-text-line3";
  8048. line3Elem.tabIndex = 0;
  8049. textElems.push(line3Elem);
  8050. const br4Elem = document.createElement("br");
  8051. textElems.push(br4Elem);
  8052. const line4Elem = document.createElement("span");
  8053. line4Elem.id = "bytm-welcome-text-line4";
  8054. line4Elem.tabIndex = 0;
  8055. textElems.push(line4Elem);
  8056. const br5Elem = document.createElement("br");
  8057. textElems.push(br5Elem);
  8058. const br6Elem = document.createElement("br");
  8059. textElems.push(br6Elem);
  8060. const line5Elem = document.createElement("span");
  8061. line5Elem.id = "bytm-welcome-text-line5";
  8062. line5Elem.tabIndex = 0;
  8063. textElems.push(line5Elem);
  8064. textElems.forEach((elem) => textElem.appendChild(elem));
  8065. textCont.appendChild(textElem);
  8066. contentWrapper.appendChild(textCont);
  8067. return contentWrapper;
  8068. }
  8069. /** Retranslates all elements inside the welcome menu */
  8070. function retranslateWelcomeMenu() {
  8071. const getLink = (href) => {
  8072. return [`<a href="${href}" class="bytm-link" target="_blank" rel="noopener noreferrer">`, "</a>"];
  8073. };
  8074. const changes = {
  8075. "#bytm-welcome-menu-title": (e) => e.textContent = e.ariaLabel = t("welcome_menu_title", scriptInfo.name),
  8076. "#bytm-welcome-menu-title-close": (e) => e.ariaLabel = e.title = t("close_menu_tooltip"),
  8077. "#bytm-welcome-menu-open-cfg": (e) => {
  8078. e.textContent = e.ariaLabel = t("config_menu");
  8079. e.ariaLabel = e.title = t("open_config_menu_tooltip");
  8080. },
  8081. "#bytm-welcome-menu-open-changelog": (e) => {
  8082. e.textContent = e.ariaLabel = t("open_changelog");
  8083. e.ariaLabel = e.title = t("open_changelog_tooltip");
  8084. },
  8085. "#bytm-welcome-menu-footer-close": (e) => {
  8086. e.textContent = e.ariaLabel = t("close");
  8087. e.ariaLabel = e.title = t("close_menu_tooltip");
  8088. },
  8089. "#bytm-welcome-text-line1": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_1")),
  8090. "#bytm-welcome-text-line2": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_2", scriptInfo.name)),
  8091. "#bytm-welcome-text-line3": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_3", scriptInfo.name, ...getLink(`${pkg.hosts.greasyfork}/feedback`), ...getLink(pkg.hosts.openuserjs))),
  8092. "#bytm-welcome-text-line4": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_4", ...getLink(pkg.funding.url))),
  8093. "#bytm-welcome-text-line5": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_5", ...getLink(pkg.bugs.url))),
  8094. };
  8095. for (const [selector, fn] of Object.entries(changes)) {
  8096. const el = document.querySelector(selector);
  8097. if (!el) {
  8098. warn(`Couldn't find element in welcome menu with selector '${selector}'`);
  8099. continue;
  8100. }
  8101. fn(el);
  8102. }
  8103. }
  8104. async function renderFooter() {
  8105. const footerCont = document.createElement("div");
  8106. footerCont.id = "bytm-welcome-menu-footer-cont";
  8107. const openCfgElem = document.createElement("button");
  8108. openCfgElem.id = "bytm-welcome-menu-open-cfg";
  8109. openCfgElem.classList.add("bytm-btn");
  8110. openCfgElem.addEventListener("click", () => {
  8111. welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close();
  8112. openCfgMenu();
  8113. });
  8114. const openChangelogElem = document.createElement("button");
  8115. openChangelogElem.id = "bytm-welcome-menu-open-changelog";
  8116. openChangelogElem.classList.add("bytm-btn");
  8117. openChangelogElem.addEventListener("click", async () => {
  8118. const dlg = await getChangelogDialog();
  8119. await dlg.mount();
  8120. welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close();
  8121. await dlg.open();
  8122. });
  8123. const closeBtnElem = document.createElement("button");
  8124. closeBtnElem.id = "bytm-welcome-menu-footer-close";
  8125. closeBtnElem.classList.add("bytm-btn");
  8126. closeBtnElem.addEventListener("click", async () => {
  8127. welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close();
  8128. });
  8129. const leftButtonsCont = document.createElement("div");
  8130. leftButtonsCont.id = "bytm-menu-footer-left-buttons-cont";
  8131. leftButtonsCont.appendChild(openCfgElem);
  8132. leftButtonsCont.appendChild(openChangelogElem);
  8133. footerCont.appendChild(leftButtonsCont);
  8134. footerCont.appendChild(closeBtnElem);
  8135. return footerCont;
  8136. }//#region cns. watermark
  8137. {
  8138. // console watermark with sexy gradient
  8139. const [styleGradient, gradientContBg] = (() => {
  8140. switch (mode) {
  8141. case "production": return ["background: rgb(165, 57, 36); background: linear-gradient(90deg, rgb(154, 31, 103) 0%, rgb(135, 31, 31) 40%, rgb(165, 57, 36) 100%);", "rgb(165, 57, 36)"];
  8142. case "development": return ["background: rgb(72, 66, 178); background: linear-gradient(90deg, rgb(38, 160, 172) 0%, rgb(33, 48, 158) 40%, rgb(72, 66, 178) 100%);", "rgb(72, 66, 178)"];
  8143. }
  8144. })();
  8145. const styleCommon = "color: #fff; font-size: 1.3rem;";
  8146. const poweredBy = `Powered by:
  8147. ─ Lots of ambition and dedication
  8148. ─ My song metadata API: https://api.sv443.net/geniurl
  8149. ─ My userscript utility library: https://github.com/Sv443-Network/UserUtils
  8150. ─ This library for semver comparison: https://github.com/omichelsen/compare-versions
  8151. ─ This TrustedTypes-compatible HTML sanitization library: https://github.com/cure53/DOMPurify
  8152. ─ This markdown parser library: https://github.com/markedjs/marked
  8153. ─ This tiny event listener library: https://github.com/ai/nanoevents
  8154. ─ TypeScript and the tslib runtime: https://github.com/microsoft/TypeScript
  8155. ─ The Cousine font: https://fonts.google.com/specimen/Cousine`;
  8156. console.log(`\
  8157. %c${scriptInfo.name}%cv${scriptInfo.version}%c • ${scriptInfo.namespace}%c
  8158. Build #${buildNumber}${mode === "development" ? " (dev mode)" : ""}
  8159. %c${poweredBy}`, `${styleCommon} ${styleGradient} font-weight: bold; padding-left: 6px; padding-right: 6px;`, `${styleCommon} background-color: ${gradientContBg}; padding-left: 8px; padding-right: 8px;`, "color: #fff; font-size: 1.2rem;", "padding: initial; font-size: 0.9rem;", "padding: initial; font-size: 1rem;");
  8160. }
  8161. //#region preInit
  8162. /** Stuff that needs to be called ASAP, before anything async happens */
  8163. function preInit() {
  8164. var _a, _b;
  8165. try {
  8166. const unsupportedHandlers = [
  8167. "FireMonkey",
  8168. ];
  8169. if (unsupportedHandlers.includes((_b = (_a = GM === null || GM === void 0 ? void 0 : GM.info) === null || _a === void 0 ? void 0 : _a.scriptHandler) !== null && _b !== void 0 ? _b : "_"))
  8170. return showPrompt({ type: "alert", message: `BetterYTM does not work when using ${GM.info.scriptHandler} as the userscript manager extension and will be disabled.\nI recommend using either ViolentMonkey, TamperMonkey or GreaseMonkey.`, denyBtnText: "Close" });
  8171. log("Session ID:", getSessionId());
  8172. initInterface();
  8173. setLogLevel(defaultLogLevel);
  8174. if (getDomain() === "ytm")
  8175. initBeforeUnloadHook();
  8176. init();
  8177. }
  8178. catch (err) {
  8179. return error("Fatal pre-init error:", err);
  8180. }
  8181. }
  8182. //#region init
  8183. async function init() {
  8184. var _a;
  8185. try {
  8186. const domain = getDomain();
  8187. const features = await initConfig();
  8188. setLogLevel(features.logLevel);
  8189. await initLyricsCache();
  8190. const initLoc = (_a = features.locale) !== null && _a !== void 0 ? _a : "en-US";
  8191. const locPromises = [];
  8192. locPromises.push(initTranslations(initLoc));
  8193. // since en-US always has the complete set of keys, it needs to always be loaded:
  8194. initLoc !== "en-US" && locPromises.push(initTranslations("en-US"));
  8195. await Promise.allSettled(locPromises);
  8196. setLocale(initLoc);
  8197. try {
  8198. initPlugins();
  8199. }
  8200. catch (err) {
  8201. error("Plugin loading error:", err);
  8202. emitInterface("bytm:fatalError", "Error while loading plugins");
  8203. }
  8204. if (features.disableBeforeUnloadPopup && domain === "ytm")
  8205. enableDiscardBeforeUnload();
  8206. if (features.rememberSongTime)
  8207. initRememberSongTime();
  8208. if (!UserUtils.isDomLoaded())
  8209. document.addEventListener("DOMContentLoaded", onDomLoad, { once: true });
  8210. else
  8211. onDomLoad();
  8212. }
  8213. catch (err) {
  8214. error("Fatal error:", err);
  8215. }
  8216. }
  8217. //#region onDomLoad
  8218. /** Called when the DOM has finished loading and can be queried and altered by the userscript */
  8219. async function onDomLoad() {
  8220. const domain = getDomain();
  8221. const feats = getFeatures();
  8222. const ftInit = [];
  8223. // for being able to apply domain-specific styles (prefix any CSS selector with "body.bytm-dom-yt" or "body.bytm-dom-ytm")
  8224. document.body.classList.add(`bytm-dom-${domain}`);
  8225. try {
  8226. initGlobalCss();
  8227. initObservers();
  8228. initSvgSpritesheet();
  8229. Promise.allSettled([
  8230. injectCssBundle(),
  8231. initVersionCheck(),
  8232. ]);
  8233. }
  8234. catch (err) {
  8235. error("Encountered error in feature pre-init:", err);
  8236. }
  8237. log(`DOM loaded and feature pre-init finished, now initializing all features for domain "${domain}"...`);
  8238. try {
  8239. //#region welcome dlg
  8240. if (typeof await GM.getValue("bytm-installed") !== "string") {
  8241. // open welcome menu with language selector
  8242. const dlg = await getWelcomeDialog();
  8243. dlg.on("close", () => GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version })));
  8244. info("Showing welcome menu");
  8245. await dlg.open();
  8246. }
  8247. if (domain === "ytm") {
  8248. //#region (ytm) layout
  8249. if (feats.watermarkEnabled)
  8250. ftInit.push(["addWatermark", addWatermark()]);
  8251. if (feats.fixSpacing)
  8252. ftInit.push(["fixSpacing", fixSpacing()]);
  8253. ftInit.push(["thumbnailOverlay", initThumbnailOverlay()]);
  8254. if (feats.hideCursorOnIdle)
  8255. ftInit.push(["hideCursorOnIdle", initHideCursorOnIdle()]);
  8256. if (feats.fixHdrIssues)
  8257. ftInit.push(["fixHdrIssues", fixHdrIssues()]);
  8258. if (feats.showVotes)
  8259. ftInit.push(["showVotes", initShowVotes()]);
  8260. if (feats.watchPageFullSize)
  8261. ftInit.push(["watchPageFullSize", initWatchPageFullSize()]);
  8262. //#region (ytm) volume
  8263. ftInit.push(["volumeFeatures", initVolumeFeatures()]);
  8264. //#region (ytm) song lists
  8265. if (feats.lyricsQueueButton || feats.deleteFromQueueButton)
  8266. ftInit.push(["queueButtons", initQueueButtons()]);
  8267. ftInit.push(["aboveQueueBtns", initAboveQueueBtns()]);
  8268. //#region (ytm) behavior
  8269. if (feats.closeToastsTimeout > 0)
  8270. ftInit.push(["autoCloseToasts", initAutoCloseToasts()]);
  8271. ftInit.push(["autoScrollToActiveSongMode", initAutoScrollToActiveSong()]);
  8272. //#region (ytm) input
  8273. ftInit.push(["arrowKeySkip", initArrowKeySkip()]);
  8274. ftInit.push(["frameSkip", initFrameSkip()]);
  8275. if (feats.anchorImprovements)
  8276. ftInit.push(["anchorImprovements", addAnchorImprovements()]);
  8277. ftInit.push(["numKeysSkip", initNumKeysSkip()]);
  8278. //#region (ytm) lyrics
  8279. if (feats.geniusLyrics)
  8280. ftInit.push(["playerBarLyricsBtn", addPlayerBarLyricsBtn()]);
  8281. // #region (ytm) integrations
  8282. if (feats.sponsorBlockIntegration)
  8283. ftInit.push(["sponsorBlockIntegration", fixSponsorBlock()]);
  8284. const hideThemeSongLogo = addStyleFromResource("css-hide_themesong_logo");
  8285. if (feats.themeSongIntegration)
  8286. ftInit.push(["themeSongIntegration", Promise.allSettled([fixThemeSong(), hideThemeSongLogo])]);
  8287. else
  8288. ftInit.push(["themeSongIntegration", Promise.allSettled([fixPlayerPageTheming(), hideThemeSongLogo])]);
  8289. }
  8290. //#region (ytm+yt) cfg menu
  8291. try {
  8292. if (domain === "ytm") {
  8293. addSelectorListener("popupContainer", "tp-yt-iron-dropdown #contentWrapper ytmusic-multi-page-menu-renderer #container", {
  8294. listener: addConfigMenuOptionYTM,
  8295. });
  8296. }
  8297. else if (domain === "yt") {
  8298. addSelectorListener("ytGuide", "#sections ytd-guide-section-renderer:nth-child(5) #items ytd-guide-entry-renderer:nth-child(1)", {
  8299. listener: (el) => el.parentElement && addConfigMenuOptionYT(el.parentElement),
  8300. });
  8301. }
  8302. }
  8303. catch (err) {
  8304. error("Couldn't add config menu option:", err);
  8305. }
  8306. if (["ytm", "yt"].includes(domain)) {
  8307. //#region general
  8308. ftInit.push(["initSiteEvents", initSiteEvents()]);
  8309. //#region (ytm+yt) layout
  8310. if (feats.removeShareTrackingParamSites && (feats.removeShareTrackingParamSites === domain || feats.removeShareTrackingParamSites === "all"))
  8311. ftInit.push(["initRemShareTrackParam", initRemShareTrackParam()]);
  8312. //#region (ytm+yt) input
  8313. ftInit.push(["hotkeys", initHotkeys()]);
  8314. if (feats.autoLikeChannels)
  8315. ftInit.push(["autoLikeChannels", initAutoLike()]);
  8316. //#region (ytm+yt) integrations
  8317. if (feats.disableDarkReaderSites !== "none")
  8318. ftInit.push(["disableDarkReaderSites", disableDarkReader()]);
  8319. }
  8320. emitInterface("bytm:featureInitStarted");
  8321. const initStartTs = Date.now();
  8322. // wait for feature init or timeout (in case an init function is hung up on a promise)
  8323. await Promise.race([
  8324. UserUtils.pauseFor(feats.initTimeout > 0 ? feats.initTimeout * 1000 : 8000),
  8325. Promise.allSettled(ftInit.map(([name, prom]) => new Promise(async (res) => {
  8326. const v = await prom;
  8327. emitInterface("bytm:featureInitialized", name);
  8328. res(v);
  8329. }))),
  8330. ]);
  8331. // ensure site adjusts itself to new CSS files
  8332. UserUtils.getUnsafeWindow().dispatchEvent(new Event("resize", { bubbles: true, cancelable: true }));
  8333. // preload icons
  8334. preloadResources();
  8335. emitInterface("bytm:ready");
  8336. info(`Done initializing ${ftInit.length} features after ${Math.floor(Date.now() - initStartTs)}ms`);
  8337. try {
  8338. registerDevCommands();
  8339. }
  8340. catch (e) {
  8341. warn("Couldn't register dev menu commands:", e);
  8342. }
  8343. try {
  8344. runDevTreatments();
  8345. }
  8346. catch (e) {
  8347. warn("Couldn't run dev treatments:", e);
  8348. }
  8349. }
  8350. catch (err) {
  8351. error("Feature error:", err);
  8352. emitInterface("bytm:fatalError", "Error while initializing features");
  8353. }
  8354. }
  8355. //#region preload icons
  8356. /** Preloads all resources that should be preloaded */
  8357. async function preloadResources() {
  8358. const preloadAssetRegex = new RegExp(resourcesJson.preloadAssetPattern);
  8359. const urlPromises = Object.keys(resourcesJson.resources)
  8360. .filter(k => preloadAssetRegex.test(k))
  8361. .map(k => getResourceUrl(k));
  8362. const urls = await Promise.all(urlPromises);
  8363. if (urls.length > 0)
  8364. info("Preloading", urls.length, "resources:", urls);
  8365. else
  8366. info("No resources to preload");
  8367. await UserUtils.preloadImages(urls);
  8368. }
  8369. //#region css
  8370. /** Inserts the bundled CSS files imported throughout the script into a <style> element in the <head> */
  8371. async function injectCssBundle() {
  8372. if (!await addStyleFromResource("css-bundle"))
  8373. error("Couldn't inject CSS bundle due to an error");
  8374. }
  8375. /** Initializes global CSS values */
  8376. function initGlobalCss() {
  8377. try {
  8378. initFonts();
  8379. const applyVars = () => {
  8380. setGlobalCssVars({
  8381. "inner-height": `${window.innerHeight}px`,
  8382. "outer-height": `${window.outerHeight}px`,
  8383. "inner-width": `${window.innerWidth}px`,
  8384. "outer-width": `${window.outerWidth}px`,
  8385. });
  8386. };
  8387. window.addEventListener("resize", applyVars);
  8388. applyVars();
  8389. }
  8390. catch (err) {
  8391. error("Couldn't initialize global CSS:", err);
  8392. }
  8393. }
  8394. async function initFonts() {
  8395. const fonts = {
  8396. "Cousine": {
  8397. woff: await getResourceUrl("font-cousine_woff"),
  8398. woff2: await getResourceUrl("font-cousine_woff2"),
  8399. truetype: await getResourceUrl("font-cousine_ttf"),
  8400. },
  8401. };
  8402. let css = "";
  8403. for (const [fontName, urls] of Object.entries(fonts))
  8404. css += `\
  8405. @font-face {
  8406. font-family: "${fontName}";
  8407. src: ${Object.entries(urls)
  8408. .map(([type, url]) => `url("${url}") format("${type}")`)
  8409. .join(", ")};
  8410. font-weight: normal;
  8411. font-style: normal;
  8412. font-display: swap;
  8413. }`;
  8414. addStyle(css, "fonts");
  8415. }
  8416. //#region svg spritesheet
  8417. /** Initializes the SVG spritesheet */
  8418. async function initSvgSpritesheet() {
  8419. const svgUrl = await getResourceUrl("doc-svg_spritesheet");
  8420. const div = document.createElement("div");
  8421. div.style.display = "none";
  8422. UserUtils.setInnerHtmlUnsafe(div, await (await UserUtils.fetchAdvanced(svgUrl)).text());
  8423. document.body.appendChild(div);
  8424. }
  8425. //#region dev menu cmds
  8426. /** Registers dev commands using `GM.registerMenuCommand` */
  8427. function registerDevCommands() {
  8428. if (mode !== "development")
  8429. return;
  8430. GM.registerMenuCommand("Reset config", async () => {
  8431. if (await showPrompt({ type: "confirm", message: "Reset the configuration to its default values?\nThis will automatically reload the page.", confirmBtnText: "Reset" })) {
  8432. await clearConfig();
  8433. await reloadTab();
  8434. }
  8435. });
  8436. GM.registerMenuCommand("List GM values in console with decompression", async () => {
  8437. const keys = await GM.listValues();
  8438. dbg(`GM values (${keys.length}):`);
  8439. if (keys.length === 0)
  8440. dbg(" No values found.");
  8441. const values = {};
  8442. let longestKey = 0;
  8443. for (const key of keys) {
  8444. const isEncoded = key.startsWith("_uucfg-") ? await GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
  8445. const val = await GM.getValue(key, undefined);
  8446. values[key] = typeof val !== "undefined" && isEncoded ? await UserUtils.decompress(val, compressionFormat, "string") : val;
  8447. longestKey = Math.max(longestKey, key.length);
  8448. }
  8449. for (const [key, finalVal] of Object.entries(values)) {
  8450. const isEncoded = key.startsWith("_uucfg-") ? await GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
  8451. const lengthStr = String(finalVal).length > 50 ? `(${String(finalVal).length} chars) ` : "";
  8452. dbg(` "${key}"${" ".repeat(longestKey - key.length)} -${isEncoded ? "-[decoded]-" : ""}> ${lengthStr}${finalVal}`);
  8453. }
  8454. });
  8455. GM.registerMenuCommand("List GM values in console, without decompression", async () => {
  8456. const keys = await GM.listValues();
  8457. dbg(`GM values (${keys.length}):`);
  8458. if (keys.length === 0)
  8459. dbg(" No values found.");
  8460. const values = {};
  8461. let longestKey = 0;
  8462. for (const key of keys) {
  8463. const val = await GM.getValue(key, undefined);
  8464. values[key] = val;
  8465. longestKey = Math.max(longestKey, key.length);
  8466. }
  8467. for (const [key, val] of Object.entries(values)) {
  8468. const lengthStr = String(val).length >= 16 ? `(${String(val).length} chars) ` : "";
  8469. dbg(` "${key}"${" ".repeat(longestKey - key.length)} -> ${lengthStr}${val}`);
  8470. }
  8471. });
  8472. GM.registerMenuCommand("Delete all GM values", async () => {
  8473. const keys = await GM.listValues();
  8474. if (await showPrompt({ type: "confirm", message: `Clear all ${keys.length} GM values?\nSee console for details.`, confirmBtnText: "Clear" })) {
  8475. dbg(`Clearing ${keys.length} GM values:`);
  8476. if (keys.length === 0)
  8477. dbg(" No values found.");
  8478. for (const key of keys) {
  8479. await GM.deleteValue(key);
  8480. dbg(` Deleted ${key}`);
  8481. }
  8482. }
  8483. });
  8484. GM.registerMenuCommand("Delete GM values by name (comma separated)", async () => {
  8485. var _a;
  8486. const keys = await showPrompt({ type: "prompt", message: "Enter the name(s) of the GM value to delete (comma separated).\nEmpty input cancels the operation.", confirmBtnText: "Delete" });
  8487. if (!keys)
  8488. return;
  8489. for (const key of (_a = keys === null || keys === void 0 ? void 0 : keys.split(",")) !== null && _a !== void 0 ? _a : []) {
  8490. if (key && key.length > 0) {
  8491. const truncLength = 400;
  8492. const oldVal = await GM.getValue(key);
  8493. await GM.deleteValue(key);
  8494. dbg(`Deleted GM value '${key}' with previous value '${oldVal && String(oldVal).length > truncLength ? String(oldVal).substring(0, truncLength) + `… (${String(oldVal).length} / ${truncLength} chars.)` : oldVal}'`);
  8495. }
  8496. }
  8497. });
  8498. GM.registerMenuCommand("Reset install timestamp", async () => {
  8499. await GM.deleteValue("bytm-installed");
  8500. dbg("Reset install time.");
  8501. });
  8502. GM.registerMenuCommand("Reset version check timestamp", async () => {
  8503. await GM.deleteValue("bytm-version-check");
  8504. dbg("Reset version check time.");
  8505. });
  8506. GM.registerMenuCommand("List active selector listeners in console", async () => {
  8507. const lines = [];
  8508. let listenersAmt = 0;
  8509. for (const [obsName, obs] of Object.entries(globservers)) {
  8510. const listeners = obs.getAllListeners();
  8511. lines.push(`- "${obsName}" (${listeners.size} listeners):`);
  8512. [...listeners].forEach(([k, v]) => {
  8513. listenersAmt += v.length;
  8514. lines.push(` [${v.length}] ${k}`);
  8515. v.forEach(({ all, continuous }, i) => {
  8516. lines.push(` ${v.length > 1 && i !== v.length - 1 ? "├" : "└"}> ${continuous ? "continuous" : "single-shot"}${all ? ", multiple" : ""}`);
  8517. });
  8518. });
  8519. }
  8520. dbg(`Showing currently active listeners for ${Object.keys(globservers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
  8521. });
  8522. GM.registerMenuCommand("Compress value", async () => {
  8523. const input = await showPrompt({ type: "prompt", message: "Enter the value to compress.\nSee console for output.", confirmBtnText: "Compress" });
  8524. if (input && input.length > 0) {
  8525. const compressed = await UserUtils.compress(input, compressionFormat);
  8526. dbg(`Compression result (${input.length} chars -> ${compressed.length} chars)\nValue: ${compressed}`);
  8527. }
  8528. });
  8529. GM.registerMenuCommand("Decompress value", async () => {
  8530. const input = await showPrompt({ type: "prompt", message: "Enter the value to decompress.\nSee console for output.", confirmBtnText: "Decompress" });
  8531. if (input && input.length > 0) {
  8532. const decompressed = await UserUtils.decompress(input, compressionFormat);
  8533. dbg(`Decompresion result (${input.length} chars -> ${decompressed.length} chars)\nValue: ${decompressed}`);
  8534. }
  8535. });
  8536. GM.registerMenuCommand("Download DataStoreSerializer file", () => downloadData(false));
  8537. GM.registerMenuCommand("Import all data using DataStoreSerializer", async () => {
  8538. const input = await showPrompt({ type: "prompt", message: "Paste the content of the export file to import:", confirmBtnText: "Import" });
  8539. if (input && input.length > 0) {
  8540. await getStoreSerializer().deserialize(input);
  8541. if (await showPrompt({ type: "confirm", message: "Successfully imported data using DataStoreSerializer.\nReload the page to apply changes?", confirmBtnText: "Reload" }))
  8542. await reloadTab();
  8543. }
  8544. });
  8545. GM.registerMenuCommand("Throw error (toast example)", () => error("Test error thrown by user command:", new SyntaxError("Test error")));
  8546. GM.registerMenuCommand("Example MarkdownDialog", async () => {
  8547. const mdDlg = new MarkdownDialog({
  8548. id: "example",
  8549. width: 500,
  8550. height: 400,
  8551. renderHeader() {
  8552. const header = document.createElement("h1");
  8553. header.textContent = "Example Markdown Dialog";
  8554. return header;
  8555. },
  8556. body: "## This is a test dialog\n```ts\nconsole.log(\"Hello, world!\");\n```\n\n- List item 1\n- List item 2\n- List item 3",
  8557. });
  8558. await mdDlg.open();
  8559. });
  8560. GM.registerMenuCommand("Toggle dev treatments", async () => {
  8561. const val = !await GM.getValue("bytm-dev-treatments", false);
  8562. await GM.setValue("bytm-dev-treatments", val);
  8563. if (await showPrompt({ type: "confirm", message: `Dev treatments are now ${val ? "enabled" : "disabled"}.\nDo you want to reload the page?`, confirmBtnText: "Reload", denyBtnText: "nothxbye" }))
  8564. await reloadTab();
  8565. });
  8566. log("Registered dev menu commands");
  8567. }
  8568. async function runDevTreatments() {
  8569. if (mode !== "development" || !await GM.getValue("bytm-dev-treatments", false))
  8570. return;
  8571. // const dlg = await getAllDataExImDialog();
  8572. // await dlg.open();
  8573. }
  8574. preInit();})(UserUtils,DOMPurify,marked,compareVersions);//# sourceMappingURL=http://localhost:8710/BetterYTM.user.js.map