BetterYTM.user.js 258 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720
  1. // ==UserScript==
  2. // @name BetterYTM
  3. // @namespace https://github.com/Sv443/BetterYTM
  4. // @version 1.1.1
  5. // @description Configurable layout and user experience improvements for YouTube Music
  6. // @description:de-DE Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music
  7. // @description:en-US Configurable layout and user experience improvements for YouTube Music
  8. // @description:en-UK Configurable layout and user experience improvements for YouTube Music
  9. // @description:es-ES Mejoras de diseño y experiencia de usuario configurables para YouTube Music
  10. // @description:fr-FR Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music
  11. // @description:hi-IN YouTube Music के लिए विन्यास और यूजर अनुभव में सुधार करने योग्य लेआउट और यूजर अनुभव सुधार
  12. // @description:ja-JA YouTube Musicのレイアウトとユーザーエクスペリエンスの改善を設定可能にする
  13. // @description:pt-BR Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music
  14. // @description:zh-CN 可配置的布局和YouTube Music的用户体验改进
  15. // @homepageURL https://github.com/Sv443/BetterYTM#readme
  16. // @supportURL https://github.com/Sv443/BetterYTM/issues
  17. // @license AGPL-3.0-only
  18. // @author Sv443
  19. // @copyright Sv443 (https://github.com/Sv443)
  20. // @icon https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/logo/logo_48.png
  21. // @match https://music.youtube.com/*
  22. // @match https://www.youtube.com/*
  23. // @run-at document-start
  24. // @downloadURL https://raw.githubusercontent.com/Sv443/BetterYTM/develop/dist/BetterYTM.user.js
  25. // @updateURL https://raw.githubusercontent.com/Sv443/BetterYTM/develop/dist/BetterYTM.user.js
  26. // @connect api.sv443.net
  27. // @connect github.com
  28. // @connect raw.githubusercontent.com
  29. // @grant GM.getValue
  30. // @grant GM.setValue
  31. // @grant GM.deleteValue
  32. // @grant GM.getResourceUrl
  33. // @grant GM.setClipboard
  34. // @grant GM.xmlHttpRequest
  35. // @grant unsafeWindow
  36. // @noframes
  37. // @resource img-arrow_down https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/arrow_down.svg
  38. // @resource img-delete https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/delete.svg
  39. // @resource img-error https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/error.svg
  40. // @resource img-globe https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/globe.svg
  41. // @resource img-help https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/help.svg
  42. // @resource img-lyrics https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/lyrics.svg
  43. // @resource img-skip_to https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/skip_to.svg
  44. // @resource img-spinner https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/spinner.svg
  45. // @resource img-logo https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/logo/logo_48.png
  46. // @resource img-close https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/close.png
  47. // @resource img-discord https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/external/discord.png
  48. // @resource img-github https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/external/github.png
  49. // @resource img-greasyfork https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/external/greasyfork.png
  50. // @resource img-openuserjs https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/external/openuserjs.png
  51. // @resource css-fix_spacing https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/style/fixSpacing.css
  52. // @resource css-anchor_improvements https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/style/anchorImprovements.css
  53. // @resource trans-de_DE https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/de_DE.json
  54. // @resource trans-en_US https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/en_US.json
  55. // @resource trans-en_UK https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/en_UK.json
  56. // @resource trans-es_ES https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/es_ES.json
  57. // @resource trans-fr_FR https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/fr_FR.json
  58. // @resource trans-hi_IN https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/hi_IN.json
  59. // @resource trans-ja_JA https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/ja_JA.json
  60. // @resource trans-pt_BR https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/pt_BR.json
  61. // @resource trans-zh_CN https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/zh_CN.json
  62. // ==/UserScript==
  63. /*
  64. ▄▄▄ ▄ ▄▄▄▄▄▄ ▄
  65. █ █ ▄▄▄ █ █ ▄▄▄ ▄ ▄█ █ █ █▀▄▀█
  66. █▀▀▄ █▄█ █▀ █▀ █▄█ █▀ █ █ █ █
  67. █▄▄▀ ▀▄▄ ▀▄▄ ▀▄▄ ▀▄▄ █ █ █ █ █
  68. Made with ❤️ by Sv443
  69. I welcome every contribution on GitHub!
  70. https://github.com/Sv443/BetterYTM
  71. */
  72. /* Disclaimer: I am not affiliated with or endorsed by YouTube, Google, Alphabet, Genius or anyone else */
  73. /* C&D this 🖕 */
  74. (function () {
  75. 'use strict';
  76. /******************************************************************************
  77. Copyright (c) Microsoft Corporation.
  78. Permission to use, copy, modify, and/or distribute this software for any
  79. purpose with or without fee is hereby granted.
  80. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
  81. REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
  82. AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
  83. INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
  84. LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
  85. OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
  86. PERFORMANCE OF THIS SOFTWARE.
  87. ***************************************************************************** */
  88. /* global Reflect, Promise, SuppressedError, Symbol */
  89. function __awaiter(thisArg, _arguments, P, generator) {
  90. function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
  91. return new (P || (P = Promise))(function (resolve, reject) {
  92. function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
  93. function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
  94. function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
  95. step((generator = generator.apply(thisArg, _arguments || [])).next());
  96. });
  97. }
  98. function __values(o) {
  99. var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
  100. if (m) return m.call(o);
  101. if (o && typeof o.length === "number") return {
  102. next: function () {
  103. if (o && i >= o.length) o = void 0;
  104. return { value: o && o[i++], done: !o };
  105. }
  106. };
  107. throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
  108. }
  109. function __asyncValues(o) {
  110. if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
  111. var m = o[Symbol.asyncIterator], i;
  112. 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);
  113. 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); }); }; }
  114. function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
  115. }
  116. typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
  117. var e = new Error(message);
  118. return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
  119. };
  120. var __defProp = Object.defineProperty;
  121. var __defProps = Object.defineProperties;
  122. var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
  123. var __getOwnPropSymbols = Object.getOwnPropertySymbols;
  124. var __hasOwnProp = Object.prototype.hasOwnProperty;
  125. var __propIsEnum = Object.prototype.propertyIsEnumerable;
  126. var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
  127. var __spreadValues = (a, b) => {
  128. for (var prop in b || (b = {}))
  129. if (__hasOwnProp.call(b, prop))
  130. __defNormalProp(a, prop, b[prop]);
  131. if (__getOwnPropSymbols)
  132. for (var prop of __getOwnPropSymbols(b)) {
  133. if (__propIsEnum.call(b, prop))
  134. __defNormalProp(a, prop, b[prop]);
  135. }
  136. return a;
  137. };
  138. var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
  139. var __publicField = (obj, key, value) => {
  140. __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
  141. return value;
  142. };
  143. var __async = (__this, __arguments, generator) => {
  144. return new Promise((resolve, reject) => {
  145. var fulfilled = (value) => {
  146. try {
  147. step(generator.next(value));
  148. } catch (e) {
  149. reject(e);
  150. }
  151. };
  152. var rejected = (value) => {
  153. try {
  154. step(generator.throw(value));
  155. } catch (e) {
  156. reject(e);
  157. }
  158. };
  159. var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
  160. step((generator = generator.apply(__this, __arguments)).next());
  161. });
  162. };
  163. // lib/math.ts
  164. function clamp(value, min, max) {
  165. return Math.max(Math.min(value, max), min);
  166. }
  167. function mapRange(value, range1min, range1max, range2min, range2max) {
  168. if (Number(range1min) === 0 && Number(range2min) === 0)
  169. return value * (range2max / range1max);
  170. return (value - range1min) * ((range2max - range2min) / (range1max - range1min)) + range2min;
  171. }
  172. function randRange(...args) {
  173. let min, max;
  174. if (typeof args[0] === "number" && typeof args[1] === "number")
  175. [min, max] = args;
  176. else if (typeof args[0] === "number" && typeof args[1] !== "number") {
  177. min = 0;
  178. [max] = args;
  179. } else
  180. throw new TypeError(`Wrong parameter(s) provided - expected: "number" and "number|undefined", got: "${typeof args[0]}" and "${typeof args[1]}"`);
  181. min = Number(min);
  182. max = Number(max);
  183. if (isNaN(min) || isNaN(max))
  184. return NaN;
  185. if (min > max)
  186. throw new TypeError(`Parameter "min" can't be bigger than "max"`);
  187. return Math.floor(Math.random() * (max - min + 1)) + min;
  188. }
  189. function randomId(length = 16, radix = 16) {
  190. const arr = new Uint8Array(length);
  191. crypto.getRandomValues(arr);
  192. return Array.from(
  193. arr,
  194. (v) => mapRange(v, 0, 255, 0, radix).toString(radix).substring(0, 1)
  195. ).join("");
  196. }
  197. // lib/array.ts
  198. function randomItem(array) {
  199. return randomItemIndex(array)[0];
  200. }
  201. function randomItemIndex(array) {
  202. if (array.length === 0)
  203. return [void 0, void 0];
  204. const idx = randRange(array.length - 1);
  205. return [array[idx], idx];
  206. }
  207. function takeRandomItem(arr) {
  208. const [itm, idx] = randomItemIndex(arr);
  209. if (idx === void 0)
  210. return void 0;
  211. arr.splice(idx, 1);
  212. return itm;
  213. }
  214. function randomizeArray(array) {
  215. const retArray = [...array];
  216. if (array.length === 0)
  217. return array;
  218. for (let i = retArray.length - 1; i > 0; i--) {
  219. const j = Math.floor(randRange(0, 1e4) / 1e4 * (i + 1));
  220. [retArray[i], retArray[j]] = [retArray[j], retArray[i]];
  221. }
  222. return retArray;
  223. }
  224. // lib/ConfigManager.ts
  225. var ConfigManager = class {
  226. /**
  227. * Creates an instance of ConfigManager to manage a user configuration that is cached in memory and persistently saved across sessions.
  228. * Supports migrating data from older versions of the configuration to newer ones and populating the cache with default data if no persistent data is found.
  229. *
  230. * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue`
  231. * ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultConfig`
  232. *
  233. * @template TData The type of the data that is saved in persistent storage (will be automatically inferred from `config.defaultConfig`) - this should also be the type of the data format associated with the current `options.formatVersion`
  234. * @param options The options for this ConfigManager instance
  235. */
  236. constructor(options) {
  237. __publicField(this, "id");
  238. __publicField(this, "formatVersion");
  239. __publicField(this, "defaultConfig");
  240. __publicField(this, "cachedConfig");
  241. __publicField(this, "migrations");
  242. this.id = options.id;
  243. this.formatVersion = options.formatVersion;
  244. this.defaultConfig = options.defaultConfig;
  245. this.cachedConfig = options.defaultConfig;
  246. this.migrations = options.migrations;
  247. }
  248. /**
  249. * Loads the data saved in persistent storage into the in-memory cache and also returns it.
  250. * Automatically populates persistent storage with default data if it doesn't contain any data yet.
  251. * Also runs all necessary migration functions if the data format has changed since the last time the data was saved.
  252. */
  253. loadData() {
  254. return __async(this, null, function* () {
  255. try {
  256. const gmData = yield GM.getValue(`_uucfg-${this.id}`, this.defaultConfig);
  257. let gmFmtVer = Number(yield GM.getValue(`_uucfgver-${this.id}`));
  258. if (typeof gmData !== "string") {
  259. yield this.saveDefaultData();
  260. return this.defaultConfig;
  261. }
  262. if (isNaN(gmFmtVer))
  263. yield GM.setValue(`_uucfgver-${this.id}`, gmFmtVer = this.formatVersion);
  264. let parsed = JSON.parse(gmData);
  265. if (gmFmtVer < this.formatVersion && this.migrations)
  266. parsed = yield this.runMigrations(parsed, gmFmtVer);
  267. return this.cachedConfig = typeof parsed === "object" ? parsed : void 0;
  268. } catch (err) {
  269. yield this.saveDefaultData();
  270. return this.defaultConfig;
  271. }
  272. });
  273. }
  274. /**
  275. * Returns a copy of the data from the in-memory cache.
  276. * Use {@linkcode loadData()} to get fresh data from persistent storage (usually not necessary since the cache should always exactly reflect persistent storage).
  277. */
  278. getData() {
  279. return this.deepCopy(this.cachedConfig);
  280. }
  281. /** Saves the data synchronously to the in-memory cache and asynchronously to the persistent storage */
  282. setData(data) {
  283. this.cachedConfig = data;
  284. return new Promise((resolve) => __async(this, null, function* () {
  285. yield Promise.all([
  286. GM.setValue(`_uucfg-${this.id}`, JSON.stringify(data)),
  287. GM.setValue(`_uucfgver-${this.id}`, this.formatVersion)
  288. ]);
  289. resolve();
  290. }));
  291. }
  292. /** Saves the default configuration data passed in the constructor synchronously to the in-memory cache and asynchronously to persistent storage */
  293. saveDefaultData() {
  294. return __async(this, null, function* () {
  295. this.cachedConfig = this.defaultConfig;
  296. return new Promise((resolve) => __async(this, null, function* () {
  297. yield Promise.all([
  298. GM.setValue(`_uucfg-${this.id}`, JSON.stringify(this.defaultConfig)),
  299. GM.setValue(`_uucfgver-${this.id}`, this.formatVersion)
  300. ]);
  301. resolve();
  302. }));
  303. });
  304. }
  305. /**
  306. * Call this method to clear all persistently stored data associated with this ConfigManager instance.
  307. * The in-memory cache will be left untouched, so you may still access the data with {@linkcode getData()}
  308. * Calling {@linkcode loadData()} or {@linkcode setData()} after this method was called will recreate persistent storage with the cached or default data.
  309. *
  310. * ⚠️ This requires the additional directive `@grant GM.deleteValue`
  311. */
  312. deleteConfig() {
  313. return __async(this, null, function* () {
  314. yield Promise.all([
  315. GM.deleteValue(`_uucfg-${this.id}`),
  316. GM.deleteValue(`_uucfgver-${this.id}`)
  317. ]);
  318. });
  319. }
  320. /** Runs all necessary migration functions consecutively - may be overwritten in a subclass */
  321. runMigrations(oldData, oldFmtVer) {
  322. return __async(this, null, function* () {
  323. if (!this.migrations)
  324. return oldData;
  325. let newData = oldData;
  326. const sortedMigrations = Object.entries(this.migrations).sort(([a], [b]) => Number(a) - Number(b));
  327. let lastFmtVer = oldFmtVer;
  328. for (const [fmtVer, migrationFunc] of sortedMigrations) {
  329. const ver = Number(fmtVer);
  330. if (oldFmtVer < this.formatVersion && oldFmtVer < ver) {
  331. try {
  332. const migRes = migrationFunc(newData);
  333. newData = migRes instanceof Promise ? yield migRes : migRes;
  334. lastFmtVer = oldFmtVer = ver;
  335. } catch (err) {
  336. console.error(`Error while running migration function for format version ${fmtVer}:`, err);
  337. }
  338. }
  339. }
  340. yield Promise.all([
  341. GM.setValue(`_uucfg-${this.id}`, JSON.stringify(newData)),
  342. GM.setValue(`_uucfgver-${this.id}`, lastFmtVer)
  343. ]);
  344. return newData;
  345. });
  346. }
  347. /** Copies a JSON-compatible object and loses its internal references */
  348. deepCopy(obj) {
  349. return JSON.parse(JSON.stringify(obj));
  350. }
  351. };
  352. // lib/dom.ts
  353. function getUnsafeWindow$1() {
  354. try {
  355. return unsafeWindow;
  356. } catch (e) {
  357. return window;
  358. }
  359. }
  360. function insertAfter(beforeElement, afterElement) {
  361. var _a;
  362. (_a = beforeElement.parentNode) == null ? void 0 : _a.insertBefore(afterElement, beforeElement.nextSibling);
  363. return afterElement;
  364. }
  365. function addParent(element, newParent) {
  366. const oldParent = element.parentNode;
  367. if (!oldParent)
  368. throw new Error("Element doesn't have a parent node");
  369. oldParent.replaceChild(newParent, element);
  370. newParent.appendChild(element);
  371. return newParent;
  372. }
  373. function addGlobalStyle(style) {
  374. const styleElem = document.createElement("style");
  375. styleElem.innerHTML = style;
  376. document.head.appendChild(styleElem);
  377. }
  378. function preloadImages(srcUrls, rejects = false) {
  379. const promises = srcUrls.map((src) => new Promise((res, rej) => {
  380. const image = new Image();
  381. image.src = src;
  382. image.addEventListener("load", () => res(image));
  383. image.addEventListener("error", (evt) => rejects && rej(evt));
  384. }));
  385. return Promise.allSettled(promises);
  386. }
  387. function openInNewTab(href) {
  388. const openElem = document.createElement("a");
  389. Object.assign(openElem, {
  390. className: "userutils-open-in-new-tab",
  391. target: "_blank",
  392. rel: "noopener noreferrer",
  393. href
  394. });
  395. openElem.style.display = "none";
  396. document.body.appendChild(openElem);
  397. openElem.click();
  398. setTimeout(openElem.remove, 50);
  399. }
  400. function interceptEvent(eventObject, eventName, predicate = () => true) {
  401. if (typeof Error.stackTraceLimit === "number" && Error.stackTraceLimit < 1e3) {
  402. Error.stackTraceLimit = 1e3;
  403. }
  404. (function(original) {
  405. eventObject.__proto__.addEventListener = function(...args) {
  406. var _a, _b;
  407. const origListener = typeof args[1] === "function" ? args[1] : (_b = (_a = args[1]) == null ? void 0 : _a.handleEvent) != null ? _b : () => void 0;
  408. args[1] = function(...a) {
  409. if (args[0] === eventName && predicate(Array.isArray(a) ? a[0] : a))
  410. return;
  411. else
  412. return origListener.apply(this, a);
  413. };
  414. original.apply(this, args);
  415. };
  416. })(eventObject.__proto__.addEventListener);
  417. }
  418. function interceptWindowEvent(eventName, predicate = () => true) {
  419. return interceptEvent(getUnsafeWindow$1(), eventName, predicate);
  420. }
  421. function isScrollable(element) {
  422. const { overflowX, overflowY } = getComputedStyle(element);
  423. return {
  424. vertical: (overflowY === "scroll" || overflowY === "auto") && element.scrollHeight > element.clientHeight,
  425. horizontal: (overflowX === "scroll" || overflowX === "auto") && element.scrollWidth > element.clientWidth
  426. };
  427. }
  428. function observeElementProp(element, property, callback) {
  429. const elementPrototype = Object.getPrototypeOf(element);
  430. if (elementPrototype.hasOwnProperty(property)) {
  431. const descriptor = Object.getOwnPropertyDescriptor(elementPrototype, property);
  432. Object.defineProperty(element, property, {
  433. get: function() {
  434. var _a;
  435. return (_a = descriptor == null ? void 0 : descriptor.get) == null ? void 0 : _a.apply(this, arguments);
  436. },
  437. set: function() {
  438. var _a;
  439. const oldValue = this[property];
  440. (_a = descriptor == null ? void 0 : descriptor.set) == null ? void 0 : _a.apply(this, arguments);
  441. const newValue = this[property];
  442. if (typeof callback === "function") {
  443. callback.bind(this, oldValue, newValue);
  444. }
  445. return newValue;
  446. }
  447. });
  448. }
  449. }
  450. // lib/misc.ts
  451. function autoPlural(word, num) {
  452. if (Array.isArray(num) || num instanceof NodeList)
  453. num = num.length;
  454. return `${word}${num === 1 ? "" : "s"}`;
  455. }
  456. function pauseFor(time) {
  457. return new Promise((res) => {
  458. setTimeout(() => res(), time);
  459. });
  460. }
  461. function debounce(func, timeout = 300) {
  462. let timer;
  463. return function(...args) {
  464. clearTimeout(timer);
  465. timer = setTimeout(() => func.apply(this, args), timeout);
  466. };
  467. }
  468. function fetchAdvanced(_0) {
  469. return __async(this, arguments, function* (url, options = {}) {
  470. const { timeout = 1e4 } = options;
  471. const controller = new AbortController();
  472. const id = setTimeout(() => controller.abort(), timeout);
  473. const res = yield fetch(url, __spreadProps(__spreadValues({}, options), {
  474. signal: controller.signal
  475. }));
  476. clearTimeout(id);
  477. return res;
  478. });
  479. }
  480. function insertValues(input, ...values) {
  481. return input.replace(/%\d/gm, (match) => {
  482. var _a, _b;
  483. const argIndex = Number(match.substring(1)) - 1;
  484. return (_b = (_a = values[argIndex]) != null ? _a : match) == null ? void 0 : _b.toString();
  485. });
  486. }
  487. function compress(input, compressionFormat, outputType = "base64") {
  488. return __async(this, null, function* () {
  489. const byteArray = typeof input === "string" ? new TextEncoder().encode(input) : input;
  490. const comp = new CompressionStream(compressionFormat);
  491. const writer = comp.writable.getWriter();
  492. writer.write(byteArray);
  493. writer.close();
  494. const buf = yield new Response(comp.readable).arrayBuffer();
  495. return outputType === "arrayBuffer" ? buf : ab2str(buf);
  496. });
  497. }
  498. function decompress(input, compressionFormat, outputType = "string") {
  499. return __async(this, null, function* () {
  500. const byteArray = typeof input === "string" ? str2ab(input) : input;
  501. const decomp = new DecompressionStream(compressionFormat);
  502. const writer = decomp.writable.getWriter();
  503. writer.write(byteArray);
  504. writer.close();
  505. const buf = yield new Response(decomp.readable).arrayBuffer();
  506. return outputType === "arrayBuffer" ? buf : new TextDecoder().decode(buf);
  507. });
  508. }
  509. function ab2str(buf) {
  510. return getUnsafeWindow$1().btoa(
  511. new Uint8Array(buf).reduce((data, byte) => data + String.fromCharCode(byte), "")
  512. );
  513. }
  514. function str2ab(str) {
  515. return Uint8Array.from(getUnsafeWindow$1().atob(str), (c) => c.charCodeAt(0));
  516. }
  517. // lib/SelectorObserver.ts
  518. var SelectorObserver = class {
  519. constructor(baseElement, options = {}) {
  520. __publicField(this, "enabled", false);
  521. __publicField(this, "baseElement");
  522. __publicField(this, "observer");
  523. __publicField(this, "observerOptions");
  524. __publicField(this, "listenerMap");
  525. this.baseElement = baseElement;
  526. this.listenerMap = /* @__PURE__ */ new Map();
  527. this.observer = new MutationObserver(() => this.checkAllSelectors());
  528. this.observerOptions = __spreadValues({
  529. childList: true,
  530. subtree: true
  531. }, options);
  532. }
  533. checkAllSelectors() {
  534. for (const [selector, listeners] of this.listenerMap.entries())
  535. this.checkSelector(selector, listeners);
  536. }
  537. checkSelector(selector, listeners) {
  538. var _a;
  539. if (!this.enabled)
  540. return;
  541. const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
  542. if (!baseElement)
  543. return;
  544. const all = listeners.some((listener) => listener.all);
  545. const one = listeners.some((listener) => !listener.all);
  546. const allElements = all ? baseElement.querySelectorAll(selector) : null;
  547. const oneElement = one ? baseElement.querySelector(selector) : null;
  548. for (const options of listeners) {
  549. if (options.all) {
  550. if (allElements && allElements.length > 0) {
  551. options.listener(allElements);
  552. if (!options.continuous)
  553. this.removeListener(selector, options);
  554. }
  555. } else {
  556. if (oneElement) {
  557. options.listener(oneElement);
  558. if (!options.continuous)
  559. this.removeListener(selector, options);
  560. }
  561. }
  562. if (((_a = this.listenerMap.get(selector)) == null ? void 0 : _a.length) === 0)
  563. this.listenerMap.delete(selector);
  564. }
  565. }
  566. debounce(func, time) {
  567. let timeout;
  568. return function(...args) {
  569. clearTimeout(timeout);
  570. timeout = setTimeout(() => func.apply(this, args), time);
  571. };
  572. }
  573. /**
  574. * Starts observing the children of the base element for changes to the given {@linkcode selector} according to the set {@linkcode options}
  575. * @param selector The selector to observe
  576. * @param options Options for the selector observation
  577. * @param options.listener Gets called whenever the selector was found in the DOM
  578. * @param [options.all] Whether to use `querySelectorAll()` instead - default is false
  579. * @param [options.continuous] Whether to call the listener continuously instead of just once - default is false
  580. * @param [options.debounce] Whether to debounce the listener to reduce calls to `querySelector` or `querySelectorAll` - set undefined or <=0 to disable (default)
  581. */
  582. addListener(selector, options) {
  583. options = __spreadValues({ all: false, continuous: false, debounce: 0 }, options);
  584. if (options.debounce && options.debounce > 0 || this.observerOptions.defaultDebounce && this.observerOptions.defaultDebounce > 0) {
  585. options.listener = this.debounce(
  586. options.listener,
  587. options.debounce || this.observerOptions.defaultDebounce
  588. );
  589. }
  590. if (this.listenerMap.has(selector))
  591. this.listenerMap.get(selector).push(options);
  592. else
  593. this.listenerMap.set(selector, [options]);
  594. this.checkSelector(selector, [options]);
  595. }
  596. /** Disables the observation of the child elements */
  597. disable() {
  598. if (!this.enabled)
  599. return;
  600. this.enabled = false;
  601. this.observer.disconnect();
  602. }
  603. /**
  604. * Enables or reenables the observation of the child elements.
  605. * @param immediatelyCheckSelectors Whether to immediately check if all previously registered selectors exist (default is true)
  606. * @returns Returns true when the observation was enabled, false otherwise (e.g. when the base element wasn't found)
  607. */
  608. enable(immediatelyCheckSelectors = true) {
  609. const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
  610. if (this.enabled || !baseElement)
  611. return false;
  612. this.enabled = true;
  613. this.observer.observe(baseElement, this.observerOptions);
  614. if (immediatelyCheckSelectors)
  615. this.checkAllSelectors();
  616. return true;
  617. }
  618. /** Returns whether the observation of the child elements is currently enabled */
  619. isEnabled() {
  620. return this.enabled;
  621. }
  622. /** Removes all listeners that have been registered with {@linkcode addListener()} */
  623. clearListeners() {
  624. this.listenerMap.clear();
  625. }
  626. /**
  627. * Removes all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()}
  628. * @returns Returns true when all listeners for the associated selector were found and removed, false otherwise
  629. */
  630. removeAllListeners(selector) {
  631. return this.listenerMap.delete(selector);
  632. }
  633. /**
  634. * Removes a single listener for the given {@linkcode selector} and {@linkcode options} that has been registered with {@linkcode addListener()}
  635. * @returns Returns true when the listener was found and removed, false otherwise
  636. */
  637. removeListener(selector, options) {
  638. const listeners = this.listenerMap.get(selector);
  639. if (!listeners)
  640. return false;
  641. const index = listeners.indexOf(options);
  642. if (index > -1) {
  643. listeners.splice(index, 1);
  644. return true;
  645. }
  646. return false;
  647. }
  648. /** Returns all listeners that have been registered with {@linkcode addListener()} */
  649. getAllListeners() {
  650. return this.listenerMap;
  651. }
  652. /** Returns all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()} */
  653. getListeners(selector) {
  654. return this.listenerMap.get(selector);
  655. }
  656. };
  657. // lib/translation.ts
  658. var trans = {};
  659. var curLang;
  660. function tr(key, ...args) {
  661. var _a;
  662. if (!curLang)
  663. return key;
  664. const trText = (_a = trans[curLang]) == null ? void 0 : _a[key];
  665. if (!trText)
  666. return key;
  667. if (args.length > 0 && trText.match(/%\d/)) {
  668. return insertValues(trText, ...args);
  669. }
  670. return trText;
  671. }
  672. tr.addLanguage = (language, translations) => {
  673. trans[language] = translations;
  674. };
  675. tr.setLanguage = (language) => {
  676. curLang = language;
  677. };
  678. tr.getLanguage = () => {
  679. return curLang;
  680. };
  681. var UserUtils = /*#__PURE__*/Object.freeze({
  682. __proto__: null,
  683. ConfigManager: ConfigManager,
  684. SelectorObserver: SelectorObserver,
  685. addGlobalStyle: addGlobalStyle,
  686. addParent: addParent,
  687. autoPlural: autoPlural,
  688. clamp: clamp,
  689. compress: compress,
  690. debounce: debounce,
  691. decompress: decompress,
  692. fetchAdvanced: fetchAdvanced,
  693. getUnsafeWindow: getUnsafeWindow$1,
  694. insertAfter: insertAfter,
  695. insertValues: insertValues,
  696. interceptEvent: interceptEvent,
  697. interceptWindowEvent: interceptWindowEvent,
  698. isScrollable: isScrollable,
  699. mapRange: mapRange,
  700. observeElementProp: observeElementProp,
  701. openInNewTab: openInNewTab,
  702. pauseFor: pauseFor,
  703. preloadImages: preloadImages,
  704. randRange: randRange,
  705. randomId: randomId,
  706. randomItem: randomItem,
  707. randomItemIndex: randomItemIndex,
  708. randomizeArray: randomizeArray,
  709. takeRandomItem: takeRandomItem,
  710. tr: tr
  711. });
  712. let createNanoEvents = () => ({
  713. emit(event, ...args) {
  714. for (
  715. let i = 0,
  716. callbacks = this.events[event] || [],
  717. length = callbacks.length;
  718. i < length;
  719. i++
  720. ) {
  721. callbacks[i](...args);
  722. }
  723. },
  724. events: {},
  725. on(event, cb) {
  726. (this.events[event] ||= []).push(cb);
  727. return () => {
  728. this.events[event] = this.events[event]?.filter(i => cb !== i);
  729. }
  730. }
  731. });
  732. /** Abstract class that can be extended to create an event emitter with helper methods and a strongly typed event map */
  733. class NanoEmitter {
  734. constructor() {
  735. Object.defineProperty(this, "events", {
  736. enumerable: true,
  737. configurable: true,
  738. writable: true,
  739. value: createNanoEvents()
  740. });
  741. Object.defineProperty(this, "unsubscribers", {
  742. enumerable: true,
  743. configurable: true,
  744. writable: true,
  745. value: []
  746. });
  747. }
  748. /** Subscribes to an event - returns a function that unsubscribes the event listener */
  749. on(event, cb) {
  750. // eslint-disable-next-line prefer-const
  751. let unsub;
  752. const unsubProxy = () => {
  753. if (!unsub)
  754. return;
  755. unsub();
  756. this.unsubscribers = this.unsubscribers.filter(u => u !== unsub);
  757. };
  758. unsub = this.events.on(event, cb);
  759. this.unsubscribers.push(unsub);
  760. return unsubProxy;
  761. }
  762. /** Unsubscribes all event listeners */
  763. unsubscribeAll() {
  764. for (const unsub of this.unsubscribers)
  765. unsub();
  766. this.unsubscribers = [];
  767. }
  768. }
  769. // I know TS enums are impure but it doesn't really matter here, plus they look cooler
  770. var LogLevel;
  771. (function (LogLevel) {
  772. LogLevel[LogLevel["Debug"] = 0] = "Debug";
  773. LogLevel[LogLevel["Info"] = 1] = "Info";
  774. })(LogLevel || (LogLevel = {}));
  775. const modeRaw = "production";
  776. const branchRaw = "develop";
  777. const hostRaw = "github";
  778. /** The mode in which the script was built (production or development) */
  779. const mode = (modeRaw.match(/^#{{.+}}$/) ? "production" : modeRaw);
  780. /** The branch to use in various URLs that point to the GitHub repo */
  781. const branch = (branchRaw.match(/^#{{.+}}$/) ? "main" : branchRaw);
  782. /** Path to the GitHub repo */
  783. const repo = "Sv443/BetterYTM";
  784. /** Which host the userscript was installed from */
  785. const host = (hostRaw.match(/^#{{.+}}$/) ? "github" : hostRaw);
  786. /**
  787. * How much info should be logged to the devtools console
  788. * 0 = Debug (show everything) or 1 = Info (show only important stuff)
  789. */
  790. const defaultLogLevel = mode === "production" ? LogLevel.Info : LogLevel.Debug;
  791. /** Info about the userscript, parsed from the userscript header (tools/post-build.js) */
  792. const scriptInfo = {
  793. name: GM.info.script.name,
  794. version: GM.info.script.version,
  795. namespace: GM.info.script.namespace,
  796. buildNumber: "b86580a", // asserted as generic string instead of literal
  797. };
  798. /** Options that are applied to every SelectorObserver instance */
  799. const defaultObserverOptions = {
  800. defaultDebounce: 100,
  801. };
  802. const observers$1 = {};
  803. /** Call after DOM load to initialize all SelectorObserver instances */
  804. function initObservers() {
  805. try {
  806. // #SECTION body = the entire <body> element - use sparingly due to performance impacts!
  807. observers$1.body = new SelectorObserver(document.body, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: false }));
  808. observers$1.body.enable();
  809. // #SECTION playerBar = media controls bar at the bottom of the page
  810. const playerBarSelector = "ytmusic-app-layout ytmusic-player-bar.ytmusic-app";
  811. observers$1.playerBar = new SelectorObserver(playerBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 200 }));
  812. observers$1.body.addListener(playerBarSelector, {
  813. listener: () => {
  814. console.log("#DBG-UU enabling playerBar observer");
  815. observers$1.playerBar.enable();
  816. },
  817. });
  818. // #SECTION playerBarInfo = song title, artist, album, etc. inside the player bar
  819. const playerBarInfoSelector = `${playerBarSelector} .middle-controls .content-info-wrapper`;
  820. observers$1.playerBarInfo = new SelectorObserver(playerBarInfoSelector, Object.assign(Object.assign({}, defaultObserverOptions), { attributes: true, attributeFilter: ["title"] }));
  821. observers$1.playerBarInfo.addListener(playerBarInfoSelector, {
  822. listener: () => {
  823. console.log("#DBG-UU enabling playerBarTitle observer");
  824. observers$1.playerBarInfo.enable();
  825. },
  826. });
  827. // #DEBUG example: listen for title change:
  828. observers$1.playerBarInfo.addListener("yt-formatted-string.title", {
  829. continuous: true,
  830. listener: (titleElem) => {
  831. console.log("#DBG-UU >>>>> title changed", titleElem.title);
  832. },
  833. });
  834. emitInterface("bytm:observersReady");
  835. }
  836. catch (err) {
  837. error("Failed to initialize observers:", err);
  838. }
  839. }
  840. /** Interface function for adding listeners to the already present observers */
  841. function addSelectorListener(observerName, selector, options) {
  842. observers$1[observerName].addListener(selector, options);
  843. }
  844. var de_DE = {
  845. name: "Deutsch (Deutschland)",
  846. userscriptDesc: "Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music",
  847. authors: [
  848. "Sv443"
  849. ]
  850. };
  851. var en_US = {
  852. name: "English (United States)",
  853. userscriptDesc: "Configurable layout and user experience improvements for YouTube Music",
  854. authors: [
  855. "Sv443"
  856. ]
  857. };
  858. var en_UK = {
  859. name: "English (United Kingdom)",
  860. userscriptDesc: "Configurable layout and user experience improvements for YouTube Music",
  861. authors: [
  862. "Sv443"
  863. ]
  864. };
  865. var es_ES = {
  866. name: "Español (España)",
  867. userscriptDesc: "Mejoras de diseño y experiencia de usuario configurables para YouTube Music",
  868. authors: [
  869. "Sv443"
  870. ]
  871. };
  872. var fr_FR = {
  873. name: "Français (France)",
  874. userscriptDesc: "Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music",
  875. authors: [
  876. "Sv443"
  877. ]
  878. };
  879. var hi_IN = {
  880. name: "हिंदी (भारत)",
  881. userscriptDesc: "YouTube Music के लिए विन्यास और यूजर अनुभव में सुधार करने योग्य लेआउट और यूजर अनुभव सुधार",
  882. authors: [
  883. "Sv443"
  884. ]
  885. };
  886. var ja_JA = {
  887. name: "日本語 (日本)",
  888. userscriptDesc: "YouTube Musicのレイアウトとユーザーエクスペリエンスの改善を設定可能にする",
  889. authors: [
  890. "Sv443"
  891. ]
  892. };
  893. var pt_BR = {
  894. name: "Português (Brasil)",
  895. userscriptDesc: "Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music",
  896. authors: [
  897. "Sv443"
  898. ]
  899. };
  900. var zh_CN = {
  901. name: "中文(简化,中国)",
  902. userscriptDesc: "可配置的布局和YouTube Music的用户体验改进",
  903. authors: [
  904. "Sv443"
  905. ]
  906. };
  907. var locales = {
  908. de_DE: de_DE,
  909. en_US: en_US,
  910. en_UK: en_UK,
  911. es_ES: es_ES,
  912. fr_FR: fr_FR,
  913. hi_IN: hi_IN,
  914. ja_JA: ja_JA,
  915. pt_BR: pt_BR,
  916. zh_CN: zh_CN
  917. };
  918. let features$3;
  919. function preInitBehavior(feats) {
  920. features$3 = feats;
  921. }
  922. //#MARKER beforeunload popup
  923. let beforeUnloadEnabled = true;
  924. /** Disables the popup before leaving the site */
  925. function disableBeforeUnload() {
  926. beforeUnloadEnabled = false;
  927. info("Disabled popup before leaving the site");
  928. }
  929. /**
  930. * Adds a spy function into `window.__proto__.addEventListener` to selectively discard `beforeunload`
  931. * event listeners before they can be called by the site.
  932. */
  933. function initBeforeUnloadHook() {
  934. return __awaiter(this, void 0, void 0, function* () {
  935. Error.stackTraceLimit = 1000; // default is 25 on FF so this should hopefully be more than enough
  936. (function (original) {
  937. // @ts-ignore
  938. window.__proto__.addEventListener = function (...args) {
  939. const origListener = typeof args[1] === "function" ? args[1] : args[1].handleEvent;
  940. args[1] = function (...a) {
  941. if (!beforeUnloadEnabled && args[0] === "beforeunload") {
  942. info("Prevented beforeunload event listener from being called");
  943. return false;
  944. }
  945. else
  946. return origListener.apply(this, a);
  947. };
  948. original.apply(this, args);
  949. };
  950. // @ts-ignore
  951. })(window.__proto__.addEventListener);
  952. });
  953. }
  954. //#MARKER auto close toasts
  955. /** Closes toasts after a set amount of time */
  956. function initAutoCloseToasts() {
  957. return __awaiter(this, void 0, void 0, function* () {
  958. try {
  959. const animTimeout = 300;
  960. const closeTimeout = Math.max(features$3.closeToastsTimeout * 1000 + animTimeout, animTimeout);
  961. onSelectorOld("tp-yt-paper-toast#toast", {
  962. all: true,
  963. continuous: true,
  964. listener: (toastElems) => __awaiter(this, void 0, void 0, function* () {
  965. var _a;
  966. for (const toastElem of toastElems) {
  967. if (!toastElem.hasAttribute("allow-click-through"))
  968. continue;
  969. if (toastElem.classList.contains("bytm-closing"))
  970. continue;
  971. toastElem.classList.add("bytm-closing");
  972. yield pauseFor(closeTimeout);
  973. toastElem.classList.remove("paper-toast-open");
  974. log(`Automatically closed toast '${(_a = toastElem.querySelector("#text-container yt-formatted-string")) === null || _a === void 0 ? void 0 : _a.textContent}' after ${features$3.closeToastsTimeout * 1000}ms`);
  975. // wait for the transition to finish
  976. yield pauseFor(animTimeout);
  977. toastElem.style.display = "none";
  978. }
  979. }),
  980. });
  981. log("Initialized automatic toast closing");
  982. }
  983. catch (err) {
  984. error("Error in automatic toast closing:", err);
  985. }
  986. });
  987. }
  988. /** After how many milliseconds a remembered entry should expire */
  989. const remSongEntryExpiry = 1000 * 60 * 1;
  990. /** Minimum time a song has to be played before it is committed to GM storage */
  991. const remSongMinPlayTime = 10;
  992. let remSongsCache = [];
  993. /** Remembers the time of the last played song and resumes playback from that time */
  994. function initRememberSongTime() {
  995. return __awaiter(this, void 0, void 0, function* () {
  996. if (features$3.rememberSongTimeSites !== "all" && features$3.rememberSongTimeSites !== getDomain())
  997. return;
  998. const storedDataRaw = yield GM.getValue("bytm-rem-songs");
  999. if (!storedDataRaw)
  1000. yield GM.setValue("bytm-rem-songs", "[]");
  1001. remSongsCache = JSON.parse(String(storedDataRaw !== null && storedDataRaw !== void 0 ? storedDataRaw : "[]"));
  1002. log(`Initialized song time remembering with ${remSongsCache.length} initial entries`);
  1003. if (location.pathname.startsWith("/watch"))
  1004. yield restoreSongTime();
  1005. remSongUpdateEntry();
  1006. setInterval(remSongUpdateEntry, 1000);
  1007. });
  1008. }
  1009. /** Tries to restore the time of the currently playing song */
  1010. function restoreSongTime() {
  1011. return __awaiter(this, void 0, void 0, function* () {
  1012. if (location.pathname.startsWith("/watch")) {
  1013. const { searchParams } = new URL(location.href);
  1014. const watchID = searchParams.get("v");
  1015. if (!watchID)
  1016. return;
  1017. const entry = remSongsCache.find(entry => entry.watchID === watchID);
  1018. if (entry) {
  1019. if (Date.now() - entry.updateTimestamp > remSongEntryExpiry) {
  1020. yield delRemSongData(entry.watchID);
  1021. return;
  1022. }
  1023. else {
  1024. onSelectorOld(videoSelector, {
  1025. listener: (vidElem) => __awaiter(this, void 0, void 0, function* () {
  1026. if (vidElem) {
  1027. const applyTime = () => __awaiter(this, void 0, void 0, function* () {
  1028. if (isNaN(entry.songTime))
  1029. return;
  1030. vidElem.currentTime = clamp(Math.max(entry.songTime, 0), 0, vidElem.duration);
  1031. yield delRemSongData(entry.watchID);
  1032. info(`Restored song time to ${Math.floor(entry.songTime / 60)}m, ${(entry.songTime % 60).toFixed(1)}s`, LogLevel.Info);
  1033. });
  1034. if (vidElem.readyState === 4)
  1035. applyTime();
  1036. else
  1037. vidElem.addEventListener("canplay", applyTime, { once: true });
  1038. }
  1039. }),
  1040. });
  1041. }
  1042. }
  1043. }
  1044. });
  1045. }
  1046. /** Updates the currently playing song's entry in GM storage */
  1047. function remSongUpdateEntry() {
  1048. var _a, _b, _c;
  1049. return __awaiter(this, void 0, void 0, function* () {
  1050. if (location.pathname.startsWith("/watch")) {
  1051. const { searchParams } = new URL(location.href);
  1052. const watchID = searchParams.get("v");
  1053. if (!watchID)
  1054. return;
  1055. const songTime = (_a = yield getVideoTime()) !== null && _a !== void 0 ? _a : 0;
  1056. const paused = (_c = (_b = document.querySelector(videoSelector)) === null || _b === void 0 ? void 0 : _b.paused) !== null && _c !== void 0 ? _c : false;
  1057. // don't immediately update to reduce race conditions and only update if the video is playing
  1058. // also it just sounds better if the song starts at the beginning if only a couple seconds have passed
  1059. if (songTime > remSongMinPlayTime && !paused) {
  1060. const entry = {
  1061. watchID,
  1062. songTime,
  1063. updateTimestamp: Date.now(),
  1064. };
  1065. yield setRemSongData(entry);
  1066. }
  1067. // if the song is rewound to the beginning, delete the entry
  1068. else {
  1069. const entry = remSongsCache.find(entry => entry.watchID === watchID);
  1070. if (entry && songTime <= remSongMinPlayTime)
  1071. yield delRemSongData(entry.watchID);
  1072. }
  1073. }
  1074. const expiredEntries = remSongsCache.filter(entry => Date.now() - entry.updateTimestamp > remSongEntryExpiry);
  1075. for (const entry of expiredEntries)
  1076. yield delRemSongData(entry.watchID);
  1077. });
  1078. }
  1079. /** Adds an entry or updates it if it already exists */
  1080. function setRemSongData(data) {
  1081. return __awaiter(this, void 0, void 0, function* () {
  1082. const foundIdx = remSongsCache.findIndex(entry => entry.watchID === data.watchID);
  1083. if (foundIdx >= 0)
  1084. remSongsCache[foundIdx] = data;
  1085. else
  1086. remSongsCache.push(data);
  1087. yield GM.setValue("bytm-rem-songs", JSON.stringify(remSongsCache));
  1088. });
  1089. }
  1090. /** Deletes an entry */
  1091. function delRemSongData(watchID) {
  1092. return __awaiter(this, void 0, void 0, function* () {
  1093. remSongsCache = [...remSongsCache.filter(entry => entry.watchID !== watchID)];
  1094. yield GM.setValue("bytm-rem-songs", JSON.stringify(remSongsCache));
  1095. });
  1096. }
  1097. //#MARKER disable darkreader
  1098. /** Disables Dark Reader if it is enabled */
  1099. function disableDarkReader() {
  1100. if (document.querySelector(".darkreader")) {
  1101. const metaElem = document.createElement("meta");
  1102. metaElem.name = "darkreader-lock";
  1103. metaElem.classList.add("bytm-disable-darkreader");
  1104. document.head.appendChild(metaElem);
  1105. info("Sent hint to Dark Reader to disable itself");
  1106. }
  1107. }
  1108. /** EventEmitter instance that is used to detect changes to the site */
  1109. const siteEvents = createNanoEvents();
  1110. let observers = [];
  1111. /** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */
  1112. function initSiteEvents() {
  1113. return __awaiter(this, void 0, void 0, function* () {
  1114. try {
  1115. //#SECTION queue
  1116. // the queue container always exists so it doesn't need an extra init function
  1117. const queueObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => {
  1118. if (addedNodes.length > 0 || removedNodes.length > 0) {
  1119. info(`Detected queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  1120. emitSiteEvent("queueChanged", target);
  1121. }
  1122. });
  1123. // only observe added or removed elements
  1124. queueObs.observe(document.querySelector("#side-panel #contents.ytmusic-player-queue"), {
  1125. childList: true,
  1126. });
  1127. const autoplayObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => {
  1128. if (addedNodes.length > 0 || removedNodes.length > 0) {
  1129. info(`Detected autoplay queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  1130. emitSiteEvent("autoplayQueueChanged", target);
  1131. }
  1132. });
  1133. autoplayObs.observe(document.querySelector("#side-panel ytmusic-player-queue #automix-contents"), {
  1134. childList: true,
  1135. });
  1136. info("Successfully initialized SiteEvents observers");
  1137. observers = observers.concat([
  1138. queueObs,
  1139. autoplayObs,
  1140. ]);
  1141. }
  1142. catch (err) {
  1143. error("Couldn't initialize SiteEvents observers due to an error:\n", err);
  1144. }
  1145. });
  1146. }
  1147. /** Emits a site event with the given key and arguments */
  1148. function emitSiteEvent(key, ...args) {
  1149. siteEvents.emit(key, ...args);
  1150. emitInterface(`bytm:siteEvent:${key}`, args);
  1151. }
  1152. var changelog = {"html":"<h2 id=\"111\">1.1.1</h2>\n<ul>\n<li><strong>Fixes:</strong><ul>\n<li>Fixed Chinese translations</li></ul></li>\n<li><strong>Internal Changes:</strong><ul>\n<li>Removed React JSX support</li>\n<li>Small utility function refactoring</li></ul></li>\n</ul>\n<p><a href=\"https://github.com/Sv443/BetterYTM/pull/TODO\">See pull request for more info</a></p>\n<div class=\"split\"></div>\n<p><br></p>\n<h2 id=\"110\">1.1.0</h2>\n<ul>\n<li><strong>Features / Changes:</strong><ul>\n<li>The userscript is now available in 9 languages! To submit or edit translations, please <a href=\"https://github.com/Sv443/BetterYTM/blob/main/contributing.md#submitting-translations\">view this guide</a></li>\n<li>Added an interface for user-created plugins (<a href=\"https://github.com/Sv443/BetterYTM/blob/develop/contributing.md#developing-a-plugin-that-interfaces-with-betterytm\">see contributing guide for more info</a>)</li>\n<li>Made site switch hotkey customizable</li>\n<li>Userscript will now show a welcome page after first install / update</li>\n<li>Feature to restore last song's time on page reload</li>\n<li>Made interval of arrow key skip configurable</li>\n<li>A hint is now sent to Dark Reader to disable itself (see <a href=\"https://github.com/darkreader/darkreader/discussions/6868#discussioncomment-3109841\">this</a>)</li>\n<li>Made volume slider scroll sensitivity configurable</li>\n<li>Added details / help dialog to menu feature list</li>\n<li>Added queue buttons to all types of song list</li>\n<li>Added manual version check (can be disabled in config menu)</li></ul></li>\n<li><strong>Fixes:</strong><ul>\n<li>BetterYTM now uses a more reliable way to skip to a certain time</li>\n<li>Fixed resources not loading in Chrome</li>\n<li>Fixed album list spacing getting messed up by anchor improvements styling</li>\n<li>Fixed \"Start at\" option in share menu making tracking parameter reappear</li>\n<li>Fixed selector for player queue that was changed by a YTM update</li></ul></li>\n<li><strong>Internal Changes:</strong><ul>\n<li>The license of the source code has been changed from MIT to <a href=\"https://github.com/Sv443/BetterYTM/blob/main/LICENSE.txt\">AGPL-3.0</a></li>\n<li>Migrated to the Rollup bundler</li>\n<li>Now multiple versions of the script are compiled for the different hosts (GitHub, GreasyFork, OpenUserJS) with slight compatibility fixes each</li>\n<li>Target branch can now be specified while compiling instead of being tied to the bundler mode</li>\n<li>Added support for React JSX</li>\n<li>Added support for external libraries through <code>@require</code></li></ul></li>\n</ul>\n<p><a href=\"https://github.com/Sv443/BetterYTM/pull/35\">See pull request for more info</a></p>\n<div class=\"split\"></div>\n<p><br></p>\n<h2 id=\"102\">1.0.2</h2>\n<ul>\n<li><strong>Changes:</strong><ul>\n<li>Script is now published to OpenUserJS!</li>\n<li>Added a OpenUserJS link to the configuration menu</li></ul></li>\n</ul>\n<div class=\"split\"></div>\n<p><br></p>\n<h2 id=\"101\">1.0.1</h2>\n<ul>\n<li><strong>Changes:</strong><ul>\n<li>Script is now published to GreasyFork!</li>\n<li>Added a GreasyFork link to the configuration menu</li></ul></li>\n</ul>\n<div class=\"split\"></div>\n<p><br></p>\n<h2 id=\"100\">1.0.0</h2>\n<ul>\n<li><strong>Added Features:</strong><ul>\n<li>Added configuration menu to toggle and configure all features</li>\n<li>Added lyrics button to each song in the queue</li>\n<li>Added \"remove from queue\" button to each song in the queue</li>\n<li>Use number keys to skip to a specific point in the song</li>\n<li>Added feature to make volume slider bigger and volume control finer</li>\n<li>Added percentage label next to the volume slider &amp; title on hover</li>\n<li>Improvements to link hitboxes &amp; more links in general</li>\n<li>Permanent toast notifications can be automatically closed now</li>\n<li>Remove tracking parameter <code>&amp;si</code> from links in the share menu</li>\n<li>Fix spacing issues throughout the site</li>\n<li>Added a button to scroll to the currently active song in the queue</li>\n<li>Added an easter egg to the watermark and config menu option :)</li></ul></li>\n<li><strong>Changes & Fixes:</strong><ul>\n<li>Now the lyrics button will directly link to the lyrics (using my API <a href=\"https://github.com/Sv443/geniURL\">geniURL</a>)</li>\n<li>Video time is now kept when switching site on regular YT too</li>\n<li>Fixed compatibility with the new site design</li>\n<li>A loading indicator is shown while the lyrics are loading</li>\n<li>Images are now smaller and cached by the userscript extension</li>\n<li>Song names with hyphens are now resolved better for lyrics lookup</li>\n<li>Site switch with <kbd>F9</kbd> will now keep the video time</li>\n<li>Moved lots of utility code to my new library <a href=\"https://github.com/Sv443-Network/UserUtils\">UserUtils</a></li></ul></li>\n</ul>\n<p><a href=\"https://github.com/Sv443/BetterYTM/pull/9\">See pull request for more info</a></p>\n<div class=\"split\"></div>\n<p><br></p>\n<h2 id=\"020\">0.2.0</h2>\n<ul>\n<li><strong>Added Features:</strong><ul>\n<li>Switch between YouTube and YT Music (with <kbd>F9</kbd> by default)</li>\n<li>Search for song lyrics with new button in media controls</li>\n<li>Remove \"Upgrade to YTM Premium\" tab</li></ul></li>\n</ul>\n<p><a href=\"https://github.com/Sv443/BetterYTM/pull/3\">See pull request for more info</a></p>\n<div class=\"split\"></div>\n<p><br></p>\n<h2 id=\"010\">0.1.0</h2>\n<ul>\n<li>Added support for arrow keys to skip forward or backward (currently only by fixed 10 second interval)</li>\n</ul>","metadata":{},"filename":"changelog.md","path":"/Users/svenfehler/Code/sv443/BetterYTM/changelog.md"};
  1153. /** Creates a hotkey input element */
  1154. function createHotkeyInput({ initialValue, resetValue, onChange }) {
  1155. var _a;
  1156. const wrapperElem = document.createElement("div");
  1157. wrapperElem.classList.add("bytm-hotkey-wrapper");
  1158. const infoElem = document.createElement("span");
  1159. infoElem.classList.add("bytm-hotkey-info");
  1160. const inputElem = document.createElement("input");
  1161. inputElem.type = "button";
  1162. inputElem.classList.add("bytm-ftconf-input", "bytm-hotkey-input", "bytm-btn");
  1163. inputElem.dataset.state = "inactive";
  1164. inputElem.value = (_a = initialValue === null || initialValue === void 0 ? void 0 : initialValue.code) !== null && _a !== void 0 ? _a : t("hotkey_input_click_to_change");
  1165. inputElem.ariaLabel = inputElem.title = t("hotkey_input_click_to_change_tooltip");
  1166. const resetElem = document.createElement("span");
  1167. resetElem.classList.add("bytm-hotkey-reset", "bytm-link");
  1168. resetElem.role = "button";
  1169. resetElem.tabIndex = 0;
  1170. resetElem.textContent = `(${t("reset")})`;
  1171. const resetClicked = (e) => {
  1172. e.preventDefault();
  1173. e.stopImmediatePropagation();
  1174. onChange(resetValue);
  1175. inputElem.value = resetValue.code;
  1176. inputElem.dataset.state = "inactive";
  1177. infoElem.textContent = getHotkeyInfo(resetValue);
  1178. };
  1179. resetElem.addEventListener("click", resetClicked);
  1180. resetElem.addEventListener("keydown", (e) => e.key === "Enter" && resetClicked(e));
  1181. if (initialValue)
  1182. infoElem.textContent = getHotkeyInfo(initialValue);
  1183. let lastKeyDown;
  1184. document.addEventListener("keypress", (e) => {
  1185. if (inputElem.dataset.state !== "active")
  1186. return;
  1187. 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)
  1188. return;
  1189. e.preventDefault();
  1190. e.stopImmediatePropagation();
  1191. const hotkey = {
  1192. code: e.code,
  1193. shift: e.shiftKey,
  1194. ctrl: e.ctrlKey,
  1195. alt: e.altKey,
  1196. };
  1197. inputElem.value = hotkey.code;
  1198. inputElem.dataset.state = "inactive";
  1199. infoElem.textContent = getHotkeyInfo(hotkey);
  1200. onChange(hotkey);
  1201. });
  1202. document.addEventListener("keydown", (e) => {
  1203. if (inputElem.dataset.state !== "active")
  1204. return;
  1205. if (["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight"].includes(e.code))
  1206. return;
  1207. e.preventDefault();
  1208. e.stopImmediatePropagation();
  1209. const hotkey = {
  1210. code: e.code,
  1211. shift: e.shiftKey,
  1212. ctrl: e.ctrlKey,
  1213. alt: e.altKey,
  1214. };
  1215. lastKeyDown = hotkey;
  1216. inputElem.value = hotkey.code;
  1217. inputElem.dataset.state = "inactive";
  1218. infoElem.textContent = getHotkeyInfo(hotkey);
  1219. inputElem.ariaLabel = inputElem.title = t("hotkey_input_click_to_cancel_tooltip");
  1220. onChange(hotkey);
  1221. });
  1222. const deactivate = () => {
  1223. var _a, _b;
  1224. siteEvents.emit("hotkeyInputActive", false);
  1225. const curVal = (_a = getFeatures().switchSitesHotkey) !== null && _a !== void 0 ? _a : initialValue;
  1226. inputElem.value = (_b = curVal === null || curVal === void 0 ? void 0 : curVal.code) !== null && _b !== void 0 ? _b : t("hotkey_input_click_to_change");
  1227. inputElem.dataset.state = "inactive";
  1228. inputElem.ariaLabel = inputElem.title = t("hotkey_input_click_to_change_tooltip");
  1229. infoElem.textContent = curVal ? getHotkeyInfo(curVal) : "";
  1230. };
  1231. const activate = () => {
  1232. siteEvents.emit("hotkeyInputActive", true);
  1233. inputElem.value = "< ... >";
  1234. inputElem.dataset.state = "active";
  1235. inputElem.ariaLabel = inputElem.title = t("hotkey_input_click_to_cancel_tooltip");
  1236. };
  1237. siteEvents.on("cfgMenuClosed", deactivate);
  1238. inputElem.addEventListener("click", () => {
  1239. if (inputElem.dataset.state === "active")
  1240. deactivate();
  1241. else
  1242. activate();
  1243. });
  1244. wrapperElem.appendChild(infoElem);
  1245. wrapperElem.appendChild(inputElem);
  1246. resetValue && wrapperElem.appendChild(resetElem);
  1247. return wrapperElem;
  1248. }
  1249. function getHotkeyInfo(hotkey) {
  1250. const modifiers = [];
  1251. hotkey.ctrl && modifiers.push(t("hotkey_key_ctrl"));
  1252. hotkey.shift && modifiers.push(t("hotkey_key_shift"));
  1253. hotkey.alt && modifiers.push(getOS() === "mac" ? t("hotkey_key_mac_option") : t("hotkey_key_alt"));
  1254. return modifiers.reduce((a, c) => a += `${c} + `, "");
  1255. }
  1256. /** Crude OS detection for keyboard layout purposes */
  1257. function getOS() {
  1258. if (navigator.userAgent.match(/mac(\s?os|intel)/i))
  1259. return "mac";
  1260. return "other";
  1261. }
  1262. var name = "betterytm";
  1263. var userscriptName = "BetterYTM";
  1264. var version = "1.1.1";
  1265. var description = "Configurable layout and user experience improvements for YouTube Music";
  1266. var homepage = "https://github.com/Sv443/BetterYTM";
  1267. var main = "./src/index.ts";
  1268. var type = "module";
  1269. var scripts = {
  1270. dev: "concurrently \"nodemon --exec npm run build-watch\" \"npm run serve\"",
  1271. serve: "npm run node-ts -- ./src/tools/serve.ts",
  1272. lint: "tsc --noEmit && eslint .",
  1273. build: "rollup -c",
  1274. "build-watch": "rollup -c --config-mode development --config-host github --config-branch develop",
  1275. "build-develop": "rollup -c --config-mode production --config-host github --config-branch develop",
  1276. "build-prod": "npm run build-prod-gh && npm run build-prod-gf && npm run build-prod-oujs",
  1277. "build-prod-base": "rollup -c --config-mode production --config-branch main",
  1278. "build-prod-gh": "npm run build-prod-base -- --config-host github",
  1279. "build-prod-gf": "npm run build-prod-base -- --config-host greasyfork --config-suffix _gf",
  1280. "build-prod-oujs": "npm run build-prod-base -- --config-host openuserjs --config-suffix _oujs",
  1281. "post-build": "npm run node-ts -- ./src/tools/post-build.ts",
  1282. "tr-progress": "npm run node-ts -- ./src/tools/tr-progress.ts",
  1283. "tr-format": "npm run node-ts -- ./src/tools/tr-format.ts",
  1284. "node-ts": "node --no-warnings=ExperimentalWarning --enable-source-maps --loader ts-node/esm",
  1285. invisible: "node src/tools/run-invisible.mjs",
  1286. test: "npm run node-ts -- ./test.ts"
  1287. };
  1288. var engines = {
  1289. node: ">=18",
  1290. npm: ">=8"
  1291. };
  1292. var repository = {
  1293. type: "git",
  1294. url: "git+https://github.com/Sv443/BetterYTM.git"
  1295. };
  1296. var author = {
  1297. name: "Sv443",
  1298. url: "https://github.com/Sv443"
  1299. };
  1300. var license = "AGPL-3.0-only";
  1301. var bugs = {
  1302. url: "https://github.com/Sv443/BetterYTM/issues"
  1303. };
  1304. var funding = {
  1305. type: "github",
  1306. url: "https://github.com/sponsors/Sv443"
  1307. };
  1308. var hosts = {
  1309. github: "https://github.com/Sv443/BetterYTM",
  1310. greasyfork: "https://greasyfork.org/en/scripts/475682-betterytm",
  1311. openuserjs: "https://openuserjs.org/scripts/Sv443/BetterYTM"
  1312. };
  1313. var updates = {
  1314. github: "https://github.com/Sv443/BetterYTM/releases",
  1315. greasyfork: "https://greasyfork.org/en/scripts/475682-betterytm",
  1316. openuserjs: "https://openuserjs.org/scripts/Sv443/BetterYTM"
  1317. };
  1318. var dependencies = {
  1319. "@sv443-network/userutils": "^4.1.0",
  1320. nanoevents: "^9.0.0"
  1321. };
  1322. var devDependencies = {
  1323. "@jackfranklin/rollup-plugin-markdown": "^0.4.0",
  1324. "@rollup/plugin-json": "^6.0.1",
  1325. "@rollup/plugin-node-resolve": "^15.2.3",
  1326. "@rollup/plugin-terser": "^0.4.4",
  1327. "@rollup/plugin-typescript": "^11.1.5",
  1328. "@types/express": "^4.17.17",
  1329. "@types/greasemonkey": "^4.0.4",
  1330. "@types/node": "^20.2.4",
  1331. "@typescript-eslint/eslint-plugin": "^6.7.4",
  1332. "@typescript-eslint/parser": "^6.7.4",
  1333. concurrently: "^8.1.0",
  1334. dotenv: "^16.4.1",
  1335. eslint: "^8.51.0",
  1336. express: "^4.18.2",
  1337. nodemon: "^3.0.1",
  1338. rollup: "^4.6.0",
  1339. "rollup-plugin-execute": "^1.1.1",
  1340. "rollup-plugin-html": "^0.2.1",
  1341. "rollup-plugin-import-css": "^3.3.5",
  1342. "ts-node": "^10.9.1",
  1343. tslib: "^2.5.2",
  1344. typescript: "^5.0.4"
  1345. };
  1346. var browserslist = [
  1347. "last 1 version",
  1348. "> 1%",
  1349. "not dead"
  1350. ];
  1351. var nodemonConfig = {
  1352. watch: [
  1353. "src/**",
  1354. "assets/**",
  1355. "rollup.config.mjs",
  1356. ".env",
  1357. "changelog.md",
  1358. "package.json"
  1359. ],
  1360. ext: "ts,mts,js,jsx,mjs,json,html,css,svg,png",
  1361. ignore: [
  1362. "dist/*",
  1363. "dev/*"
  1364. ]
  1365. };
  1366. var pkg = {
  1367. name: name,
  1368. userscriptName: userscriptName,
  1369. version: version,
  1370. description: description,
  1371. homepage: homepage,
  1372. main: main,
  1373. type: type,
  1374. scripts: scripts,
  1375. engines: engines,
  1376. repository: repository,
  1377. author: author,
  1378. license: license,
  1379. bugs: bugs,
  1380. funding: funding,
  1381. hosts: hosts,
  1382. updates: updates,
  1383. dependencies: dependencies,
  1384. devDependencies: devDependencies,
  1385. browserslist: browserslist,
  1386. nodemonConfig: nodemonConfig
  1387. };
  1388. //#MARKER create menu elements
  1389. let isCfgMenuAdded = false;
  1390. let isCfgMenuOpen = false;
  1391. const compressionFormat = "deflate-raw";
  1392. function compressionSupported() {
  1393. return __awaiter(this, void 0, void 0, function* () {
  1394. try {
  1395. yield compress(".", compressionFormat);
  1396. return true;
  1397. }
  1398. catch (e) {
  1399. return false;
  1400. }
  1401. });
  1402. }
  1403. /** Threshold in pixels from the top of the options container that dictates for how long the scroll indicator is shown */
  1404. const scrollIndicatorOffsetThreshold = 30;
  1405. let scrollIndicatorEnabled = true;
  1406. /** Locale at the point of initializing the config menu */
  1407. let initLocale;
  1408. /** Stringified config at the point of initializing the config menu */
  1409. let initConfig$1;
  1410. /**
  1411. * Adds an element to open the BetterYTM menu
  1412. * @deprecated to be replaced with new menu - see https://github.com/Sv443/BetterYTM/issues/23
  1413. */
  1414. function addCfgMenu() {
  1415. var _a, _b, _c, _d, _e;
  1416. return __awaiter(this, void 0, void 0, function* () {
  1417. if (isCfgMenuAdded)
  1418. return;
  1419. isCfgMenuAdded = true;
  1420. initLocale = getFeatures().locale;
  1421. initConfig$1 = JSON.stringify(getFeatures());
  1422. const initLangReloadText = t("lang_changed_prompt_reload");
  1423. const toggled_on = t("toggled_on");
  1424. const toggled_off = t("toggled_off");
  1425. //#SECTION backdrop & menu container
  1426. const backgroundElem = document.createElement("div");
  1427. backgroundElem.id = "bytm-cfg-menu-bg";
  1428. backgroundElem.classList.add("bytm-menu-bg");
  1429. backgroundElem.ariaLabel = backgroundElem.title = t("close_menu_tooltip");
  1430. backgroundElem.style.visibility = "hidden";
  1431. backgroundElem.style.display = "none";
  1432. backgroundElem.addEventListener("click", (e) => {
  1433. var _a;
  1434. if (isCfgMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-cfg-menu-bg")
  1435. closeCfgMenu(e);
  1436. });
  1437. document.body.addEventListener("keydown", (e) => {
  1438. if (isCfgMenuOpen && e.key === "Escape")
  1439. closeCfgMenu(e);
  1440. });
  1441. const menuContainer = document.createElement("div");
  1442. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  1443. menuContainer.classList.add("bytm-menu");
  1444. menuContainer.id = "bytm-cfg-menu";
  1445. //#SECTION title bar
  1446. const headerElem = document.createElement("div");
  1447. headerElem.classList.add("bytm-menu-header");
  1448. const titleCont = document.createElement("div");
  1449. titleCont.className = "bytm-menu-titlecont";
  1450. titleCont.role = "heading";
  1451. titleCont.ariaLevel = "1";
  1452. const titleElem = document.createElement("h2");
  1453. titleElem.className = "bytm-menu-title";
  1454. const titleTextElem = document.createElement("div");
  1455. titleTextElem.textContent = t("config_menu_title", scriptInfo.name);
  1456. titleElem.appendChild(titleTextElem);
  1457. const linksCont = document.createElement("div");
  1458. linksCont.id = "bytm-menu-linkscont";
  1459. linksCont.role = "navigation";
  1460. const addLink = (imgSrc, href, title) => {
  1461. const anchorElem = document.createElement("a");
  1462. anchorElem.className = "bytm-menu-link bytm-no-select";
  1463. anchorElem.rel = "noopener noreferrer";
  1464. anchorElem.href = href;
  1465. anchorElem.target = "_blank";
  1466. anchorElem.tabIndex = 0;
  1467. anchorElem.role = "button";
  1468. anchorElem.ariaLabel = anchorElem.title = title;
  1469. const imgElem = document.createElement("img");
  1470. imgElem.className = "bytm-menu-img";
  1471. imgElem.src = imgSrc;
  1472. imgElem.style.width = "32px";
  1473. imgElem.style.height = "32px";
  1474. anchorElem.appendChild(imgElem);
  1475. linksCont.appendChild(anchorElem);
  1476. };
  1477. addLink(yield getResourceUrl("img-discord"), "https://dc.sv443.net/", t("open_discord"));
  1478. const links = [
  1479. ["github", yield getResourceUrl("img-github"), scriptInfo.namespace, t("open_github", scriptInfo.name)],
  1480. ["greasyfork", yield getResourceUrl("img-greasyfork"), pkg.hosts.greasyfork, t("open_greasyfork", scriptInfo.name)],
  1481. ["openuserjs", yield getResourceUrl("img-openuserjs"), pkg.hosts.openuserjs, t("open_openuserjs", scriptInfo.name)],
  1482. ];
  1483. const hostLink = links.find(([name]) => name === host);
  1484. const otherLinks = links.filter(([name]) => name !== host);
  1485. const reorderedLinks = hostLink ? [hostLink, ...otherLinks] : links;
  1486. for (const [, ...args] of reorderedLinks)
  1487. addLink(...args);
  1488. const closeElem = document.createElement("img");
  1489. closeElem.classList.add("bytm-menu-close");
  1490. closeElem.role = "button";
  1491. closeElem.tabIndex = 0;
  1492. closeElem.src = yield getResourceUrl("img-close");
  1493. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  1494. closeElem.addEventListener("click", closeCfgMenu);
  1495. closeElem.addEventListener("keydown", ({ key }) => key === "Enter" && closeCfgMenu());
  1496. titleCont.appendChild(titleElem);
  1497. titleCont.appendChild(linksCont);
  1498. headerElem.appendChild(titleCont);
  1499. headerElem.appendChild(closeElem);
  1500. //#SECTION footer
  1501. const footerCont = document.createElement("div");
  1502. footerCont.className = "bytm-menu-footer-cont";
  1503. const footerElemCont = document.createElement("div");
  1504. const footerElem = document.createElement("div");
  1505. footerElem.classList.add("bytm-menu-footer", "hidden");
  1506. footerElem.textContent = t("reload_hint");
  1507. const reloadElem = document.createElement("button");
  1508. reloadElem.classList.add("bytm-btn");
  1509. reloadElem.style.marginLeft = "10px";
  1510. reloadElem.textContent = t("reload_now");
  1511. reloadElem.ariaLabel = reloadElem.title = t("reload_tooltip");
  1512. reloadElem.addEventListener("click", () => {
  1513. closeCfgMenu();
  1514. disableBeforeUnload();
  1515. location.reload();
  1516. });
  1517. footerElem.appendChild(reloadElem);
  1518. footerElemCont.appendChild(footerElem);
  1519. const resetElem = document.createElement("button");
  1520. resetElem.classList.add("bytm-btn");
  1521. resetElem.ariaLabel = resetElem.title = t("reset_tooltip");
  1522. resetElem.textContent = t("reset");
  1523. resetElem.addEventListener("click", () => __awaiter(this, void 0, void 0, function* () {
  1524. if (confirm(t("reset_confirm"))) {
  1525. yield setDefaultFeatures();
  1526. closeCfgMenu();
  1527. disableBeforeUnload();
  1528. location.reload();
  1529. }
  1530. }));
  1531. const exportElem = document.createElement("button");
  1532. exportElem.classList.add("bytm-btn");
  1533. exportElem.ariaLabel = exportElem.title = t("export_tooltip");
  1534. exportElem.textContent = t("export");
  1535. exportElem.addEventListener("click", () => __awaiter(this, void 0, void 0, function* () {
  1536. closeCfgMenu();
  1537. openExportMenu();
  1538. }));
  1539. const importElem = document.createElement("button");
  1540. importElem.classList.add("bytm-btn");
  1541. importElem.ariaLabel = importElem.title = t("import_tooltip");
  1542. importElem.textContent = t("import");
  1543. importElem.addEventListener("click", () => __awaiter(this, void 0, void 0, function* () {
  1544. closeCfgMenu();
  1545. openImportMenu();
  1546. }));
  1547. const buttonsCont = document.createElement("div");
  1548. buttonsCont.id = "bytm-menu-footer-buttons-cont";
  1549. buttonsCont.appendChild(exportElem);
  1550. buttonsCont.appendChild(importElem);
  1551. buttonsCont.appendChild(resetElem);
  1552. footerCont.appendChild(footerElemCont);
  1553. footerCont.appendChild(buttonsCont);
  1554. //#SECTION feature list
  1555. const featuresCont = document.createElement("div");
  1556. featuresCont.id = "bytm-menu-opts";
  1557. /** Gets called whenever the feature config is changed */
  1558. const confChanged = debounce((key, initialVal, newVal) => __awaiter(this, void 0, void 0, function* () {
  1559. const fmt = (val) => typeof val === "object" ? JSON.stringify(val) : String(val);
  1560. info(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`);
  1561. const featConf = JSON.parse(JSON.stringify(getFeatures()));
  1562. featConf[key] = newVal;
  1563. yield saveFeatures(featConf);
  1564. if (initConfig$1 !== JSON.stringify(featConf))
  1565. footerElem.classList.remove("hidden");
  1566. else
  1567. footerElem.classList.add("hidden");
  1568. if (initLocale !== featConf.locale) {
  1569. yield initTranslations(featConf.locale);
  1570. setLocale(featConf.locale);
  1571. const newText = t("lang_changed_prompt_reload");
  1572. const confirmText = newText !== initLangReloadText ? `${newText}\n\n────────────────────────────────\n\n${initLangReloadText}` : newText;
  1573. if (confirm(confirmText)) {
  1574. closeCfgMenu();
  1575. disableBeforeUnload();
  1576. location.reload();
  1577. }
  1578. }
  1579. else if (getLocale() !== featConf.locale)
  1580. setLocale(featConf.locale);
  1581. }));
  1582. const featureCfg = getFeatures();
  1583. const featureCfgWithCategories = Object.entries(featInfo)
  1584. .reduce((acc, [key, { category }]) => {
  1585. if (!acc[category])
  1586. acc[category] = {};
  1587. acc[category][key] = featureCfg[key];
  1588. return acc;
  1589. }, {});
  1590. const fmtVal = (v) => String(v).trim();
  1591. const toggleLabelText = (toggled) => toggled ? toggled_on : toggled_off;
  1592. for (const category in featureCfgWithCategories) {
  1593. const featObj = featureCfgWithCategories[category];
  1594. const catHeaderElem = document.createElement("h3");
  1595. catHeaderElem.classList.add("bytm-ftconf-category-header");
  1596. catHeaderElem.role = "heading";
  1597. catHeaderElem.ariaLevel = "2";
  1598. catHeaderElem.textContent = `${t(`feature_category_${category}`)}:`;
  1599. featuresCont.appendChild(catHeaderElem);
  1600. for (const featKey in featObj) {
  1601. const ftInfo = featInfo[featKey];
  1602. // @ts-ignore
  1603. if (!ftInfo || ftInfo.hidden === true)
  1604. continue;
  1605. const { type, default: ftDefault } = ftInfo;
  1606. // @ts-ignore
  1607. const step = (_a = ftInfo === null || ftInfo === void 0 ? void 0 : ftInfo.step) !== null && _a !== void 0 ? _a : undefined;
  1608. const val = featureCfg[featKey];
  1609. const initialVal = (_b = val !== null && val !== void 0 ? val : ftDefault) !== null && _b !== void 0 ? _b : undefined;
  1610. const ftConfElem = document.createElement("div");
  1611. ftConfElem.classList.add("bytm-ftitem");
  1612. {
  1613. const featLeftSideElem = document.createElement("div");
  1614. featLeftSideElem.classList.add("bytm-ftitem-leftside");
  1615. const textElem = document.createElement("span");
  1616. textElem.textContent = t(`feature_desc_${featKey}`);
  1617. let adornmentElem;
  1618. const adornContent = (_c = ftInfo.textAdornment) === null || _c === void 0 ? void 0 : _c.call(ftInfo);
  1619. if (typeof adornContent === "string" || adornContent instanceof Promise) {
  1620. adornmentElem = document.createElement("span");
  1621. adornmentElem.id = `bytm-ftitem-${featKey}-adornment`;
  1622. adornmentElem.classList.add("bytm-ftitem-adornment");
  1623. adornmentElem.innerHTML = adornContent instanceof Promise ? yield adornContent : adornContent;
  1624. }
  1625. let helpElem;
  1626. // @ts-ignore
  1627. const hasHelpTextFunc = typeof ((_d = featInfo[featKey]) === null || _d === void 0 ? void 0 : _d.helpText) === "function";
  1628. // @ts-ignore
  1629. const helpTextVal = hasHelpTextFunc && featInfo[featKey].helpText();
  1630. if (hasKey(`feature_helptext_${featKey}`) || (helpTextVal && hasKey(helpTextVal))) {
  1631. const helpElemImgHtml = yield resourceToHTMLString("img-help");
  1632. if (helpElemImgHtml) {
  1633. helpElem = document.createElement("div");
  1634. helpElem.classList.add("bytm-ftitem-help-btn", "bytm-generic-btn");
  1635. helpElem.ariaLabel = helpElem.title = t("feature_help_button_tooltip");
  1636. helpElem.role = "button";
  1637. helpElem.tabIndex = 0;
  1638. helpElem.innerHTML = helpElemImgHtml;
  1639. const helpElemClicked = (e) => {
  1640. e.preventDefault();
  1641. e.stopPropagation();
  1642. openHelpDialog(featKey);
  1643. };
  1644. helpElem.addEventListener("click", helpElemClicked);
  1645. helpElem.addEventListener("keydown", (e) => e.key === "Enter" && helpElemClicked(e));
  1646. }
  1647. else {
  1648. error(`Couldn't create help button SVG element for feature '${featKey}'`);
  1649. }
  1650. }
  1651. featLeftSideElem.appendChild(textElem);
  1652. adornmentElem && featLeftSideElem.appendChild(adornmentElem);
  1653. helpElem && featLeftSideElem.appendChild(helpElem);
  1654. ftConfElem.appendChild(featLeftSideElem);
  1655. }
  1656. {
  1657. let inputType = "text";
  1658. let inputTag = "input";
  1659. switch (type) {
  1660. case "toggle":
  1661. inputType = "checkbox";
  1662. break;
  1663. case "slider":
  1664. inputType = "range";
  1665. break;
  1666. case "number":
  1667. inputType = "number";
  1668. break;
  1669. case "select":
  1670. inputTag = "select";
  1671. inputType = undefined;
  1672. break;
  1673. case "hotkey":
  1674. inputTag = undefined;
  1675. inputType = undefined;
  1676. break;
  1677. }
  1678. const inputElemId = `bytm-ftconf-${featKey}-input`;
  1679. const ctrlElem = document.createElement("span");
  1680. ctrlElem.classList.add("bytm-ftconf-ctrl");
  1681. if (inputTag) {
  1682. // standard input element:
  1683. const inputElem = document.createElement(inputTag);
  1684. inputElem.classList.add("bytm-ftconf-input");
  1685. inputElem.id = inputElemId;
  1686. if (inputType)
  1687. inputElem.type = inputType;
  1688. // @ts-ignore
  1689. if (typeof ftInfo.min !== "undefined" && ftInfo.max !== "undefined") {
  1690. // @ts-ignore
  1691. inputElem.min = ftInfo.min;
  1692. // @ts-ignore
  1693. inputElem.max = ftInfo.max;
  1694. }
  1695. if (typeof initialVal !== "undefined")
  1696. inputElem.value = String(initialVal);
  1697. if (type === "number" || type === "slider" && step)
  1698. inputElem.step = String(step);
  1699. if (type === "toggle" && typeof initialVal !== "undefined")
  1700. inputElem.checked = Boolean(initialVal);
  1701. // @ts-ignore
  1702. const unitTxt = typeof ftInfo.unit === "string" ? " " + ftInfo.unit : "";
  1703. let labelElem;
  1704. if (type === "slider") {
  1705. labelElem = document.createElement("label");
  1706. labelElem.classList.add("bytm-ftconf-label", "bytm-slider-label");
  1707. labelElem.textContent = fmtVal(initialVal) + unitTxt;
  1708. inputElem.addEventListener("input", () => {
  1709. if (labelElem)
  1710. labelElem.textContent = fmtVal(Number(inputElem.value)) + unitTxt;
  1711. });
  1712. }
  1713. else if (type === "toggle") {
  1714. labelElem = document.createElement("label");
  1715. labelElem.classList.add("bytm-ftconf-label", "bytm-toggle-label");
  1716. labelElem.textContent = toggleLabelText(Boolean(initialVal)) + unitTxt;
  1717. inputElem.addEventListener("input", () => {
  1718. if (labelElem)
  1719. labelElem.textContent = toggleLabelText(inputElem.checked) + unitTxt;
  1720. });
  1721. }
  1722. else if (type === "select") {
  1723. const ftOpts = typeof ftInfo.options === "function"
  1724. ? ftInfo.options()
  1725. : ftInfo.options;
  1726. for (const { value, label } of ftOpts) {
  1727. const optionElem = document.createElement("option");
  1728. optionElem.value = String(value);
  1729. optionElem.textContent = label;
  1730. if (value === initialVal)
  1731. optionElem.selected = true;
  1732. inputElem.appendChild(optionElem);
  1733. }
  1734. }
  1735. inputElem.addEventListener("input", () => {
  1736. let v = String(inputElem.value).trim();
  1737. if (["number", "slider"].includes(type) || v.match(/^-?\d+$/))
  1738. v = Number(v);
  1739. if (typeof initialVal !== "undefined")
  1740. confChanged(featKey, initialVal, (type !== "toggle" ? v : inputElem.checked));
  1741. });
  1742. if (labelElem) {
  1743. labelElem.id = `bytm-ftconf-${featKey}-label`;
  1744. labelElem.htmlFor = inputElemId;
  1745. ctrlElem.appendChild(labelElem);
  1746. }
  1747. ctrlElem.appendChild(inputElem);
  1748. }
  1749. else {
  1750. // custom input element:
  1751. let wrapperElem;
  1752. switch (type) {
  1753. case "hotkey":
  1754. wrapperElem = createHotkeyInput({
  1755. initialValue: initialVal,
  1756. resetValue: featInfo.switchSitesHotkey.default,
  1757. onChange: (hotkey) => {
  1758. confChanged(featKey, initialVal, hotkey);
  1759. },
  1760. });
  1761. break;
  1762. }
  1763. ctrlElem.appendChild(wrapperElem);
  1764. }
  1765. ftConfElem.appendChild(ctrlElem);
  1766. }
  1767. featuresCont.appendChild(ftConfElem);
  1768. }
  1769. }
  1770. //#SECTION set values of inputs on external change
  1771. siteEvents.on("rebuildCfgMenu", (newConfig) => {
  1772. for (const ftKey in featInfo) {
  1773. const ftElem = document.querySelector(`#bytm-ftconf-${ftKey}-input`);
  1774. const labelElem = document.querySelector(`#bytm-ftconf-${ftKey}-label`);
  1775. if (!ftElem)
  1776. continue;
  1777. const ftInfo = featInfo[ftKey];
  1778. const value = newConfig[ftKey];
  1779. if (ftInfo.type === "toggle")
  1780. ftElem.checked = Boolean(value);
  1781. else
  1782. ftElem.value = String(value);
  1783. if (!labelElem)
  1784. continue;
  1785. // @ts-ignore
  1786. const unitTxt = typeof ftInfo.unit === "string" ? " " + ftInfo.unit : "";
  1787. if (ftInfo.type === "slider")
  1788. labelElem.textContent = fmtVal(Number(value)) + unitTxt;
  1789. else if (ftInfo.type === "toggle")
  1790. labelElem.textContent = toggleLabelText(Boolean(value)) + unitTxt;
  1791. }
  1792. info("Rebuilt config menu");
  1793. });
  1794. //#SECTION scroll indicator
  1795. const scrollIndicator = document.createElement("img");
  1796. scrollIndicator.id = "bytm-menu-scroll-indicator";
  1797. scrollIndicator.src = yield getResourceUrl("img-arrow_down");
  1798. scrollIndicator.role = "button";
  1799. scrollIndicator.ariaLabel = scrollIndicator.title = t("scroll_to_bottom");
  1800. featuresCont.appendChild(scrollIndicator);
  1801. scrollIndicator.addEventListener("click", () => {
  1802. const bottomAnchor = document.querySelector("#bytm-menu-bottom-anchor");
  1803. bottomAnchor === null || bottomAnchor === void 0 ? void 0 : bottomAnchor.scrollIntoView({
  1804. behavior: "smooth",
  1805. });
  1806. });
  1807. featuresCont.addEventListener("scroll", (evt) => {
  1808. var _a, _b;
  1809. const scrollPos = (_b = (_a = evt.target) === null || _a === void 0 ? void 0 : _a.scrollTop) !== null && _b !== void 0 ? _b : 0;
  1810. const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator");
  1811. if (!scrollIndicator)
  1812. return;
  1813. if (scrollIndicatorEnabled && scrollPos > scrollIndicatorOffsetThreshold && !scrollIndicator.classList.contains("bytm-hidden")) {
  1814. scrollIndicator.classList.add("bytm-hidden");
  1815. }
  1816. else if (scrollIndicatorEnabled && scrollPos <= scrollIndicatorOffsetThreshold && scrollIndicator.classList.contains("bytm-hidden")) {
  1817. scrollIndicator.classList.remove("bytm-hidden");
  1818. }
  1819. });
  1820. const bottomAnchor = document.createElement("div");
  1821. bottomAnchor.id = "bytm-menu-bottom-anchor";
  1822. featuresCont.appendChild(bottomAnchor);
  1823. //#SECTION finalize
  1824. menuContainer.appendChild(headerElem);
  1825. menuContainer.appendChild(featuresCont);
  1826. const versionElemCont = document.createElement("div");
  1827. versionElemCont.id = "bytm-menu-version";
  1828. const versionElem = document.createElement("a");
  1829. versionElem.classList.add("bytm-link");
  1830. versionElem.role = "button";
  1831. versionElem.tabIndex = 0;
  1832. versionElem.ariaLabel = versionElem.title = t("version_tooltip", scriptInfo.version, scriptInfo.buildNumber);
  1833. versionElem.textContent = `v${scriptInfo.version} (${scriptInfo.buildNumber})`;
  1834. const versionElemClicked = (e) => {
  1835. e.preventDefault();
  1836. e.stopPropagation();
  1837. closeCfgMenu();
  1838. openChangelogMenu("cfgMenu");
  1839. };
  1840. versionElem.addEventListener("click", versionElemClicked);
  1841. versionElem.addEventListener("keydown", (e) => e.key === "Enter" && versionElemClicked(e));
  1842. menuContainer.appendChild(footerCont);
  1843. versionElemCont.appendChild(versionElem);
  1844. titleElem.appendChild(versionElemCont);
  1845. backgroundElem.appendChild(menuContainer);
  1846. document.body.appendChild(backgroundElem);
  1847. window.addEventListener("resize", debounce(checkToggleScrollIndicator, 150));
  1848. yield addChangelogMenu();
  1849. yield addExportMenu();
  1850. yield addImportMenu();
  1851. log("Added menu element");
  1852. // ensure stuff is reset if menu was opened before being added
  1853. isCfgMenuOpen = false;
  1854. document.body.classList.remove("bytm-disable-scroll");
  1855. (_e = document.querySelector("ytmusic-app")) === null || _e === void 0 ? void 0 : _e.removeAttribute("inert");
  1856. backgroundElem.style.visibility = "hidden";
  1857. backgroundElem.style.display = "none";
  1858. });
  1859. }
  1860. /** Closes the config menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  1861. function closeCfgMenu(evt) {
  1862. var _a;
  1863. if (!isCfgMenuOpen)
  1864. return;
  1865. isCfgMenuOpen = false;
  1866. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  1867. document.body.classList.remove("bytm-disable-scroll");
  1868. (_a = document.querySelector("ytmusic-app")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert");
  1869. const menuBg = document.querySelector("#bytm-cfg-menu-bg");
  1870. siteEvents.emit("cfgMenuClosed");
  1871. if (!menuBg)
  1872. return;
  1873. menuBg.style.visibility = "hidden";
  1874. menuBg.style.display = "none";
  1875. }
  1876. /** Opens the config menu if it is closed */
  1877. function openCfgMenu() {
  1878. var _a;
  1879. return __awaiter(this, void 0, void 0, function* () {
  1880. if (!isCfgMenuAdded)
  1881. yield addCfgMenu();
  1882. if (isCfgMenuOpen)
  1883. return;
  1884. isCfgMenuOpen = true;
  1885. document.body.classList.add("bytm-disable-scroll");
  1886. (_a = document.querySelector("ytmusic-app")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true");
  1887. const menuBg = document.querySelector("#bytm-cfg-menu-bg");
  1888. if (!menuBg)
  1889. return;
  1890. menuBg.style.visibility = "visible";
  1891. menuBg.style.display = "block";
  1892. checkToggleScrollIndicator();
  1893. });
  1894. }
  1895. /** Checks if the features container is scrollable and toggles the scroll indicator accordingly */
  1896. function checkToggleScrollIndicator() {
  1897. const featuresCont = document.querySelector("#bytm-menu-opts");
  1898. const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator");
  1899. // disable scroll indicator if container doesn't scroll
  1900. if (featuresCont && scrollIndicator) {
  1901. const verticalScroll = isScrollable(featuresCont).vertical;
  1902. /** If true, the indicator's threshold is under the available scrollable space and so it should be disabled */
  1903. const underThreshold = featuresCont.scrollHeight - featuresCont.clientHeight <= scrollIndicatorOffsetThreshold;
  1904. if (!underThreshold && verticalScroll && !scrollIndicatorEnabled) {
  1905. scrollIndicatorEnabled = true;
  1906. scrollIndicator.classList.remove("bytm-hidden");
  1907. }
  1908. if ((!verticalScroll && scrollIndicatorEnabled) || underThreshold) {
  1909. scrollIndicatorEnabled = false;
  1910. scrollIndicator.classList.add("bytm-hidden");
  1911. }
  1912. }
  1913. }
  1914. //#MARKER help dialog
  1915. let isHelpDialogOpen = false;
  1916. /** Key of the feature currently loaded in the help dialog */
  1917. let helpDialogCurFeature;
  1918. /** Opens the feature help dialog for the given feature */
  1919. function openHelpDialog(featureKey) {
  1920. var _a, _b, _c;
  1921. return __awaiter(this, void 0, void 0, function* () {
  1922. if (isHelpDialogOpen)
  1923. return;
  1924. isHelpDialogOpen = true;
  1925. let menuBgElem;
  1926. if (!helpDialogCurFeature) {
  1927. // create menu
  1928. const headerElem = document.createElement("div");
  1929. headerElem.classList.add("bytm-menu-header", "small");
  1930. const titleCont = document.createElement("div");
  1931. titleCont.className = "bytm-menu-titlecont-no-title";
  1932. titleCont.role = "heading";
  1933. titleCont.ariaLevel = "1";
  1934. const helpIconSvg = yield resourceToHTMLString("img-help");
  1935. if (helpIconSvg)
  1936. titleCont.innerHTML = helpIconSvg;
  1937. const closeElem = document.createElement("img");
  1938. closeElem.classList.add("bytm-menu-close", "small");
  1939. closeElem.role = "button";
  1940. closeElem.tabIndex = 0;
  1941. closeElem.src = yield getResourceUrl("img-close");
  1942. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  1943. closeElem.addEventListener("click", (e) => closeHelpDialog(e));
  1944. closeElem.addEventListener("keydown", (e) => e.key === "Enter" && closeHelpDialog(e));
  1945. headerElem.appendChild(titleCont);
  1946. headerElem.appendChild(closeElem);
  1947. menuBgElem = document.createElement("div");
  1948. menuBgElem.id = "bytm-feat-help-menu-bg";
  1949. menuBgElem.classList.add("bytm-menu-bg");
  1950. menuBgElem.ariaLabel = menuBgElem.title = t("close_menu_tooltip");
  1951. menuBgElem.style.visibility = "hidden";
  1952. menuBgElem.style.display = "none";
  1953. menuBgElem.addEventListener("click", (e) => {
  1954. var _a;
  1955. if (isHelpDialogOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-feat-help-menu-bg")
  1956. closeHelpDialog(e);
  1957. });
  1958. document.body.addEventListener("keydown", (e) => {
  1959. if (isHelpDialogOpen && e.key === "Escape")
  1960. closeHelpDialog(e);
  1961. });
  1962. const menuContainer = document.createElement("div");
  1963. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  1964. menuContainer.classList.add("bytm-menu");
  1965. menuContainer.id = "bytm-feat-help-menu";
  1966. const featDescElem = document.createElement("h3");
  1967. featDescElem.id = "bytm-feat-help-menu-desc";
  1968. const helpTextElem = document.createElement("div");
  1969. helpTextElem.id = "bytm-feat-help-menu-text";
  1970. menuContainer.appendChild(headerElem);
  1971. menuContainer.appendChild(featDescElem);
  1972. menuContainer.appendChild(helpTextElem);
  1973. menuBgElem.appendChild(menuContainer);
  1974. document.body.appendChild(menuBgElem);
  1975. }
  1976. else
  1977. menuBgElem = document.querySelector("#bytm-feat-help-menu-bg");
  1978. if (helpDialogCurFeature !== featureKey) {
  1979. // update help text
  1980. const featDescElem = menuBgElem.querySelector("#bytm-feat-help-menu-desc");
  1981. const helpTextElem = menuBgElem.querySelector("#bytm-feat-help-menu-text");
  1982. featDescElem.textContent = t(`feature_desc_${featureKey}`);
  1983. // @ts-ignore
  1984. const helpText = (_b = (_a = featInfo[featureKey]) === null || _a === void 0 ? void 0 : _a.helpText) === null || _b === void 0 ? void 0 : _b.call(_a);
  1985. helpTextElem.textContent = helpText !== null && helpText !== void 0 ? helpText : t(`feature_helptext_${featureKey}`);
  1986. }
  1987. // show menu
  1988. const menuBg = document.querySelector("#bytm-feat-help-menu-bg");
  1989. if (!menuBg)
  1990. return warn("Couldn't find feature help dialog background element");
  1991. helpDialogCurFeature = featureKey;
  1992. menuBg.style.visibility = "visible";
  1993. menuBg.style.display = "block";
  1994. (_c = document.querySelector("#bytm-cfg-menu")) === null || _c === void 0 ? void 0 : _c.setAttribute("inert", "true");
  1995. });
  1996. }
  1997. function closeHelpDialog(evt) {
  1998. var _a;
  1999. if (!isHelpDialogOpen)
  2000. return;
  2001. isHelpDialogOpen = false;
  2002. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  2003. const menuBg = document.querySelector("#bytm-feat-help-menu-bg");
  2004. if (!menuBg)
  2005. return warn("Couldn't find feature help dialog background element");
  2006. menuBg.style.visibility = "hidden";
  2007. menuBg.style.display = "none";
  2008. (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert");
  2009. }
  2010. //#MARKER export menu
  2011. let isExportMenuOpen = false;
  2012. let copiedTxtTimeout = undefined;
  2013. /** Adds a menu to copy the current configuration as compressed (if supported) or uncompressed JSON (hidden by default) */
  2014. function addExportMenu() {
  2015. return __awaiter(this, void 0, void 0, function* () {
  2016. const canCompress = yield compressionSupported();
  2017. const menuBgElem = document.createElement("div");
  2018. menuBgElem.id = "bytm-export-menu-bg";
  2019. menuBgElem.classList.add("bytm-menu-bg");
  2020. menuBgElem.ariaLabel = menuBgElem.title = t("close_menu_tooltip");
  2021. menuBgElem.style.visibility = "hidden";
  2022. menuBgElem.style.display = "none";
  2023. menuBgElem.addEventListener("click", (e) => {
  2024. var _a;
  2025. if (isExportMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-export-menu-bg") {
  2026. closeExportMenu(e);
  2027. openCfgMenu();
  2028. }
  2029. });
  2030. document.body.addEventListener("keydown", (e) => {
  2031. if (isExportMenuOpen && e.key === "Escape") {
  2032. closeExportMenu(e);
  2033. openCfgMenu();
  2034. }
  2035. });
  2036. const menuContainer = document.createElement("div");
  2037. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  2038. menuContainer.classList.add("bytm-menu");
  2039. menuContainer.id = "bytm-export-menu";
  2040. //#SECTION title bar
  2041. const headerElem = document.createElement("div");
  2042. headerElem.classList.add("bytm-menu-header");
  2043. const titleCont = document.createElement("div");
  2044. titleCont.className = "bytm-menu-titlecont";
  2045. titleCont.role = "heading";
  2046. titleCont.ariaLevel = "1";
  2047. const titleElem = document.createElement("h2");
  2048. titleElem.className = "bytm-menu-title";
  2049. titleElem.textContent = t("export_menu_title", scriptInfo.name);
  2050. const closeElem = document.createElement("img");
  2051. closeElem.classList.add("bytm-menu-close");
  2052. closeElem.role = "button";
  2053. closeElem.tabIndex = 0;
  2054. closeElem.src = yield getResourceUrl("img-close");
  2055. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  2056. const closeExportMenuClicked = (e) => {
  2057. closeExportMenu(e);
  2058. openCfgMenu();
  2059. };
  2060. closeElem.addEventListener("click", (e) => closeExportMenuClicked(e));
  2061. closeElem.addEventListener("keydown", (e) => e.key === "Enter" && closeExportMenuClicked(e));
  2062. titleCont.appendChild(titleElem);
  2063. headerElem.appendChild(titleCont);
  2064. headerElem.appendChild(closeElem);
  2065. //#SECTION body
  2066. const menuBodyElem = document.createElement("div");
  2067. menuBodyElem.classList.add("bytm-menu-body");
  2068. const textElem = document.createElement("div");
  2069. textElem.id = "bytm-export-menu-text";
  2070. textElem.textContent = t("export_hint");
  2071. const textAreaElem = document.createElement("textarea");
  2072. textAreaElem.id = "bytm-export-menu-textarea";
  2073. textAreaElem.readOnly = true;
  2074. const cfgString = JSON.stringify({ formatVersion, data: getFeatures() });
  2075. textAreaElem.value = canCompress ? yield compress(cfgString, compressionFormat) : cfgString;
  2076. siteEvents.on("configChanged", (data) => __awaiter(this, void 0, void 0, function* () {
  2077. const textAreaElem = document.querySelector("#bytm-export-menu-textarea");
  2078. const cfgString = JSON.stringify({ formatVersion, data });
  2079. if (textAreaElem)
  2080. textAreaElem.value = canCompress ? yield compress(cfgString, compressionFormat) : cfgString;
  2081. }));
  2082. //#SECTION footer
  2083. const footerElem = document.createElement("div");
  2084. footerElem.classList.add("bytm-menu-footer-right");
  2085. const copyBtnElem = document.createElement("button");
  2086. copyBtnElem.classList.add("bytm-btn");
  2087. copyBtnElem.textContent = t("copy_to_clipboard");
  2088. copyBtnElem.ariaLabel = copyBtnElem.title = t("copy_config_tooltip");
  2089. const copiedTextElem = document.createElement("span");
  2090. copiedTextElem.id = "bytm-export-menu-copied-txt";
  2091. copiedTextElem.classList.add("bytm-menu-footer-copied");
  2092. copiedTextElem.textContent = t("copied_notice");
  2093. copiedTextElem.style.display = "none";
  2094. copyBtnElem.addEventListener("click", (evt) => __awaiter(this, void 0, void 0, function* () {
  2095. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  2096. const textAreaElem = document.querySelector("#bytm-export-menu-textarea");
  2097. if (textAreaElem) {
  2098. GM.setClipboard(textAreaElem.value);
  2099. copiedTextElem.style.display = "inline-block";
  2100. if (typeof copiedTxtTimeout !== "number") {
  2101. copiedTxtTimeout = setTimeout(() => {
  2102. copiedTextElem.style.display = "none";
  2103. copiedTxtTimeout = undefined;
  2104. }, 3000);
  2105. }
  2106. }
  2107. }));
  2108. // flex-direction is row-reverse
  2109. footerElem.appendChild(copyBtnElem);
  2110. footerElem.appendChild(copiedTextElem);
  2111. //#SECTION finalize
  2112. menuBodyElem.appendChild(textElem);
  2113. menuBodyElem.appendChild(textAreaElem);
  2114. menuBodyElem.appendChild(footerElem);
  2115. menuContainer.appendChild(headerElem);
  2116. menuContainer.appendChild(menuBodyElem);
  2117. menuBgElem.appendChild(menuContainer);
  2118. document.body.appendChild(menuBgElem);
  2119. });
  2120. }
  2121. /** Closes the export menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  2122. function closeExportMenu(evt) {
  2123. var _a;
  2124. if (!isExportMenuOpen)
  2125. return;
  2126. isExportMenuOpen = false;
  2127. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  2128. document.body.classList.remove("bytm-disable-scroll");
  2129. (_a = document.querySelector("ytmusic-app")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert");
  2130. const menuBg = document.querySelector("#bytm-export-menu-bg");
  2131. if (!menuBg)
  2132. return warn("Couldn't find export menu background element");
  2133. menuBg.style.visibility = "hidden";
  2134. menuBg.style.display = "none";
  2135. const copiedTxt = document.querySelector("#bytm-export-menu-copied-txt");
  2136. if (copiedTxt) {
  2137. copiedTxt.style.display = "none";
  2138. if (typeof copiedTxtTimeout === "number") {
  2139. clearTimeout(copiedTxtTimeout);
  2140. copiedTxtTimeout = undefined;
  2141. }
  2142. }
  2143. }
  2144. /** Opens the export menu if it is closed */
  2145. function openExportMenu() {
  2146. var _a;
  2147. if (isExportMenuOpen)
  2148. return;
  2149. isExportMenuOpen = true;
  2150. document.body.classList.add("bytm-disable-scroll");
  2151. (_a = document.querySelector("ytmusic-app")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true");
  2152. const menuBg = document.querySelector("#bytm-export-menu-bg");
  2153. if (!menuBg)
  2154. return warn("Couldn't find export menu background element");
  2155. menuBg.style.visibility = "visible";
  2156. menuBg.style.display = "block";
  2157. }
  2158. //#MARKER import menu
  2159. let isImportMenuOpen = false;
  2160. /** Adds a menu to import a configuration from compressed or uncompressed JSON (hidden by default) */
  2161. function addImportMenu() {
  2162. return __awaiter(this, void 0, void 0, function* () {
  2163. const menuBgElem = document.createElement("div");
  2164. menuBgElem.id = "bytm-import-menu-bg";
  2165. menuBgElem.classList.add("bytm-menu-bg");
  2166. menuBgElem.ariaLabel = menuBgElem.title = t("close_menu_tooltip");
  2167. menuBgElem.style.visibility = "hidden";
  2168. menuBgElem.style.display = "none";
  2169. menuBgElem.addEventListener("click", (e) => {
  2170. var _a;
  2171. if (isImportMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-import-menu-bg") {
  2172. closeImportMenu(e);
  2173. openCfgMenu();
  2174. }
  2175. });
  2176. document.body.addEventListener("keydown", (e) => {
  2177. if (isImportMenuOpen && e.key === "Escape") {
  2178. closeImportMenu(e);
  2179. openCfgMenu();
  2180. }
  2181. });
  2182. const menuContainer = document.createElement("div");
  2183. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  2184. menuContainer.classList.add("bytm-menu");
  2185. menuContainer.id = "bytm-import-menu";
  2186. //#SECTION title bar
  2187. const headerElem = document.createElement("div");
  2188. headerElem.classList.add("bytm-menu-header");
  2189. const titleCont = document.createElement("div");
  2190. titleCont.className = "bytm-menu-titlecont";
  2191. titleCont.role = "heading";
  2192. titleCont.ariaLevel = "1";
  2193. const titleElem = document.createElement("h2");
  2194. titleElem.className = "bytm-menu-title";
  2195. titleElem.textContent = t("import_menu_title", scriptInfo.name);
  2196. const closeElem = document.createElement("img");
  2197. closeElem.classList.add("bytm-menu-close");
  2198. closeElem.role = "button";
  2199. closeElem.tabIndex = 0;
  2200. closeElem.src = yield getResourceUrl("img-close");
  2201. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  2202. const closeImportMenuClicked = (e) => {
  2203. closeImportMenu(e);
  2204. openCfgMenu();
  2205. };
  2206. closeElem.addEventListener("click", closeImportMenuClicked);
  2207. closeElem.addEventListener("keydown", (e) => e.key === "Enter" && closeImportMenuClicked(e));
  2208. titleCont.appendChild(titleElem);
  2209. headerElem.appendChild(titleCont);
  2210. headerElem.appendChild(closeElem);
  2211. //#SECTION body
  2212. const menuBodyElem = document.createElement("div");
  2213. menuBodyElem.classList.add("bytm-menu-body");
  2214. const textElem = document.createElement("div");
  2215. textElem.id = "bytm-import-menu-text";
  2216. textElem.textContent = t("import_hint");
  2217. const textAreaElem = document.createElement("textarea");
  2218. textAreaElem.id = "bytm-import-menu-textarea";
  2219. //#SECTION footer
  2220. const footerElem = document.createElement("div");
  2221. footerElem.classList.add("bytm-menu-footer-right");
  2222. const importBtnElem = document.createElement("button");
  2223. importBtnElem.classList.add("bytm-btn");
  2224. importBtnElem.textContent = t("import");
  2225. importBtnElem.ariaLabel = importBtnElem.title = t("start_import_tooltip");
  2226. importBtnElem.addEventListener("click", (evt) => __awaiter(this, void 0, void 0, function* () {
  2227. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  2228. const textAreaElem = document.querySelector("#bytm-import-menu-textarea");
  2229. if (!textAreaElem)
  2230. return warn("Couldn't find import menu textarea element");
  2231. try {
  2232. /** Tries to parse an uncompressed or compressed input string as a JSON object */
  2233. const decode = (input) => __awaiter(this, void 0, void 0, function* () {
  2234. try {
  2235. return JSON.parse(input);
  2236. }
  2237. catch (_a) {
  2238. try {
  2239. return JSON.parse(yield decompress(input, compressionFormat));
  2240. }
  2241. catch (err) {
  2242. warn("Couldn't import configuration:", err);
  2243. return null;
  2244. }
  2245. }
  2246. });
  2247. const parsed = yield decode(textAreaElem.value.trim());
  2248. if (typeof parsed !== "object")
  2249. return alert(t("import_error_invalid"));
  2250. if (typeof parsed.formatVersion !== "number")
  2251. return alert(t("import_error_no_format_version"));
  2252. if (typeof parsed.data !== "object")
  2253. return alert(t("import_error_no_data"));
  2254. if (parsed.formatVersion < formatVersion) {
  2255. let newData = JSON.parse(JSON.stringify(parsed.data));
  2256. const sortedMigrations = Object.entries(migrations)
  2257. .sort(([a], [b]) => Number(a) - Number(b));
  2258. let curFmtVer = Number(parsed.formatVersion);
  2259. for (const [fmtVer, migrationFunc] of sortedMigrations) {
  2260. const ver = Number(fmtVer);
  2261. if (curFmtVer < formatVersion && curFmtVer < ver) {
  2262. try {
  2263. const migRes = JSON.parse(JSON.stringify(migrationFunc(newData)));
  2264. newData = migRes instanceof Promise ? yield migRes : migRes;
  2265. curFmtVer = ver;
  2266. }
  2267. catch (err) {
  2268. console.error(`Error while running migration function for format version ${fmtVer}:`, err);
  2269. }
  2270. }
  2271. }
  2272. parsed.formatVersion = curFmtVer;
  2273. parsed.data = newData;
  2274. }
  2275. else if (parsed.formatVersion !== formatVersion)
  2276. return alert(t("import_error_wrong_format_version", formatVersion, parsed.formatVersion));
  2277. yield saveFeatures(Object.assign(Object.assign({}, getFeatures()), parsed.data));
  2278. if (confirm(t("import_success_confirm_reload"))) {
  2279. disableBeforeUnload();
  2280. return location.reload();
  2281. }
  2282. emitSiteEvent("rebuildCfgMenu", parsed.data);
  2283. closeImportMenu();
  2284. openCfgMenu();
  2285. }
  2286. catch (err) {
  2287. warn("Couldn't import configuration:", err);
  2288. alert(t("import_error_invalid"));
  2289. }
  2290. }));
  2291. footerElem.appendChild(importBtnElem);
  2292. //#SECTION finalize
  2293. menuBodyElem.appendChild(textElem);
  2294. menuBodyElem.appendChild(textAreaElem);
  2295. menuBodyElem.appendChild(footerElem);
  2296. menuContainer.appendChild(headerElem);
  2297. menuContainer.appendChild(menuBodyElem);
  2298. menuBgElem.appendChild(menuContainer);
  2299. document.body.appendChild(menuBgElem);
  2300. });
  2301. }
  2302. /** Closes the import menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  2303. function closeImportMenu(evt) {
  2304. var _a;
  2305. if (!isImportMenuOpen)
  2306. return;
  2307. isImportMenuOpen = false;
  2308. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  2309. document.body.classList.remove("bytm-disable-scroll");
  2310. (_a = document.querySelector("ytmusic-app")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert");
  2311. const menuBg = document.querySelector("#bytm-import-menu-bg");
  2312. const textAreaElem = document.querySelector("#bytm-import-menu-textarea");
  2313. if (textAreaElem)
  2314. textAreaElem.value = "";
  2315. if (!menuBg)
  2316. return warn("Couldn't find import menu background element");
  2317. menuBg.style.visibility = "hidden";
  2318. menuBg.style.display = "none";
  2319. }
  2320. /** Opens the import menu if it is closed */
  2321. function openImportMenu() {
  2322. var _a;
  2323. if (isImportMenuOpen)
  2324. return;
  2325. isImportMenuOpen = true;
  2326. document.body.classList.add("bytm-disable-scroll");
  2327. (_a = document.querySelector("ytmusic-app")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true");
  2328. const menuBg = document.querySelector("#bytm-import-menu-bg");
  2329. if (!menuBg)
  2330. return warn("Couldn't find import menu background element");
  2331. menuBg.style.visibility = "visible";
  2332. menuBg.style.display = "block";
  2333. }
  2334. //#MARKER changelog menu
  2335. let isChangelogMenuOpen = false;
  2336. /** Adds a changelog menu (hidden by default) */
  2337. function addChangelogMenu() {
  2338. return __awaiter(this, void 0, void 0, function* () {
  2339. const menuBgElem = document.createElement("div");
  2340. menuBgElem.id = "bytm-changelog-menu-bg";
  2341. menuBgElem.classList.add("bytm-menu-bg");
  2342. menuBgElem.ariaLabel = menuBgElem.title = t("close_menu_tooltip");
  2343. menuBgElem.style.visibility = "hidden";
  2344. menuBgElem.style.display = "none";
  2345. menuBgElem.addEventListener("click", (e) => {
  2346. var _a;
  2347. if (isChangelogMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-changelog-menu-bg") {
  2348. closeChangelogMenu(e);
  2349. if (menuBgElem.dataset.returnTo === "cfgMenu")
  2350. openCfgMenu();
  2351. }
  2352. });
  2353. document.body.addEventListener("keydown", (e) => {
  2354. if (isChangelogMenuOpen && e.key === "Escape") {
  2355. closeChangelogMenu(e);
  2356. if (menuBgElem.dataset.returnTo === "cfgMenu")
  2357. openCfgMenu();
  2358. }
  2359. });
  2360. const menuContainer = document.createElement("div");
  2361. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  2362. menuContainer.classList.add("bytm-menu");
  2363. menuContainer.id = "bytm-changelog-menu";
  2364. //#SECTION title bar
  2365. const headerElem = document.createElement("div");
  2366. headerElem.classList.add("bytm-menu-header");
  2367. const titleCont = document.createElement("div");
  2368. titleCont.className = "bytm-menu-titlecont";
  2369. titleCont.role = "heading";
  2370. titleCont.ariaLevel = "1";
  2371. const titleElem = document.createElement("h2");
  2372. titleElem.className = "bytm-menu-title";
  2373. titleElem.textContent = t("changelog_menu_title", scriptInfo.name);
  2374. const closeElem = document.createElement("img");
  2375. closeElem.classList.add("bytm-menu-close");
  2376. closeElem.role = "button";
  2377. closeElem.tabIndex = 0;
  2378. closeElem.src = yield getResourceUrl("img-close");
  2379. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  2380. const closeChangelogMenuClicked = (e) => {
  2381. closeChangelogMenu(e);
  2382. if (menuBgElem.dataset.returnTo === "cfgMenu")
  2383. openCfgMenu();
  2384. };
  2385. closeElem.addEventListener("click", closeChangelogMenuClicked);
  2386. closeElem.addEventListener("keydown", (e) => e.key === "Enter" && closeChangelogMenuClicked(e));
  2387. titleCont.appendChild(titleElem);
  2388. headerElem.appendChild(titleCont);
  2389. headerElem.appendChild(closeElem);
  2390. //#SECTION body
  2391. const menuBodyElem = document.createElement("div");
  2392. menuBodyElem.id = "bytm-changelog-menu-body";
  2393. menuBodyElem.classList.add("bytm-menu-body");
  2394. const textElem = document.createElement("div");
  2395. textElem.id = "bytm-changelog-menu-text";
  2396. textElem.classList.add("bytm-markdown-container");
  2397. textElem.innerHTML = changelog.html;
  2398. //#SECTION finalize
  2399. menuBodyElem.appendChild(textElem);
  2400. menuContainer.appendChild(headerElem);
  2401. menuContainer.appendChild(menuBodyElem);
  2402. menuBgElem.appendChild(menuContainer);
  2403. document.body.appendChild(menuBgElem);
  2404. const anchors = document.querySelectorAll("#bytm-changelog-menu-text a");
  2405. for (const anchor of anchors) {
  2406. anchor.ariaLabel = anchor.title = anchor.href;
  2407. anchor.target = "_blank";
  2408. }
  2409. });
  2410. }
  2411. /** Closes the changelog menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  2412. function closeChangelogMenu(evt) {
  2413. var _a;
  2414. if (!isChangelogMenuOpen)
  2415. return;
  2416. isChangelogMenuOpen = false;
  2417. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  2418. document.body.classList.remove("bytm-disable-scroll");
  2419. (_a = document.querySelector("ytmusic-app")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert");
  2420. const menuBg = document.querySelector("#bytm-changelog-menu-bg");
  2421. if (!menuBg)
  2422. return warn("Couldn't find changelog menu background element");
  2423. menuBg.style.visibility = "hidden";
  2424. menuBg.style.display = "none";
  2425. }
  2426. /**
  2427. * Opens the changelog menu if it is closed
  2428. * @param returnTo What menu to open after the changelog menu is closed
  2429. */
  2430. function openChangelogMenu(returnTo = "cfgMenu") {
  2431. var _a;
  2432. if (isChangelogMenuOpen)
  2433. return;
  2434. isChangelogMenuOpen = true;
  2435. document.body.classList.add("bytm-disable-scroll");
  2436. (_a = document.querySelector("ytmusic-app")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true");
  2437. const menuBg = document.querySelector("#bytm-changelog-menu-bg");
  2438. if (!menuBg)
  2439. return warn("Couldn't find changelog menu background element");
  2440. menuBg.dataset.returnTo = returnTo;
  2441. menuBg.style.visibility = "visible";
  2442. menuBg.style.display = "block";
  2443. }
  2444. let features$2;
  2445. function preInitLayout(feats) {
  2446. features$2 = feats;
  2447. }
  2448. //#MARKER BYTM-Config buttons
  2449. let logoExchanged = false, improveLogoCalled = false;
  2450. /** Adds a watermark beneath the logo */
  2451. function addWatermark() {
  2452. return __awaiter(this, void 0, void 0, function* () {
  2453. const watermark = document.createElement("a");
  2454. watermark.role = "button";
  2455. watermark.id = "bytm-watermark";
  2456. watermark.className = "style-scope ytmusic-nav-bar bytm-no-select";
  2457. watermark.textContent = scriptInfo.name;
  2458. watermark.ariaLabel = watermark.title = t("open_menu_tooltip", scriptInfo.name);
  2459. watermark.tabIndex = 0;
  2460. improveLogo();
  2461. const watermarkOpenMenu = (e) => {
  2462. e.stopPropagation();
  2463. if ((!e.shiftKey && !e.ctrlKey) || logoExchanged)
  2464. openCfgMenu();
  2465. if (!logoExchanged && (e.shiftKey || e.ctrlKey))
  2466. exchangeLogo();
  2467. };
  2468. watermark.addEventListener("click", watermarkOpenMenu);
  2469. watermark.addEventListener("keydown", (e) => e.key === "Enter" && watermarkOpenMenu(e));
  2470. onSelectorOld("ytmusic-nav-bar #left-content", {
  2471. listener: (logoElem) => insertAfter(logoElem, watermark),
  2472. });
  2473. log("Added watermark element");
  2474. });
  2475. }
  2476. /** Turns the regular `<img>`-based logo into inline SVG to be able to animate and modify parts of it */
  2477. function improveLogo() {
  2478. return __awaiter(this, void 0, void 0, function* () {
  2479. try {
  2480. if (improveLogoCalled)
  2481. return;
  2482. improveLogoCalled = true;
  2483. const res = yield fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg");
  2484. const svg = yield res.text();
  2485. onSelectorOld("ytmusic-logo a", {
  2486. listener: (logoElem) => {
  2487. var _a;
  2488. logoElem.classList.add("bytm-mod-logo", "bytm-no-select");
  2489. logoElem.innerHTML = svg;
  2490. logoElem.querySelectorAll("ellipse").forEach((e) => {
  2491. e.classList.add("bytm-mod-logo-ellipse");
  2492. });
  2493. (_a = logoElem.querySelector("path")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-mod-logo-path");
  2494. log("Swapped logo to inline SVG");
  2495. },
  2496. });
  2497. }
  2498. catch (err) {
  2499. error("Couldn't improve logo due to an error:", err);
  2500. }
  2501. });
  2502. }
  2503. /** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */
  2504. function exchangeLogo() {
  2505. onSelectorOld(".bytm-mod-logo", {
  2506. listener: (logoElem) => __awaiter(this, void 0, void 0, function* () {
  2507. if (logoElem.classList.contains("bytm-logo-exchanged"))
  2508. return;
  2509. logoExchanged = true;
  2510. logoElem.classList.add("bytm-logo-exchanged");
  2511. const iconUrl = yield getResourceUrl("img-logo");
  2512. const newLogo = document.createElement("img");
  2513. newLogo.className = "bytm-mod-logo-img";
  2514. newLogo.src = iconUrl;
  2515. logoElem.insertBefore(newLogo, logoElem.querySelector("svg"));
  2516. document.head.querySelectorAll("link[rel=\"icon\"]").forEach((e) => {
  2517. e.href = iconUrl;
  2518. });
  2519. setTimeout(() => {
  2520. logoElem.querySelectorAll(".bytm-mod-logo-ellipse").forEach(e => e.remove());
  2521. }, 1000);
  2522. }),
  2523. });
  2524. }
  2525. /** Called whenever the avatar popover menu exists to add a BYTM-Configuration button to the user menu popover */
  2526. function addConfigMenuOption(container) {
  2527. return __awaiter(this, void 0, void 0, function* () {
  2528. const cfgOptElem = document.createElement("div");
  2529. cfgOptElem.className = "bytm-cfg-menu-option";
  2530. const cfgOptItemElem = document.createElement("div");
  2531. cfgOptItemElem.className = "bytm-cfg-menu-option-item";
  2532. cfgOptItemElem.role = "button";
  2533. cfgOptItemElem.tabIndex = 0;
  2534. cfgOptItemElem.ariaLabel = cfgOptItemElem.title = t("open_menu_tooltip", scriptInfo.name);
  2535. const cfgOptItemClicked = (e) => __awaiter(this, void 0, void 0, function* () {
  2536. const settingsBtnElem = document.querySelector("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button");
  2537. settingsBtnElem === null || settingsBtnElem === void 0 ? void 0 : settingsBtnElem.click();
  2538. yield pauseFor(20);
  2539. if ((!e.shiftKey && !e.ctrlKey) || logoExchanged)
  2540. openCfgMenu();
  2541. if (!logoExchanged && (e.shiftKey || e.ctrlKey))
  2542. exchangeLogo();
  2543. });
  2544. cfgOptItemElem.addEventListener("click", cfgOptItemClicked);
  2545. cfgOptItemElem.addEventListener("keydown", (e) => e.key === "Enter" && cfgOptItemClicked(e));
  2546. const cfgOptIconElem = document.createElement("img");
  2547. cfgOptIconElem.className = "bytm-cfg-menu-option-icon";
  2548. cfgOptIconElem.src = yield getResourceUrl("img-logo");
  2549. const cfgOptTextElem = document.createElement("div");
  2550. cfgOptTextElem.className = "bytm-cfg-menu-option-text";
  2551. cfgOptTextElem.textContent = t("config_menu_option", scriptInfo.name);
  2552. cfgOptItemElem.appendChild(cfgOptIconElem);
  2553. cfgOptItemElem.appendChild(cfgOptTextElem);
  2554. cfgOptElem.appendChild(cfgOptItemElem);
  2555. container.appendChild(cfgOptElem);
  2556. improveLogo();
  2557. log("Added BYTM-Configuration button to menu popover");
  2558. });
  2559. }
  2560. //#MARKER remove upgrade tab
  2561. /** Removes the "Upgrade" / YT Music Premium tab from the sidebar */
  2562. function removeUpgradeTab() {
  2563. return __awaiter(this, void 0, void 0, function* () {
  2564. onSelectorOld("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer:nth-of-type(4)", {
  2565. listener: (tabElemLarge) => {
  2566. tabElemLarge.remove();
  2567. log("Removed large upgrade tab");
  2568. },
  2569. });
  2570. onSelectorOld("ytmusic-app-layout #mini-guide ytmusic-guide-renderer #sections ytmusic-guide-section-renderer[is-primary] #items ytmusic-guide-entry-renderer:nth-of-type(4)", {
  2571. listener: (tabElemSmall) => {
  2572. tabElemSmall.remove();
  2573. log("Removed small upgrade tab");
  2574. },
  2575. });
  2576. });
  2577. }
  2578. //#MARKER volume slider
  2579. function initVolumeFeatures() {
  2580. return __awaiter(this, void 0, void 0, function* () {
  2581. // not technically an input element but behaves pretty much the same
  2582. onSelectorOld("tp-yt-paper-slider#volume-slider", {
  2583. listener: (sliderElem) => {
  2584. const volSliderCont = document.createElement("div");
  2585. volSliderCont.id = "bytm-vol-slider-cont";
  2586. if (features$2.volumeSliderScrollStep !== featInfo.volumeSliderScrollStep.default) {
  2587. for (const evtName of ["wheel", "scroll", "mousewheel", "DOMMouseScroll"]) {
  2588. volSliderCont.addEventListener(evtName, (e) => {
  2589. var _a, _b;
  2590. e.preventDefault();
  2591. // cancels all the other events that would be fired
  2592. e.stopImmediatePropagation();
  2593. const delta = (_b = (_a = e.deltaY) !== null && _a !== void 0 ? _a : e.detail) !== null && _b !== void 0 ? _b : 1;
  2594. const volumeDir = -Math.sign(delta);
  2595. const newVolume = String(Number(sliderElem.value) + (features$2.volumeSliderScrollStep * volumeDir));
  2596. sliderElem.value = newVolume;
  2597. sliderElem.setAttribute("aria-valuenow", newVolume);
  2598. // make the site actually change the volume
  2599. sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
  2600. }, {
  2601. // takes precedence over the slider's own event listener
  2602. capture: true,
  2603. });
  2604. }
  2605. }
  2606. addParent(sliderElem, volSliderCont);
  2607. if (typeof features$2.volumeSliderSize === "number")
  2608. setVolSliderSize();
  2609. if (features$2.volumeSliderLabel)
  2610. addVolumeSliderLabel(sliderElem, volSliderCont);
  2611. setVolSliderStep(sliderElem);
  2612. },
  2613. });
  2614. });
  2615. }
  2616. /** Adds a percentage label to the volume slider and tooltip */
  2617. function addVolumeSliderLabel(sliderElem, sliderContainer) {
  2618. const labelElem = document.createElement("div");
  2619. labelElem.id = "bytm-vol-slider-label";
  2620. labelElem.textContent = `${sliderElem.value}%`;
  2621. // prevent video from minimizing
  2622. labelElem.addEventListener("click", (e) => e.stopPropagation());
  2623. const getLabelText = (slider) => { var _a; return t("volume_tooltip", slider.value, (_a = features$2.volumeSliderStep) !== null && _a !== void 0 ? _a : slider.step); };
  2624. const labelFull = getLabelText(sliderElem);
  2625. sliderContainer.setAttribute("title", labelFull);
  2626. sliderElem.setAttribute("title", labelFull);
  2627. sliderElem.setAttribute("aria-valuetext", labelFull);
  2628. const updateLabel = () => {
  2629. const labelFull = getLabelText(sliderElem);
  2630. sliderContainer.setAttribute("title", labelFull);
  2631. sliderElem.setAttribute("title", labelFull);
  2632. sliderElem.setAttribute("aria-valuetext", labelFull);
  2633. const labelElem2 = document.querySelector("#bytm-vol-slider-label");
  2634. if (labelElem2)
  2635. labelElem2.textContent = `${sliderElem.value}%`;
  2636. };
  2637. sliderElem.addEventListener("change", () => updateLabel());
  2638. onSelectorOld("#bytm-vol-slider-cont", {
  2639. listener: (volumeCont) => {
  2640. volumeCont.appendChild(labelElem);
  2641. },
  2642. });
  2643. let lastSliderVal = Number(sliderElem.value);
  2644. // show label if hovering over slider or slider is focused
  2645. const sliderHoverObserver = new MutationObserver(() => {
  2646. if (sliderElem.classList.contains("on-hover") || document.activeElement === sliderElem)
  2647. labelElem.classList.add("bytm-visible");
  2648. else if (labelElem.classList.contains("bytm-visible") || document.activeElement !== sliderElem)
  2649. labelElem.classList.remove("bytm-visible");
  2650. if (Number(sliderElem.value) !== lastSliderVal) {
  2651. lastSliderVal = Number(sliderElem.value);
  2652. updateLabel();
  2653. }
  2654. });
  2655. sliderHoverObserver.observe(sliderElem, {
  2656. attributes: true,
  2657. });
  2658. }
  2659. /** Sets the volume slider to a set size */
  2660. function setVolSliderSize() {
  2661. const { volumeSliderSize: size } = features$2;
  2662. if (typeof size !== "number" || isNaN(Number(size)))
  2663. return;
  2664. addGlobalStyle(`\
  2665. #bytm-vol-slider-cont tp-yt-paper-slider#volume-slider {
  2666. width: ${size}px !important;
  2667. }`);
  2668. }
  2669. /** Sets the `step` attribute of the volume slider */
  2670. function setVolSliderStep(sliderElem) {
  2671. sliderElem.setAttribute("step", String(features$2.volumeSliderStep));
  2672. }
  2673. //#MARKER anchor improvements
  2674. /** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
  2675. function addAnchorImprovements() {
  2676. return __awaiter(this, void 0, void 0, function* () {
  2677. try {
  2678. const css = yield (yield fetchAdvanced(yield getResourceUrl("css-anchor_improvements"))).text();
  2679. css && addGlobalStyle(css);
  2680. }
  2681. catch (err) {
  2682. error("Couldn't add anchor improvements CSS due to an error:", err);
  2683. }
  2684. //#SECTION carousel shelves
  2685. try {
  2686. const preventDefault = (e) => e.preventDefault();
  2687. /** Adds anchor improvements to &lt;ytmusic-responsive-list-item-renderer&gt; */
  2688. const addListItemAnchors = (items) => {
  2689. var _a;
  2690. for (const item of items) {
  2691. if (item.classList.contains("bytm-anchor-improved"))
  2692. continue;
  2693. item.classList.add("bytm-anchor-improved");
  2694. const thumbnailElem = item.querySelector(".left-items");
  2695. const titleElem = item.querySelector(".title-column .title a");
  2696. if (!thumbnailElem || !titleElem)
  2697. continue;
  2698. const anchorElem = document.createElement("a");
  2699. anchorElem.classList.add("bytm-anchor", "bytm-carousel-shelf-anchor");
  2700. anchorElem.href = (_a = titleElem === null || titleElem === void 0 ? void 0 : titleElem.href) !== null && _a !== void 0 ? _a : "#";
  2701. anchorElem.target = "_self";
  2702. anchorElem.role = "button";
  2703. anchorElem.addEventListener("click", preventDefault);
  2704. addParent(thumbnailElem, anchorElem);
  2705. }
  2706. };
  2707. // home page
  2708. onSelectorOld("#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", {
  2709. continuous: true,
  2710. all: true,
  2711. listener: addListItemAnchors,
  2712. });
  2713. // related tab in /watch
  2714. onSelectorOld("ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", {
  2715. continuous: true,
  2716. all: true,
  2717. listener: addListItemAnchors,
  2718. });
  2719. // playlists
  2720. onSelectorOld("#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", {
  2721. continuous: true,
  2722. all: true,
  2723. listener: addListItemAnchors,
  2724. });
  2725. // generic shelves
  2726. onSelectorOld("#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", {
  2727. continuous: true,
  2728. all: true,
  2729. listener: addListItemAnchors,
  2730. });
  2731. }
  2732. catch (err) {
  2733. error("Couldn't improve carousel shelf anchors due to an error:", err);
  2734. }
  2735. //#SECTION sidebar
  2736. try {
  2737. const addSidebarAnchors = (sidebarCont) => {
  2738. const items = sidebarCont.parentNode.querySelectorAll("ytmusic-guide-entry-renderer tp-yt-paper-item");
  2739. improveSidebarAnchors(items);
  2740. return items.length;
  2741. };
  2742. onSelectorOld("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer", {
  2743. listener: (sidebarCont) => {
  2744. const itemsAmt = addSidebarAnchors(sidebarCont);
  2745. log(`Added anchors around ${itemsAmt} sidebar ${autoPlural("item", itemsAmt)}`);
  2746. },
  2747. });
  2748. onSelectorOld("ytmusic-app-layout #mini-guide ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", {
  2749. listener: (miniSidebarCont) => {
  2750. const itemsAmt = addSidebarAnchors(miniSidebarCont);
  2751. log(`Added anchors around ${itemsAmt} mini sidebar ${autoPlural("item", itemsAmt)}`);
  2752. },
  2753. });
  2754. }
  2755. catch (err) {
  2756. error("Couldn't add anchors to sidebar items due to an error:", err);
  2757. }
  2758. });
  2759. }
  2760. const sidebarPaths = [
  2761. "/",
  2762. "/explore",
  2763. "/library",
  2764. ];
  2765. /**
  2766. * Adds anchors to the sidebar items so they can be opened in a new tab
  2767. * @param sidebarItem
  2768. */
  2769. function improveSidebarAnchors(sidebarItems) {
  2770. sidebarItems.forEach((item, i) => {
  2771. var _a;
  2772. const anchorElem = document.createElement("a");
  2773. anchorElem.classList.add("bytm-anchor", "bytm-no-select");
  2774. anchorElem.role = "button";
  2775. anchorElem.target = "_self";
  2776. anchorElem.href = (_a = sidebarPaths[i]) !== null && _a !== void 0 ? _a : "#";
  2777. anchorElem.ariaLabel = anchorElem.title = t("middle_click_open_tab");
  2778. anchorElem.addEventListener("click", (e) => {
  2779. e.preventDefault();
  2780. });
  2781. addParent(item, anchorElem);
  2782. });
  2783. }
  2784. //#MARKER remove share tracking param
  2785. let lastShareVal = "";
  2786. /** Removes the ?si tracking parameter from share URLs */
  2787. function removeShareTrackingParam() {
  2788. return __awaiter(this, void 0, void 0, function* () {
  2789. const removeSiParam = (inputElem) => {
  2790. try {
  2791. if (lastShareVal === inputElem.value)
  2792. return;
  2793. const url = new URL(inputElem.value);
  2794. if (!url.searchParams.has("si"))
  2795. return;
  2796. lastShareVal = inputElem.value;
  2797. url.searchParams.delete("si");
  2798. inputElem.value = String(url);
  2799. log(`Removed tracking parameter from share link: ${url}`);
  2800. }
  2801. catch (err) {
  2802. warn("Couldn't remove tracking parameter from share link due to error:", err);
  2803. }
  2804. };
  2805. onSelectorOld("tp-yt-paper-dialog ytmusic-unified-share-panel-renderer", {
  2806. listener: (sharePanelEl) => {
  2807. const obs = new MutationObserver(() => {
  2808. const inputElem = sharePanelEl.querySelector("input#share-url");
  2809. inputElem && removeSiParam(inputElem);
  2810. });
  2811. obs.observe(sharePanelEl, {
  2812. childList: true,
  2813. subtree: true,
  2814. attributeFilter: ["aria-hidden", "checked"],
  2815. });
  2816. },
  2817. });
  2818. });
  2819. }
  2820. //#MARKER fix margins
  2821. /** Applies global CSS to fix various spacings */
  2822. function fixSpacing() {
  2823. return __awaiter(this, void 0, void 0, function* () {
  2824. try {
  2825. const css = yield (yield fetchAdvanced(yield getResourceUrl("css-fix_spacing"))).text();
  2826. css && addGlobalStyle(css);
  2827. }
  2828. catch (err) {
  2829. error("Couldn't fix spacing due to an error:", err);
  2830. }
  2831. });
  2832. }
  2833. //#MARKER scroll to active song
  2834. /** Adds a button to the queue to scroll to the active song */
  2835. function addScrollToActiveBtn() {
  2836. return __awaiter(this, void 0, void 0, function* () {
  2837. onSelectorOld("#side-panel #tabsContent tp-yt-paper-tab:nth-of-type(1)", {
  2838. listener: (tabElem) => __awaiter(this, void 0, void 0, function* () {
  2839. const containerElem = document.createElement("div");
  2840. containerElem.id = "bytm-scroll-to-active-btn-cont";
  2841. const linkElem = document.createElement("div");
  2842. linkElem.id = "bytm-scroll-to-active-btn";
  2843. linkElem.className = "ytmusic-player-bar bytm-generic-btn";
  2844. linkElem.ariaLabel = linkElem.title = t("scroll_to_playing");
  2845. linkElem.role = "button";
  2846. const imgElem = document.createElement("img");
  2847. imgElem.className = "bytm-generic-btn-img";
  2848. imgElem.src = yield getResourceUrl("img-skip_to");
  2849. linkElem.addEventListener("click", (e) => {
  2850. const activeItem = document.querySelector("#side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]");
  2851. if (!activeItem)
  2852. return;
  2853. e.preventDefault();
  2854. e.stopImmediatePropagation();
  2855. activeItem.scrollIntoView({
  2856. behavior: "smooth",
  2857. block: "center",
  2858. inline: "center",
  2859. });
  2860. });
  2861. linkElem.appendChild(imgElem);
  2862. containerElem.appendChild(linkElem);
  2863. tabElem.appendChild(containerElem);
  2864. }),
  2865. });
  2866. });
  2867. }
  2868. let features$1;
  2869. function preInitInput(feats) {
  2870. features$1 = feats;
  2871. }
  2872. //#MARKER arrow key skip
  2873. function initArrowKeySkip() {
  2874. return __awaiter(this, void 0, void 0, function* () {
  2875. document.addEventListener("keydown", (evt) => {
  2876. var _a, _b, _c, _d;
  2877. if (!["ArrowLeft", "ArrowRight"].includes(evt.code))
  2878. return;
  2879. // discard the event when a (text) input is currently active, like when editing a playlist
  2880. if (["INPUT", "TEXTAREA", "SELECT"].includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "_"))
  2881. return info(`Captured valid key to skip forward or backward but the current active element is <${(_c = document.activeElement) === null || _c === void 0 ? void 0 : _c.tagName.toLowerCase()}>, so the keypress is ignored`);
  2882. evt.preventDefault();
  2883. evt.stopImmediatePropagation();
  2884. let skipBy = (_d = features$1.arrowKeySkipBy) !== null && _d !== void 0 ? _d : featInfo.arrowKeySkipBy.default;
  2885. if (evt.code === "ArrowLeft")
  2886. skipBy *= -1;
  2887. log(`Captured arrow key '${evt.code}' - skipping by ${skipBy} seconds`);
  2888. const vidElem = document.querySelector(videoSelector);
  2889. if (vidElem)
  2890. vidElem.currentTime = clamp(vidElem.currentTime + skipBy, 0, vidElem.duration);
  2891. });
  2892. log("Added arrow key press listener");
  2893. });
  2894. }
  2895. //#MARKER site switch
  2896. /** switch sites only if current video time is greater than this value */
  2897. const videoTimeThreshold = 3;
  2898. let siteSwitchEnabled = true;
  2899. /** Initializes the site switch feature */
  2900. function initSiteSwitch(domain) {
  2901. return __awaiter(this, void 0, void 0, function* () {
  2902. document.addEventListener("keydown", (e) => {
  2903. const hotkey = features$1.switchSitesHotkey;
  2904. if (siteSwitchEnabled && e.code === hotkey.code && e.shiftKey === hotkey.shift && e.ctrlKey === hotkey.ctrl && e.altKey === hotkey.alt)
  2905. switchSite(domain === "yt" ? "ytm" : "yt");
  2906. });
  2907. siteEvents.on("hotkeyInputActive", (state) => {
  2908. siteSwitchEnabled = !state;
  2909. });
  2910. log("Initialized site switch listener");
  2911. });
  2912. }
  2913. /** Switches to the other site (between YT and YTM) */
  2914. function switchSite(newDomain) {
  2915. return __awaiter(this, void 0, void 0, function* () {
  2916. try {
  2917. if (!(["/watch", "/playlist"].some(v => location.pathname.startsWith(v))))
  2918. return warn("Not on a supported page, so the site switch is ignored");
  2919. let subdomain;
  2920. if (newDomain === "ytm")
  2921. subdomain = "music";
  2922. else if (newDomain === "yt")
  2923. subdomain = "www";
  2924. if (!subdomain)
  2925. throw new Error(`Unrecognized domain '${newDomain}'`);
  2926. disableBeforeUnload();
  2927. const { pathname, search, hash } = new URL(location.href);
  2928. const vt = yield getVideoTime();
  2929. log(`Found video time of ${vt} seconds`);
  2930. const cleanSearch = search.split("&")
  2931. .filter((param) => !param.match(/^\??t=/))
  2932. .join("&");
  2933. const newSearch = typeof vt === "number" && vt > videoTimeThreshold ?
  2934. cleanSearch.includes("?")
  2935. ? `${cleanSearch.startsWith("?")
  2936. ? cleanSearch
  2937. : "?" + cleanSearch}&t=${vt}`
  2938. : `?t=${vt}`
  2939. : cleanSearch;
  2940. const newUrl = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`;
  2941. info(`Switching to domain '${newDomain}' at ${newUrl}`);
  2942. location.assign(newUrl);
  2943. }
  2944. catch (err) {
  2945. error("Error while switching site:", err);
  2946. }
  2947. });
  2948. }
  2949. //#MARKER number keys skip to time
  2950. /** Adds the ability to skip to a certain time in the video by pressing a number key (0-9) */
  2951. function initNumKeysSkip() {
  2952. return __awaiter(this, void 0, void 0, function* () {
  2953. document.addEventListener("keydown", (e) => {
  2954. var _a, _b, _c, _d;
  2955. if (!e.key.trim().match(/^[0-9]$/))
  2956. return;
  2957. if (isCfgMenuOpen)
  2958. return;
  2959. // discard the event when a (text) input is currently active, like when editing a playlist or when the search bar is focused
  2960. if (document.activeElement !== document.body
  2961. && !["progress-bar"].includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : "_")
  2962. && !["BUTTON", "A"].includes((_d = (_c = document.activeElement) === null || _c === void 0 ? void 0 : _c.tagName) !== null && _d !== void 0 ? _d : "_"))
  2963. return info("Captured valid key to skip video to but an unexpected element is focused, so the keypress is ignored");
  2964. const vidElem = document.querySelector(videoSelector);
  2965. if (!vidElem)
  2966. return warn("Could not find video element, so the keypress is ignored");
  2967. const newVidTime = vidElem.duration / (10 / Number(e.key));
  2968. if (!isNaN(newVidTime)) {
  2969. log(`Captured number key [${e.key}], skipping to ${Math.floor(newVidTime / 60)}m ${(newVidTime % 60).toFixed(1)}s`);
  2970. vidElem.currentTime = newVidTime;
  2971. }
  2972. });
  2973. log("Added number key press listener");
  2974. });
  2975. }
  2976. /** Base URL of geniURL */
  2977. const geniUrlBase = "https://api.sv443.net/geniurl";
  2978. /** GeniURL endpoint that gives song metadata when provided with a `?q` or `?artist` and `?song` parameter - [more info](https://api.sv443.net/geniurl) */
  2979. const geniURLSearchTopUrl = `${geniUrlBase}/search/top`;
  2980. /** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */
  2981. const geniUrlRatelimitTimeframe = 30;
  2982. //#MARKER cache
  2983. /** Cache with key format `ARTIST - SONG` (sanitized) and lyrics URLs as values. Used to prevent extraneous requests to geniURL. */
  2984. const lyricsUrlCache = new Map();
  2985. /** How many cache entries can exist at a time - this is used to cap memory usage */
  2986. const maxLyricsCacheSize = 100;
  2987. /**
  2988. * Returns the lyrics URL from the passed un-/sanitized artist and song name, or undefined if the entry doesn't exist yet.
  2989. * **The passed parameters need to be sanitized first!**
  2990. */
  2991. function getLyricsCacheEntry(artists, song) {
  2992. return lyricsUrlCache.get(`${artists} - ${song}`);
  2993. }
  2994. /** Adds the provided entry into the lyrics URL cache */
  2995. function addLyricsCacheEntry(artists, song, lyricsUrl) {
  2996. lyricsUrlCache.set(`${sanitizeArtists(artists)} - ${sanitizeSong(song)}`, lyricsUrl);
  2997. // delete oldest entry if cache gets too big
  2998. if (lyricsUrlCache.size > maxLyricsCacheSize)
  2999. lyricsUrlCache.delete([...lyricsUrlCache.keys()].at(-1));
  3000. }
  3001. //#MARKER media control bar
  3002. let currentSongTitle = "";
  3003. /** Adds a lyrics button to the media controls bar */
  3004. function addMediaCtrlLyricsBtn() {
  3005. return __awaiter(this, void 0, void 0, function* () {
  3006. onSelectorOld(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer", { listener: addActualMediaCtrlLyricsBtn });
  3007. });
  3008. }
  3009. /** Actually adds the lyrics button after the like button renderer has been verified to exist */
  3010. function addActualMediaCtrlLyricsBtn(likeContainer) {
  3011. return __awaiter(this, void 0, void 0, function* () {
  3012. const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
  3013. if (!songTitleElem)
  3014. return warn("Couldn't find song title element");
  3015. // run parallel without awaiting so the MutationObserver below can observe the title element in time
  3016. (() => __awaiter(this, void 0, void 0, function* () {
  3017. const gUrl = yield getCurrentLyricsUrl();
  3018. const linkElem = yield createLyricsBtn(gUrl !== null && gUrl !== void 0 ? gUrl : undefined);
  3019. linkElem.id = "betterytm-lyrics-button";
  3020. log("Inserted lyrics button into media controls bar");
  3021. insertAfter(likeContainer, linkElem);
  3022. }))();
  3023. currentSongTitle = songTitleElem.title;
  3024. const spinnerIconUrl = yield getResourceUrl("img-spinner");
  3025. const lyricsIconUrl = yield getResourceUrl("img-lyrics");
  3026. const errorIconUrl = yield getResourceUrl("img-error");
  3027. const onMutation = (mutations) => { var _a, mutations_1, mutations_1_1; return __awaiter(this, void 0, void 0, function* () {
  3028. var _b, e_1, _c, _d;
  3029. try {
  3030. for (_a = true, mutations_1 = __asyncValues(mutations); mutations_1_1 = yield mutations_1.next(), _b = mutations_1_1.done, !_b; _a = true) {
  3031. _d = mutations_1_1.value;
  3032. _a = false;
  3033. const mut = _d;
  3034. const newTitle = mut.target.title;
  3035. if (newTitle !== currentSongTitle && newTitle.length > 0) {
  3036. const lyricsBtn = document.querySelector("#betterytm-lyrics-button");
  3037. if (!lyricsBtn)
  3038. continue;
  3039. info(`Song title changed from '${currentSongTitle}' to '${newTitle}'`);
  3040. lyricsBtn.style.cursor = "wait";
  3041. lyricsBtn.style.pointerEvents = "none";
  3042. const imgElem = lyricsBtn.querySelector("img");
  3043. imgElem.src = spinnerIconUrl;
  3044. imgElem.classList.add("bytm-spinner");
  3045. currentSongTitle = newTitle;
  3046. const url = yield getCurrentLyricsUrl(); // can take a second or two
  3047. imgElem.src = lyricsIconUrl;
  3048. imgElem.classList.remove("bytm-spinner");
  3049. if (!url) {
  3050. let artist, song;
  3051. if ("mediaSession" in navigator && navigator.mediaSession.metadata) {
  3052. artist = navigator.mediaSession.metadata.artist;
  3053. song = navigator.mediaSession.metadata.title;
  3054. }
  3055. const query = artist && song ? "?q=" + encodeURIComponent(sanitizeArtists(artist) + " - " + sanitizeSong(song)) : "";
  3056. imgElem.src = errorIconUrl;
  3057. imgElem.ariaLabel = imgElem.title = t("lyrics_not_found_click_open_search");
  3058. lyricsBtn.style.cursor = "pointer";
  3059. lyricsBtn.style.pointerEvents = "all";
  3060. lyricsBtn.style.display = "inline-flex";
  3061. lyricsBtn.style.visibility = "visible";
  3062. lyricsBtn.href = `https://genius.com/search${query}`;
  3063. continue;
  3064. }
  3065. lyricsBtn.href = url;
  3066. lyricsBtn.ariaLabel = lyricsBtn.title = t("open_current_lyrics");
  3067. lyricsBtn.style.cursor = "pointer";
  3068. lyricsBtn.style.visibility = "visible";
  3069. lyricsBtn.style.display = "inline-flex";
  3070. lyricsBtn.style.pointerEvents = "initial";
  3071. }
  3072. }
  3073. }
  3074. catch (e_1_1) { e_1 = { error: e_1_1 }; }
  3075. finally {
  3076. try {
  3077. if (!_a && !_b && (_c = mutations_1.return)) yield _c.call(mutations_1);
  3078. }
  3079. finally { if (e_1) throw e_1.error; }
  3080. }
  3081. }); };
  3082. // 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
  3083. const obs = new MutationObserver(onMutation);
  3084. obs.observe(songTitleElem, { attributes: true, attributeFilter: ["title"] });
  3085. });
  3086. }
  3087. //#MARKER utils
  3088. /** Removes everything in parentheses from the passed song name */
  3089. function sanitizeSong(songName) {
  3090. const parensRegex = /\(.+\)/gmi;
  3091. const squareParensRegex = /\[.+\]/gmi;
  3092. // trim right after the song name:
  3093. const sanitized = songName
  3094. .replace(parensRegex, "")
  3095. .replace(squareParensRegex, "");
  3096. return sanitized.trim();
  3097. }
  3098. /** Removes the secondary artist (if it exists) from the passed artists string */
  3099. function sanitizeArtists(artists) {
  3100. artists = artists.split(/\s*\u2022\s*/gmiu)[0]; // split at &bull; [•] character
  3101. if (artists.match(/&/))
  3102. artists = artists.split(/\s*&\s*/gm)[0];
  3103. if (artists.match(/,/))
  3104. artists = artists.split(/,\s*/gm)[0];
  3105. return artists.trim();
  3106. }
  3107. /** Returns the lyrics URL from genius for the currently selected song */
  3108. function getCurrentLyricsUrl() {
  3109. var _a;
  3110. return __awaiter(this, void 0, void 0, function* () {
  3111. try {
  3112. // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
  3113. const isVideo = typeof ((_a = document.querySelector("ytmusic-player")) === null || _a === void 0 ? void 0 : _a.hasAttribute("video-mode"));
  3114. const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
  3115. const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string :first-child");
  3116. if (!songTitleElem || !songMetaElem)
  3117. return undefined;
  3118. const songNameRaw = songTitleElem.title;
  3119. let songName = songNameRaw;
  3120. let artistName = songMetaElem.textContent;
  3121. if (isVideo) {
  3122. // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
  3123. if (songName.includes("-")) {
  3124. const split = splitVideoTitle(songName);
  3125. songName = split.song;
  3126. artistName = split.artist;
  3127. }
  3128. }
  3129. if (!artistName)
  3130. return undefined;
  3131. const url = yield fetchLyricsUrl(sanitizeArtists(artistName), sanitizeSong(songName));
  3132. if (url) {
  3133. emitInterface("bytm:lyricsLoaded", {
  3134. type: "current",
  3135. artists: artistName,
  3136. title: songName,
  3137. url,
  3138. });
  3139. }
  3140. return url;
  3141. }
  3142. catch (err) {
  3143. error("Couldn't resolve lyrics URL:", err);
  3144. return undefined;
  3145. }
  3146. });
  3147. }
  3148. /** Fetches the actual lyrics URL from geniURL - **the passed parameters need to be sanitized first!** */
  3149. function fetchLyricsUrl(artist, song) {
  3150. var _a, _b, _c;
  3151. return __awaiter(this, void 0, void 0, function* () {
  3152. try {
  3153. const cacheEntry = getLyricsCacheEntry(artist, song);
  3154. if (cacheEntry) {
  3155. info(`Found lyrics URL in cache: ${cacheEntry}`);
  3156. return cacheEntry;
  3157. }
  3158. const startTs = Date.now();
  3159. const fetchUrl = constructUrlString(geniURLSearchTopUrl, {
  3160. disableFuzzy: null,
  3161. utm_source: "BetterYTM",
  3162. utm_content: `v${scriptInfo.version}`,
  3163. artist,
  3164. song,
  3165. });
  3166. log(`Requesting URL from geniURL at '${fetchUrl}'`);
  3167. const fetchRes = yield fetchAdvanced(fetchUrl);
  3168. if (fetchRes.status === 429) {
  3169. const waitSeconds = Number((_a = fetchRes.headers.get("retry-after")) !== null && _a !== void 0 ? _a : geniUrlRatelimitTimeframe);
  3170. alert(tp("lyrics_rate_limited", waitSeconds, waitSeconds));
  3171. return undefined;
  3172. }
  3173. else if (fetchRes.status < 200 || fetchRes.status >= 300) {
  3174. error(`Couldn't fetch lyrics URL from geniURL - status: ${fetchRes.status} - response: ${(_c = (_b = (yield fetchRes.json()).message) !== null && _b !== void 0 ? _b : yield fetchRes.text()) !== null && _c !== void 0 ? _c : "(none)"}`);
  3175. return undefined;
  3176. }
  3177. const result = yield fetchRes.json();
  3178. if (typeof result === "object" && result.error) {
  3179. error("Couldn't fetch lyrics URL:", result.message);
  3180. return undefined;
  3181. }
  3182. const url = result.url;
  3183. info(`Found lyrics URL (after ${Date.now() - startTs}ms): ${url}`);
  3184. addLyricsCacheEntry(artist, song, url);
  3185. return url;
  3186. }
  3187. catch (err) {
  3188. error("Couldn't get lyrics URL due to error:", err);
  3189. return undefined;
  3190. }
  3191. });
  3192. }
  3193. /** Creates the base lyrics button element */
  3194. function createLyricsBtn(geniusUrl, hideIfLoading = true) {
  3195. return __awaiter(this, void 0, void 0, function* () {
  3196. const linkElem = document.createElement("a");
  3197. linkElem.className = "ytmusic-player-bar bytm-generic-btn";
  3198. linkElem.ariaLabel = linkElem.title = geniusUrl ? t("open_lyrics") : t("lyrics_loading");
  3199. if (geniusUrl)
  3200. linkElem.href = geniusUrl;
  3201. linkElem.role = "button";
  3202. linkElem.target = "_blank";
  3203. linkElem.rel = "noopener noreferrer";
  3204. linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden";
  3205. linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none";
  3206. const imgElem = document.createElement("img");
  3207. imgElem.className = "bytm-generic-btn-img";
  3208. imgElem.src = yield getResourceUrl("img-lyrics");
  3209. linkElem.appendChild(imgElem);
  3210. return linkElem;
  3211. });
  3212. }
  3213. /** Splits a video title that contains a hyphen into an artist and song */
  3214. function splitVideoTitle(title) {
  3215. const [artist, ...rest] = title.split("-").map((v, i) => i < 2 ? v.trim() : v);
  3216. return { artist, song: rest.join("-") };
  3217. }
  3218. let features;
  3219. function preInitSongLists(feats) {
  3220. features = feats;
  3221. }
  3222. /** Initializes the queue buttons */
  3223. function initQueueButtons() {
  3224. return __awaiter(this, void 0, void 0, function* () {
  3225. const addCurrentQueueBtns = (evt) => {
  3226. let amt = 0;
  3227. for (const queueItm of evt.childNodes) {
  3228. if (!queueItm.classList.contains("bytm-has-queue-btns")) {
  3229. addQueueButtons(queueItm, undefined, "currentQueue");
  3230. amt++;
  3231. }
  3232. }
  3233. if (amt > 0)
  3234. log(`Added buttons to ${amt} new queue ${autoPlural("item", amt)}`);
  3235. };
  3236. // current queue
  3237. siteEvents.on("queueChanged", addCurrentQueueBtns);
  3238. siteEvents.on("autoplayQueueChanged", addCurrentQueueBtns);
  3239. const queueItems = document.querySelectorAll("#contents.ytmusic-player-queue > ytmusic-player-queue-item");
  3240. if (queueItems.length > 0) {
  3241. queueItems.forEach(itm => addQueueButtons(itm, undefined, "currentQueue"));
  3242. log(`Added buttons to ${queueItems.length} existing "current song queue" ${autoPlural("item", queueItems)}`);
  3243. }
  3244. // generic lists
  3245. // TODO:FIXME: dragging the items around removes the queue buttons
  3246. const addGenericListQueueBtns = (listElem) => {
  3247. if (listElem.classList.contains("bytm-list-has-queue-btns"))
  3248. return;
  3249. const queueItems = listElem.querySelectorAll("ytmusic-responsive-list-item-renderer");
  3250. if (queueItems.length === 0)
  3251. return;
  3252. listElem.classList.add("bytm-list-has-queue-btns");
  3253. queueItems.forEach(itm => addQueueButtons(itm, ".flex-columns", "genericQueue", ["bytm-generic-list-queue-btn-container"]));
  3254. log(`Added buttons to ${queueItems.length} new "generic song list" ${autoPlural("item", queueItems)}`);
  3255. };
  3256. const listSelectors = [
  3257. "ytmusic-playlist-shelf-renderer #contents",
  3258. "ytmusic-section-list-renderer[main-page-type=\"MUSIC_PAGE_TYPE_ALBUM\"] ytmusic-shelf-renderer #contents",
  3259. "ytmusic-section-list-renderer[main-page-type=\"MUSIC_PAGE_TYPE_ARTIST\"] ytmusic-shelf-renderer #contents",
  3260. ];
  3261. if (features.listButtonsPlacement === "everywhere") {
  3262. for (const selector of listSelectors) {
  3263. onSelectorOld(selector, {
  3264. all: true,
  3265. continuous: true,
  3266. listener: (songLists) => {
  3267. for (const list of songLists)
  3268. addGenericListQueueBtns(list);
  3269. },
  3270. });
  3271. }
  3272. }
  3273. // TODO: support grids?
  3274. });
  3275. }
  3276. /**
  3277. * Adds the buttons to each item in the current song queue.
  3278. * Also observes for changes to add new buttons to new items in the queue.
  3279. * @param queueItem The element with tagname `ytmusic-player-queue-item` to add queue buttons to
  3280. * @param listType The type of list the queue item is in
  3281. * @param classes Extra CSS classes to apply to the container
  3282. */
  3283. function addQueueButtons(queueItem, containerParentSelector = ".song-info", listType = "currentQueue", classes = []) {
  3284. var _a;
  3285. return __awaiter(this, void 0, void 0, function* () {
  3286. //#SECTION general queue item stuff
  3287. const queueBtnsCont = document.createElement("div");
  3288. queueBtnsCont.classList.add("bytm-queue-btn-container", ...classes);
  3289. const lyricsIconUrl = yield getResourceUrl("img-lyrics");
  3290. const deleteIconUrl = yield getResourceUrl("img-delete");
  3291. //#SECTION lyrics btn
  3292. let lyricsBtnElem;
  3293. if (features.lyricsQueueButton) {
  3294. lyricsBtnElem = yield createLyricsBtn(undefined, false);
  3295. lyricsBtnElem.ariaLabel = lyricsBtnElem.title = t("open_lyrics");
  3296. lyricsBtnElem.style.display = "inline-flex";
  3297. lyricsBtnElem.style.visibility = "initial";
  3298. lyricsBtnElem.style.pointerEvents = "initial";
  3299. lyricsBtnElem.role = "link";
  3300. lyricsBtnElem.tabIndex = 0;
  3301. const lyricsBtnClicked = (e) => __awaiter(this, void 0, void 0, function* () {
  3302. e.preventDefault();
  3303. e.stopImmediatePropagation();
  3304. let song, artist;
  3305. if (listType === "currentQueue") {
  3306. const songInfo = queueItem.querySelector(".song-info");
  3307. if (!songInfo)
  3308. return;
  3309. const [songEl, artistEl] = songInfo.querySelectorAll("yt-formatted-string");
  3310. song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent;
  3311. artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
  3312. }
  3313. else if (listType === "genericQueue") {
  3314. const songEl = queueItem.querySelector(".title-column yt-formatted-string a");
  3315. let artistEl = null;
  3316. if (location.pathname.startsWith("/playlist"))
  3317. artistEl = document.querySelector("ytmusic-detail-header-renderer .metadata .subtitle-container yt-formatted-string a");
  3318. else
  3319. artistEl = queueItem.querySelector(".secondary-flex-columns yt-formatted-string:first-child a");
  3320. song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent;
  3321. artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
  3322. }
  3323. else
  3324. return;
  3325. if (!song || !artist)
  3326. return error("Couldn't get song or artist name from queue item - song:", song, "- artist:", artist);
  3327. let lyricsUrl;
  3328. const artistsSan = sanitizeArtists(artist);
  3329. const songSan = sanitizeSong(song);
  3330. const splitTitle = splitVideoTitle(songSan);
  3331. const cachedLyricsUrl = songSan.includes("-")
  3332. ? getLyricsCacheEntry(splitTitle.artist, splitTitle.song)
  3333. : getLyricsCacheEntry(artistsSan, songSan);
  3334. if (cachedLyricsUrl)
  3335. lyricsUrl = cachedLyricsUrl;
  3336. else if (!queueItem.hasAttribute("data-bytm-loading")) {
  3337. const imgEl = lyricsBtnElem === null || lyricsBtnElem === void 0 ? void 0 : lyricsBtnElem.querySelector("img");
  3338. if (!imgEl)
  3339. return;
  3340. if (!cachedLyricsUrl) {
  3341. queueItem.setAttribute("data-bytm-loading", "");
  3342. imgEl.src = yield getResourceUrl("img-spinner");
  3343. imgEl.classList.add("bytm-spinner");
  3344. }
  3345. lyricsUrl = cachedLyricsUrl !== null && cachedLyricsUrl !== void 0 ? cachedLyricsUrl : yield fetchLyricsUrl(artistsSan, songSan);
  3346. if (lyricsUrl) {
  3347. emitInterface("bytm:lyricsLoaded", {
  3348. type: "queue",
  3349. artists: artist,
  3350. title: song,
  3351. url: lyricsUrl,
  3352. });
  3353. }
  3354. const resetImgElem = () => {
  3355. imgEl.src = lyricsIconUrl;
  3356. imgEl.classList.remove("bytm-spinner");
  3357. };
  3358. if (!cachedLyricsUrl) {
  3359. queueItem.removeAttribute("data-bytm-loading");
  3360. // so the new image doesn't "blink"
  3361. setTimeout(resetImgElem, 100);
  3362. }
  3363. if (!lyricsUrl) {
  3364. resetImgElem();
  3365. if (confirm(t("lyrics_not_found_confirm_open_search")))
  3366. openInNewTab(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} - ${songSan}`)}`);
  3367. return;
  3368. }
  3369. }
  3370. lyricsUrl && openInNewTab(lyricsUrl);
  3371. });
  3372. lyricsBtnElem.addEventListener("click", lyricsBtnClicked);
  3373. lyricsBtnElem.addEventListener("keydown", (e) => e.key === "Enter" && lyricsBtnClicked(e));
  3374. }
  3375. //#SECTION delete from queue btn
  3376. let deleteBtnElem;
  3377. if (features.deleteFromQueueButton) {
  3378. deleteBtnElem = document.createElement("a");
  3379. deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("remove_from_queue") : t("delete_from_list"));
  3380. deleteBtnElem.classList.add("ytmusic-player-bar", "bytm-delete-from-queue", "bytm-generic-btn");
  3381. deleteBtnElem.role = "button";
  3382. deleteBtnElem.tabIndex = 0;
  3383. deleteBtnElem.style.visibility = "initial";
  3384. const imgElem = document.createElement("img");
  3385. imgElem.classList.add("bytm-generic-btn-img");
  3386. imgElem.src = deleteIconUrl;
  3387. const deleteBtnClicked = (e) => __awaiter(this, void 0, void 0, function* () {
  3388. e.preventDefault();
  3389. e.stopImmediatePropagation();
  3390. // container of the queue item popup menu - element gets reused for every queue item
  3391. let queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
  3392. try {
  3393. // three dots button to open the popup menu of a queue item
  3394. const dotsBtnElem = queueItem.querySelector("ytmusic-menu-renderer yt-button-shape[id=\"button-shape\"] button");
  3395. if (dotsBtnElem) {
  3396. if (queuePopupCont)
  3397. queuePopupCont.setAttribute("data-bytm-hidden", "true");
  3398. dotsBtnElem.click();
  3399. yield pauseFor(10);
  3400. queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
  3401. queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.setAttribute("data-bytm-hidden", "true");
  3402. // a little bit janky and unreliable but the only way afaik
  3403. const removeFromQueueBtn = queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.querySelector("tp-yt-paper-listbox ytmusic-menu-service-item-renderer:nth-of-type(3)");
  3404. yield pauseFor(10);
  3405. removeFromQueueBtn === null || removeFromQueueBtn === void 0 ? void 0 : removeFromQueueBtn.click();
  3406. // queue items aren't removed automatically outside of the current queue
  3407. if (removeFromQueueBtn && listType === "genericQueue") {
  3408. yield pauseFor(500);
  3409. clearInner(queueItem);
  3410. queueItem.remove();
  3411. }
  3412. if (!removeFromQueueBtn) {
  3413. warn("Couldn't find 'remove from queue' button in queue item three dots menu");
  3414. dotsBtnElem.click();
  3415. imgElem.src = yield getResourceUrl("img-error");
  3416. if (deleteBtnElem)
  3417. deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("couldnt_remove_from_queue") : t("couldnt_delete_from_list"));
  3418. }
  3419. }
  3420. }
  3421. catch (err) {
  3422. error("Couldn't remove song from queue due to error:", err);
  3423. }
  3424. finally {
  3425. queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.removeAttribute("data-bytm-hidden");
  3426. }
  3427. });
  3428. deleteBtnElem.addEventListener("click", deleteBtnClicked);
  3429. deleteBtnElem.addEventListener("keydown", (e) => e.key === "Enter" && deleteBtnClicked(e));
  3430. deleteBtnElem.appendChild(imgElem);
  3431. }
  3432. //#SECTION append elements to DOM
  3433. lyricsBtnElem && queueBtnsCont.appendChild(lyricsBtnElem);
  3434. deleteBtnElem && queueBtnsCont.appendChild(deleteBtnElem);
  3435. (_a = queueItem.querySelector(containerParentSelector)) === null || _a === void 0 ? void 0 : _a.appendChild(queueBtnsCont);
  3436. queueItem.classList.add("bytm-has-queue-btns");
  3437. });
  3438. }
  3439. let verNotifDialog = null;
  3440. /** Returns the dialog shown when a new version is available */
  3441. function getVersionNotifDialog({ latestTag, }) {
  3442. if (!verNotifDialog) {
  3443. verNotifDialog = new BytmDialog({
  3444. id: "version-notif",
  3445. closeOnBgClick: false,
  3446. closeOnEscPress: false,
  3447. destroyOnClose: true,
  3448. renderBody: () => renderBody(latestTag),
  3449. });
  3450. }
  3451. return verNotifDialog;
  3452. }
  3453. function renderBody(latestTag) {
  3454. const platformNames = {
  3455. github: "GitHub",
  3456. greasyfork: "GreasyFork",
  3457. openuserjs: "OpenUserJS",
  3458. };
  3459. // TODO:
  3460. const wrapperEl = document.createElement("div");
  3461. const pEl = document.createElement("p");
  3462. pEl.textContent = t("new_version_available", scriptInfo.name, scriptInfo.version, latestTag, platformNames[host]);
  3463. wrapperEl.appendChild(pEl);
  3464. const btnEl = document.createElement("button");
  3465. btnEl.className = "bytm-btn";
  3466. btnEl.textContent = t("update_now");
  3467. btnEl.addEventListener("click", () => window.open(pkg.updates[host]));
  3468. wrapperEl.appendChild(btnEl);
  3469. return wrapperEl;
  3470. }
  3471. const releaseURL = "https://github.com/Sv443/BetterYTM/releases/latest";
  3472. function checkVersion() {
  3473. var _a;
  3474. return __awaiter(this, void 0, void 0, function* () {
  3475. try {
  3476. if (getFeatures().versionCheck === false)
  3477. return info("Version check is disabled");
  3478. const lastCheck = yield GM.getValue("bytm-version-check", 0);
  3479. if (Date.now() - lastCheck < 1000 * 60 * 60 * 24)
  3480. return;
  3481. yield GM.setValue("bytm-version-check", Date.now());
  3482. const res = yield sendRequest({
  3483. method: "GET",
  3484. url: releaseURL,
  3485. });
  3486. const latestTag = (_a = res.finalUrl.split("/").pop()) === null || _a === void 0 ? void 0 : _a.replace(/[a-zA-Z]/g, "");
  3487. if (!latestTag)
  3488. return;
  3489. const versionComp = compareVersions(scriptInfo.version, latestTag);
  3490. info("Version check - current version:", scriptInfo.version, "- latest version:", latestTag);
  3491. if (versionComp < 0) {
  3492. const platformNames = {
  3493. github: "GitHub",
  3494. greasyfork: "GreasyFork",
  3495. openuserjs: "OpenUserJS",
  3496. };
  3497. const dialog = getVersionNotifDialog({ latestTag });
  3498. yield dialog.open();
  3499. // TODO: replace with custom dialog
  3500. if (confirm(t("new_version_available", scriptInfo.name, scriptInfo.version, latestTag, platformNames[host])))
  3501. window.open(pkg.updates[host]);
  3502. }
  3503. }
  3504. catch (err) {
  3505. error("Version check failed:", err);
  3506. }
  3507. });
  3508. }
  3509. /**
  3510. * Crudely compares two semver version strings.
  3511. * @returns Returns 1 if a > b or -1 if a < b or 0 if a == b
  3512. */
  3513. function compareVersions(a, b) {
  3514. const pa = a.split(".");
  3515. const pb = b.split(".");
  3516. for (let i = 0; i < 3; i++) {
  3517. const na = Number(pa[i]);
  3518. const nb = Number(pb[i]);
  3519. if (na > nb)
  3520. return 1;
  3521. if (nb > na)
  3522. return -1;
  3523. if (!isNaN(na) && isNaN(nb))
  3524. return 1;
  3525. if (isNaN(na) && !isNaN(nb))
  3526. return -1;
  3527. }
  3528. return 0;
  3529. }
  3530. //#MARKER feature dependencies
  3531. const localeOptions = Object.entries(locales).reduce((a, [locale, { name }]) => {
  3532. return [...a, {
  3533. value: locale,
  3534. label: name,
  3535. }];
  3536. }, [])
  3537. .sort((a, b) => a.label.localeCompare(b.label));
  3538. //#MARKER features
  3539. /**
  3540. * Contains all possible features with their default values and other configuration.
  3541. *
  3542. * **Required props:**
  3543. * | Property | Description |
  3544. * | :-- | :-- |
  3545. * | `type` | type of the feature configuration element - use autocomplete or check `FeatureTypeProps` in `src/types.ts` |
  3546. * | `category` | category of the feature - use autocomplete or check `FeatureCategory` in `src/types.ts` |
  3547. * | `default` | default value of the feature - type of the value depends on the given `type` |
  3548. * | `enable(value: any)` | function that will be called when the feature is enabled / initialized for the first time |
  3549. *
  3550. * **Optional props:**
  3551. * | Property | Description |
  3552. * | :-- | :-- |
  3553. * | `disable(newValue: any)` | for type `toggle` only - function that will be called when the feature is disabled - can be a synchronous or asynchronous function |
  3554. * | `change(prevValue: any, newValue: any)` | for types `number`, `select`, `slider` and `hotkey` only - function that will be called when the value is changed |
  3555. * | `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 |
  3556. * | `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 - TODO: to be replaced in the big menu rework |
  3557. * | `hidden` | if true, the feature will not be shown in the settings - default is undefined (false) |
  3558. * | `min` | Only if type is `number` or `slider` - Overwrites the default of the `min` property of the HTML input element |
  3559. * | `max` | Only if type is `number` or `slider` - Overwrites the default of the `max` property of the HTML input element |
  3560. * | `step` | Only if type is `number` or `slider` - Overwrites the default of the `step` property of the HTML input element |
  3561. * | `unit` | Only if type is `number` or `slider` - The unit text that is displayed next to the input element, i.e. "px" |
  3562. *
  3563. * **Notes:**
  3564. * - If no `disable()` or `change()` function is present, the page needs to be reloaded for the changes to take effect
  3565. */
  3566. const featInfo = {
  3567. //#SECTION layout
  3568. removeUpgradeTab: {
  3569. type: "toggle",
  3570. category: "layout",
  3571. default: true,
  3572. enable: () => void "TODO",
  3573. },
  3574. volumeSliderLabel: {
  3575. type: "toggle",
  3576. category: "layout",
  3577. default: true,
  3578. enable: () => void "TODO",
  3579. disable: () => void "TODO",
  3580. },
  3581. volumeSliderSize: {
  3582. type: "number",
  3583. category: "layout",
  3584. min: 50,
  3585. max: 500,
  3586. step: 5,
  3587. default: 150,
  3588. unit: "px",
  3589. enable: () => void "TODO",
  3590. change: () => void "TODO",
  3591. },
  3592. volumeSliderStep: {
  3593. type: "slider",
  3594. category: "layout",
  3595. min: 1,
  3596. max: 25,
  3597. default: 2,
  3598. unit: "%",
  3599. enable: () => void "TODO",
  3600. change: () => void "TODO",
  3601. },
  3602. volumeSliderScrollStep: {
  3603. type: "slider",
  3604. category: "layout",
  3605. min: 1,
  3606. max: 25,
  3607. default: 10,
  3608. unit: "%",
  3609. enable: () => void "TODO",
  3610. change: () => void "TODO",
  3611. },
  3612. watermarkEnabled: {
  3613. type: "toggle",
  3614. category: "layout",
  3615. default: true,
  3616. enable: () => void "TODO",
  3617. disable: () => void "TODO",
  3618. },
  3619. removeShareTrackingParam: {
  3620. type: "toggle",
  3621. category: "layout",
  3622. default: true,
  3623. enable: () => void "TODO",
  3624. disable: () => void "TODO",
  3625. },
  3626. fixSpacing: {
  3627. type: "toggle",
  3628. category: "layout",
  3629. default: true,
  3630. enable: () => void "TODO",
  3631. disable: () => void "TODO",
  3632. },
  3633. scrollToActiveSongBtn: {
  3634. type: "toggle",
  3635. category: "layout",
  3636. default: true,
  3637. enable: () => void "TODO",
  3638. disable: () => void "TODO",
  3639. },
  3640. //#SECTION song lists
  3641. lyricsQueueButton: {
  3642. type: "toggle",
  3643. category: "songLists",
  3644. default: true,
  3645. enable: () => void "TODO",
  3646. disable: () => void "TODO",
  3647. },
  3648. deleteFromQueueButton: {
  3649. type: "toggle",
  3650. category: "songLists",
  3651. default: true,
  3652. enable: () => void "TODO",
  3653. disable: () => void "TODO",
  3654. },
  3655. listButtonsPlacement: {
  3656. type: "select",
  3657. category: "songLists",
  3658. options: () => [
  3659. { value: "queueOnly", label: t("list_button_placement_queue_only") },
  3660. { value: "everywhere", label: t("list_button_placement_everywhere") },
  3661. ],
  3662. default: "everywhere",
  3663. enable: () => void "TODO",
  3664. disable: () => void "TODO",
  3665. },
  3666. //#SECTION behavior
  3667. disableBeforeUnloadPopup: {
  3668. type: "toggle",
  3669. category: "behavior",
  3670. default: false,
  3671. enable: () => void "TODO",
  3672. },
  3673. closeToastsTimeout: {
  3674. type: "number",
  3675. category: "behavior",
  3676. min: 0,
  3677. max: 30,
  3678. step: 0.5,
  3679. default: 0,
  3680. unit: "s",
  3681. enable: () => void "TODO",
  3682. change: () => void "TODO",
  3683. },
  3684. rememberSongTime: {
  3685. type: "toggle",
  3686. category: "behavior",
  3687. default: true,
  3688. enable: () => void "TODO",
  3689. disable: () => void "TODO", // TODO: feasible?
  3690. helpText: () => tp("feature_helptext_rememberSongTime", remSongMinPlayTime, remSongMinPlayTime)
  3691. },
  3692. rememberSongTimeSites: {
  3693. type: "select",
  3694. category: "behavior",
  3695. options: () => [
  3696. { value: "all", label: t("remember_song_time_sites_all") },
  3697. { value: "yt", label: t("remember_song_time_sites_yt") },
  3698. { value: "ytm", label: t("remember_song_time_sites_ytm") },
  3699. ],
  3700. default: "ytm",
  3701. enable: () => void "TODO",
  3702. change: () => void "TODO",
  3703. },
  3704. //#SECTION input
  3705. arrowKeySupport: {
  3706. type: "toggle",
  3707. category: "input",
  3708. default: true,
  3709. enable: () => void "TODO",
  3710. disable: () => void "TODO",
  3711. },
  3712. arrowKeySkipBy: {
  3713. type: "number",
  3714. category: "input",
  3715. min: 0.5,
  3716. max: 60,
  3717. step: 0.5,
  3718. default: 5,
  3719. enable: () => void "TODO",
  3720. change: () => void "TODO",
  3721. },
  3722. switchBetweenSites: {
  3723. type: "toggle",
  3724. category: "input",
  3725. default: true,
  3726. enable: () => void "TODO",
  3727. disable: () => void "TODO",
  3728. },
  3729. switchSitesHotkey: {
  3730. type: "hotkey",
  3731. category: "input",
  3732. default: {
  3733. code: "F9",
  3734. shift: false,
  3735. ctrl: false,
  3736. alt: false,
  3737. },
  3738. enable: () => void "TODO",
  3739. change: () => void "TODO",
  3740. },
  3741. anchorImprovements: {
  3742. type: "toggle",
  3743. category: "input",
  3744. default: true,
  3745. enable: () => void "TODO",
  3746. disable: () => void "TODO",
  3747. },
  3748. numKeysSkipToTime: {
  3749. type: "toggle",
  3750. category: "input",
  3751. default: true,
  3752. enable: () => void "TODO",
  3753. disable: () => void "TODO",
  3754. },
  3755. //#SECTION lyrics
  3756. geniusLyrics: {
  3757. type: "toggle",
  3758. category: "lyrics",
  3759. default: true,
  3760. enable: () => void "TODO",
  3761. disable: () => void "TODO",
  3762. },
  3763. //#SECTION general
  3764. locale: {
  3765. type: "select",
  3766. category: "general",
  3767. options: localeOptions,
  3768. default: getPreferredLocale(),
  3769. enable: () => void "TODO",
  3770. // TODO: to be reworked or removed in the big menu rework
  3771. textAdornment: () => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = yield resourceToHTMLString("img-globe")) !== null && _a !== void 0 ? _a : ""; }),
  3772. },
  3773. versionCheck: {
  3774. type: "toggle",
  3775. category: "general",
  3776. default: true,
  3777. enable: () => void "TODO",
  3778. disable: () => void "TODO",
  3779. },
  3780. logLevel: {
  3781. type: "select",
  3782. category: "general",
  3783. options: () => [
  3784. { value: 0, label: t("log_level_debug") },
  3785. { value: 1, label: t("log_level_info") },
  3786. ],
  3787. default: 1,
  3788. enable: () => void "TODO",
  3789. },
  3790. };
  3791. /** If this number is incremented, the features object data will be migrated to the new format */
  3792. const formatVersion = 4;
  3793. /** Config data format migration dictionary */
  3794. const migrations = {
  3795. // 1 -> 2
  3796. 2: (oldData) => {
  3797. const queueBtnsEnabled = Boolean(oldData.queueButtons);
  3798. delete oldData.queueButtons;
  3799. return Object.assign(Object.assign({}, oldData), { deleteFromQueueButton: queueBtnsEnabled, lyricsQueueButton: queueBtnsEnabled });
  3800. },
  3801. // 2 -> 3
  3802. 3: (oldData) => (Object.assign(Object.assign({}, oldData), { removeShareTrackingParam: getFeatureDefault("removeShareTrackingParam"), numKeysSkipToTime: getFeatureDefault("numKeysSkipToTime"), fixSpacing: getFeatureDefault("fixSpacing"), scrollToActiveSongBtn: getFeatureDefault("scrollToActiveSongBtn"), logLevel: getFeatureDefault("logLevel") })),
  3803. // 3 -> 4
  3804. 4: (oldData) => {
  3805. var _a, _b, _c, _d;
  3806. const oldSwitchSitesHotkey = oldData.switchSitesHotkey;
  3807. return Object.assign(Object.assign({}, oldData), { rememberSongTime: getFeatureDefault("rememberSongTime"), rememberSongTimeSites: getFeatureDefault("rememberSongTimeSites"), arrowKeySkipBy: 10, switchSitesHotkey: {
  3808. code: (_a = oldSwitchSitesHotkey.key) !== null && _a !== void 0 ? _a : "F9",
  3809. shift: Boolean((_b = oldSwitchSitesHotkey.shift) !== null && _b !== void 0 ? _b : false),
  3810. ctrl: Boolean((_c = oldSwitchSitesHotkey.ctrl) !== null && _c !== void 0 ? _c : false),
  3811. alt: Boolean((_d = oldSwitchSitesHotkey.meta) !== null && _d !== void 0 ? _d : false),
  3812. }, listButtonsPlacement: "queueOnly", volumeSliderScrollStep: getFeatureDefault("volumeSliderScrollStep"), locale: getFeatureDefault("locale"), versionCheck: getFeatureDefault("versionCheck") });
  3813. },
  3814. };
  3815. function getFeatureDefault(key) {
  3816. return featInfo[key].default;
  3817. }
  3818. const defaultConfig = Object.keys(featInfo)
  3819. .reduce((acc, key) => {
  3820. acc[key] = featInfo[key].default;
  3821. return acc;
  3822. }, {});
  3823. const cfgMgr = new ConfigManager({
  3824. id: "bytm-config",
  3825. formatVersion,
  3826. defaultConfig,
  3827. migrations,
  3828. });
  3829. /** Initializes the ConfigManager instance and loads persistent data into memory */
  3830. function initConfig() {
  3831. return __awaiter(this, void 0, void 0, function* () {
  3832. const oldFmtVer = Number(yield GM.getValue(`_uucfgver-${cfgMgr.id}`, NaN));
  3833. const data = yield cfgMgr.loadData();
  3834. log(`Initialized ConfigManager (format version = ${cfgMgr.formatVersion})`);
  3835. if (isNaN(oldFmtVer))
  3836. info("Config data initialized with default values");
  3837. else if (oldFmtVer !== cfgMgr.formatVersion)
  3838. info(`Config data migrated from version ${oldFmtVer} to ${cfgMgr.formatVersion}`);
  3839. return data;
  3840. });
  3841. }
  3842. /** Returns the current feature config from the in-memory cache */
  3843. function getFeatures() {
  3844. return cfgMgr.getData();
  3845. }
  3846. /** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */
  3847. function saveFeatures(featureConf) {
  3848. const res = cfgMgr.setData(featureConf);
  3849. emitSiteEvent("configChanged", cfgMgr.getData());
  3850. info("Saved new feature config:", featureConf);
  3851. return res;
  3852. }
  3853. /** Saves the default feature config synchronously to the in-memory cache and asynchronously to persistent storage */
  3854. function setDefaultFeatures() {
  3855. const res = cfgMgr.saveDefaultData();
  3856. emitSiteEvent("configChanged", cfgMgr.getData());
  3857. info("Reset feature config to its default values");
  3858. return res;
  3859. }
  3860. /** 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 */
  3861. function clearConfig() {
  3862. return __awaiter(this, void 0, void 0, function* () {
  3863. yield cfgMgr.deleteConfig();
  3864. info("Deleted config from persistent storage");
  3865. });
  3866. }
  3867. const { getUnsafeWindow } = UserUtils;
  3868. const globalFuncs = {
  3869. addSelectorListener,
  3870. getResourceUrl,
  3871. getSessionId,
  3872. getVideoTime,
  3873. setLocale,
  3874. getLocale,
  3875. hasKey,
  3876. hasKeyFor,
  3877. t,
  3878. tp,
  3879. getFeatures,
  3880. saveFeatures,
  3881. fetchLyricsUrl,
  3882. getLyricsCacheEntry,
  3883. sanitizeArtists,
  3884. sanitizeSong,
  3885. };
  3886. /** Initializes the BYTM interface */
  3887. function initInterface() {
  3888. const props = Object.assign(Object.assign(Object.assign({ mode,
  3889. branch }, scriptInfo), globalFuncs), { UserUtils });
  3890. for (const [key, value] of Object.entries(props))
  3891. setGlobalProp(key, value);
  3892. log("Initialized BYTM interface");
  3893. }
  3894. /** Sets a global property on the window.BYTM object */
  3895. function setGlobalProp(key, value) {
  3896. // use unsafeWindow so the properties are available outside of the userscript's scope
  3897. const win = getUnsafeWindow();
  3898. if (!win.BYTM)
  3899. win.BYTM = {};
  3900. win.BYTM[key] = value;
  3901. }
  3902. /** Emits an event on the BYTM interface */
  3903. function emitInterface(type, ...data) {
  3904. getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: data[0] }));
  3905. }
  3906. const fetchOpts = {
  3907. timeout: 10000,
  3908. };
  3909. /** Contains all translation keys of all initialized and loaded translations */
  3910. const allTrKeys = new Map();
  3911. /** Contains the identifiers of all initialized and loaded translation locales */
  3912. const initializedLocales = new Set();
  3913. /** Initializes the translations */
  3914. function initTranslations(locale) {
  3915. var _a;
  3916. return __awaiter(this, void 0, void 0, function* () {
  3917. if (initializedLocales.has(locale))
  3918. return;
  3919. initializedLocales.add(locale);
  3920. try {
  3921. const transUrl = yield getResourceUrl(`trans-${locale}`);
  3922. const transFile = yield (yield fetchAdvanced(transUrl, fetchOpts)).json();
  3923. // merge with base translations if specified
  3924. const baseTransUrl = transFile.base ? yield getResourceUrl(`trans-${transFile.base}`) : undefined;
  3925. const baseTransFile = baseTransUrl ? yield (yield fetchAdvanced(baseTransUrl, fetchOpts)).json() : undefined;
  3926. const translations = Object.assign(Object.assign({}, ((_a = baseTransFile === null || baseTransFile === void 0 ? void 0 : baseTransFile.translations) !== null && _a !== void 0 ? _a : {})), transFile.translations);
  3927. tr.addLanguage(locale, translations);
  3928. allTrKeys.set(locale, new Set(Object.keys(translations)));
  3929. info(`Loaded translations for locale '${locale}'`);
  3930. }
  3931. catch (err) {
  3932. const errStr = `Couldn't load translations for locale '${locale}'`;
  3933. error(errStr, err);
  3934. throw new Error(errStr);
  3935. }
  3936. });
  3937. }
  3938. /** Sets the current language for translations */
  3939. function setLocale(locale) {
  3940. tr.setLanguage(locale);
  3941. setGlobalProp("locale", locale);
  3942. emitInterface("bytm:setLocale", { locale });
  3943. }
  3944. /** Returns the currently set language */
  3945. function getLocale() {
  3946. return tr.getLanguage();
  3947. }
  3948. /** Returns whether the given translation key exists in the current locale */
  3949. function hasKey(key) {
  3950. return hasKeyFor(getLocale(), key);
  3951. }
  3952. /** Returns whether the given translation key exists in the given locale */
  3953. function hasKeyFor(locale, key) {
  3954. var _a, _b;
  3955. return (_b = (_a = allTrKeys.get(locale)) === null || _a === void 0 ? void 0 : _a.has(key)) !== null && _b !== void 0 ? _b : false;
  3956. }
  3957. /** Returns the translated string for the given key, after optionally inserting values */
  3958. function t(key, ...values) {
  3959. return tr(key, ...values);
  3960. }
  3961. /**
  3962. * Returns the translated string for the given key with an added pluralization identifier based on the passed `num`
  3963. * Tries to fall back to the non-pluralized syntax if no translation was found
  3964. */
  3965. function tp(key, num, ...values) {
  3966. if (typeof num !== "number")
  3967. num = num.length;
  3968. const plNum = num === 1 ? "1" : "n";
  3969. const trans = t(`${key}-${plNum}`, ...values);
  3970. if (trans === key)
  3971. return t(key, ...values);
  3972. return trans;
  3973. }
  3974. /** ID of the last opened (top-most) menu */
  3975. let lastMenuId = null;
  3976. /** Creates and manages a modal menu element */
  3977. class BytmDialog extends NanoEmitter {
  3978. constructor(options) {
  3979. super();
  3980. Object.defineProperty(this, "options", {
  3981. enumerable: true,
  3982. configurable: true,
  3983. writable: true,
  3984. value: void 0
  3985. });
  3986. Object.defineProperty(this, "id", {
  3987. enumerable: true,
  3988. configurable: true,
  3989. writable: true,
  3990. value: void 0
  3991. });
  3992. Object.defineProperty(this, "menuOpen", {
  3993. enumerable: true,
  3994. configurable: true,
  3995. writable: true,
  3996. value: false
  3997. });
  3998. Object.defineProperty(this, "menuRendered", {
  3999. enumerable: true,
  4000. configurable: true,
  4001. writable: true,
  4002. value: false
  4003. });
  4004. Object.defineProperty(this, "listenersAttached", {
  4005. enumerable: true,
  4006. configurable: true,
  4007. writable: true,
  4008. value: false
  4009. });
  4010. this.options = Object.assign({ closeOnBgClick: true, closeOnEscPress: true, closeBtnEnabled: true, destroyOnClose: false }, options);
  4011. this.id = options.id;
  4012. }
  4013. /** Call after DOMContentLoaded to pre-render the menu (or call just before calling open()) */
  4014. render() {
  4015. return __awaiter(this, void 0, void 0, function* () {
  4016. if (this.menuRendered)
  4017. return;
  4018. this.menuRendered = true;
  4019. const bgElem = document.createElement("div");
  4020. bgElem.id = `bytm-${this.id}-menu-bg`;
  4021. bgElem.classList.add("bytm-menu-bg");
  4022. if (this.options.closeOnBgClick)
  4023. bgElem.ariaLabel = bgElem.title = t("close_menu_tooltip");
  4024. bgElem.style.visibility = "hidden";
  4025. bgElem.style.display = "none";
  4026. bgElem.inert = true;
  4027. bgElem.appendChild(yield this.getMenuContent());
  4028. document.body.appendChild(bgElem);
  4029. this.attachListeners(bgElem);
  4030. this.events.emit("render");
  4031. });
  4032. }
  4033. /** Clears all menu contents (unmounts them from the DOM) in preparation for a new rendering call */
  4034. unmount() {
  4035. var _a;
  4036. this.menuRendered = false;
  4037. const clearSelectors = [
  4038. `#bytm-${this.id}-menu-bg`,
  4039. ];
  4040. for (const selector of clearSelectors) {
  4041. const elem = document.querySelector(selector);
  4042. if (!elem)
  4043. continue;
  4044. clearInner(elem);
  4045. }
  4046. (_a = document.querySelector(`#bytm-${this.id}-menu-bg`)) === null || _a === void 0 ? void 0 : _a.remove();
  4047. this.events.emit("clear");
  4048. }
  4049. /** Clears and then re-renders the menu */
  4050. rerender() {
  4051. return __awaiter(this, void 0, void 0, function* () {
  4052. this.unmount();
  4053. yield this.render();
  4054. });
  4055. }
  4056. /**
  4057. * Opens the menu - renders it if it hasn't been rendered yet
  4058. * Prevents default action and immediate propagation of the passed event
  4059. */
  4060. open(e) {
  4061. var _a;
  4062. return __awaiter(this, void 0, void 0, function* () {
  4063. e === null || e === void 0 ? void 0 : e.preventDefault();
  4064. e === null || e === void 0 ? void 0 : e.stopImmediatePropagation();
  4065. if (this.isOpen())
  4066. return;
  4067. this.menuOpen = true;
  4068. if (!this.isRendered())
  4069. yield this.render();
  4070. document.body.classList.add("bytm-disable-scroll");
  4071. (_a = document.querySelector("ytmusic-app")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true");
  4072. const menuBg = document.querySelector(`#bytm-${this.id}-menu-bg`);
  4073. if (!menuBg)
  4074. return warn(`Couldn't find background element for menu with ID '${this.id}'`);
  4075. menuBg.style.visibility = "visible";
  4076. menuBg.style.display = "block";
  4077. menuBg.inert = false;
  4078. lastMenuId = this.id;
  4079. this.events.emit("open");
  4080. });
  4081. }
  4082. /** Closes the menu - prevents default action and immediate propagation of the passed event */
  4083. close(e) {
  4084. var _a;
  4085. e === null || e === void 0 ? void 0 : e.preventDefault();
  4086. e === null || e === void 0 ? void 0 : e.stopImmediatePropagation();
  4087. if (!this.isOpen())
  4088. return;
  4089. this.menuOpen = false;
  4090. document.body.classList.remove("bytm-disable-scroll");
  4091. (_a = document.querySelector("ytmusic-app")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert");
  4092. const menuBg = document.querySelector(`#bytm-${this.id}-menu-bg`);
  4093. if (!menuBg)
  4094. return warn(`Couldn't find background element for menu with ID '${this.id}'`);
  4095. menuBg.style.visibility = "hidden";
  4096. menuBg.style.display = "none";
  4097. menuBg.inert = true;
  4098. if (BytmDialog.getLastMenuId() === this.id)
  4099. lastMenuId = null;
  4100. this.events.emit("close");
  4101. if (this.options.destroyOnClose)
  4102. this.destroy();
  4103. }
  4104. /** Returns true if the menu is open */
  4105. isOpen() {
  4106. return this.menuOpen;
  4107. }
  4108. /** Returns true if the menu has been rendered */
  4109. isRendered() {
  4110. return this.menuRendered;
  4111. }
  4112. /** Clears the menu and removes all event listeners */
  4113. destroy() {
  4114. this.events.emit("destroy");
  4115. this.unmount();
  4116. this.unsubscribeAll();
  4117. }
  4118. /** Returns the ID of the top-most menu (the menu that has been opened last) */
  4119. static getLastMenuId() {
  4120. return lastMenuId;
  4121. }
  4122. /** Called once to attach all generic event listeners */
  4123. attachListeners(bgElem) {
  4124. if (this.listenersAttached)
  4125. return;
  4126. this.listenersAttached = true;
  4127. if (this.options.closeOnBgClick) {
  4128. bgElem.addEventListener("click", (e) => {
  4129. var _a;
  4130. if (this.isOpen() && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === `bytm-${this.id}-menu-bg`)
  4131. this.close(e);
  4132. });
  4133. }
  4134. if (this.options.closeOnEscPress) {
  4135. document.body.addEventListener("keydown", (e) => {
  4136. if (e.key === "Escape" && this.isOpen() && BytmDialog.getLastMenuId() === this.id)
  4137. this.close(e);
  4138. });
  4139. }
  4140. }
  4141. getMenuContent() {
  4142. var _a, _b, _c, _d;
  4143. return __awaiter(this, void 0, void 0, function* () {
  4144. const header = (_b = (_a = this.options).renderHeader) === null || _b === void 0 ? void 0 : _b.call(_a);
  4145. const footer = (_d = (_c = this.options).renderFooter) === null || _d === void 0 ? void 0 : _d.call(_c);
  4146. const menuWrapperEl = document.createElement("div");
  4147. menuWrapperEl.id = `bytm-${this.id}-menu`;
  4148. menuWrapperEl.classList.add("bytm-menu");
  4149. menuWrapperEl.ariaLabel = menuWrapperEl.title = "";
  4150. //#SECTION header
  4151. const headerWrapperEl = document.createElement("div");
  4152. headerWrapperEl.classList.add("bytm-menu-header");
  4153. if (header) {
  4154. const headerTitleWrapperEl = document.createElement("div");
  4155. headerTitleWrapperEl.classList.add("bytm-menu-title-wrapper");
  4156. headerTitleWrapperEl.role = "heading";
  4157. headerTitleWrapperEl.ariaLevel = "1";
  4158. headerTitleWrapperEl.appendChild(header);
  4159. headerWrapperEl.appendChild(headerTitleWrapperEl);
  4160. }
  4161. if (this.options.closeBtnEnabled) {
  4162. const closeBtnEl = document.createElement("img");
  4163. closeBtnEl.classList.add("bytm-menu-close");
  4164. closeBtnEl.src = yield getResourceUrl("img-close");
  4165. closeBtnEl.role = "button";
  4166. closeBtnEl.tabIndex = 0;
  4167. closeBtnEl.addEventListener("click", () => this.close());
  4168. headerWrapperEl.appendChild(closeBtnEl);
  4169. }
  4170. menuWrapperEl.appendChild(headerWrapperEl);
  4171. // TODO:
  4172. //#SECTION body
  4173. const bodyWrapperEl = document.createElement("div");
  4174. bodyWrapperEl.appendChild(this.options.renderBody());
  4175. menuWrapperEl.appendChild(bodyWrapperEl);
  4176. //#SECTION footer
  4177. if (footer) {
  4178. menuWrapperEl.appendChild(footer);
  4179. }
  4180. return menuWrapperEl;
  4181. });
  4182. }
  4183. }
  4184. //#SECTION video time
  4185. const videoSelector = getDomain() === "ytm" ? "ytmusic-player video" : "#content ytd-player video";
  4186. /**
  4187. * Returns the current video time in seconds
  4188. * 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)
  4189. * @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
  4190. */
  4191. function getVideoTime() {
  4192. return new Promise((res) => {
  4193. const domain = getDomain();
  4194. try {
  4195. if (domain === "ytm") {
  4196. const vidElem = document.querySelector(videoSelector);
  4197. if (vidElem)
  4198. return res(Math.floor(vidElem.currentTime));
  4199. onSelectorOld("tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar", {
  4200. listener: (pbEl) => res(!isNaN(Number(pbEl.value)) ? Math.floor(Number(pbEl.value)) : null)
  4201. });
  4202. }
  4203. else if (domain === "yt") {
  4204. const vidElem = document.querySelector(videoSelector);
  4205. if (vidElem)
  4206. return res(Math.floor(vidElem.currentTime));
  4207. // YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it)
  4208. ytForceShowVideoTime();
  4209. const pbSelector = ".ytp-chrome-bottom div.ytp-progress-bar[role=\"slider\"]";
  4210. let videoTime = -1;
  4211. const mut = new MutationObserver(() => {
  4212. // .observe() is only called when the element exists - no need to check for null
  4213. videoTime = Number(document.querySelector(pbSelector).getAttribute("aria-valuenow"));
  4214. });
  4215. const observe = (progElem) => {
  4216. mut.observe(progElem, {
  4217. attributes: true,
  4218. attributeFilter: ["aria-valuenow"],
  4219. });
  4220. if (videoTime >= 0 && !isNaN(videoTime)) {
  4221. res(Math.floor(videoTime));
  4222. mut.disconnect();
  4223. }
  4224. else
  4225. setTimeout(() => {
  4226. res(videoTime >= 0 && !isNaN(videoTime) ? Math.floor(videoTime) : null);
  4227. mut.disconnect();
  4228. }, 500);
  4229. };
  4230. onSelectorOld(pbSelector, { listener: observe });
  4231. }
  4232. }
  4233. catch (err) {
  4234. error("Couldn't get video time due to error:", err);
  4235. res(null);
  4236. }
  4237. });
  4238. }
  4239. /**
  4240. * Sends events that force the video controls to become visible for about 3 seconds.
  4241. * This only works once (for some reason), then the page needs to be reloaded!
  4242. */
  4243. function ytForceShowVideoTime() {
  4244. const player = document.querySelector("#movie_player");
  4245. if (!player)
  4246. return false;
  4247. const defaultProps = {
  4248. // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
  4249. view: getUnsafeWindow$1(),
  4250. bubbles: true,
  4251. cancelable: false,
  4252. };
  4253. player.dispatchEvent(new MouseEvent("mouseenter", defaultProps));
  4254. const { x, y, width, height } = player.getBoundingClientRect();
  4255. const screenY = Math.round(y + height / 2);
  4256. const screenX = x + Math.min(50, Math.round(width / 3));
  4257. player.dispatchEvent(new MouseEvent("mousemove", Object.assign(Object.assign({}, defaultProps), { screenY,
  4258. screenX, movementX: 5, movementY: 0 })));
  4259. return true;
  4260. }
  4261. /** Removes all child nodes of an element without invoking the slow-ish HTML parser */
  4262. function clearInner(element) {
  4263. while (element.hasChildNodes())
  4264. clearNode(element.firstChild);
  4265. }
  4266. function clearNode(element) {
  4267. while (element.hasChildNodes())
  4268. clearNode(element.firstChild);
  4269. element.parentNode.removeChild(element);
  4270. }
  4271. let curLogLevel = LogLevel.Info;
  4272. /** Common prefix to be able to tell logged messages apart and filter them in devtools */
  4273. const consPrefix = `[${scriptInfo.name}]`;
  4274. `[${scriptInfo.name}/#DEBUG]`;
  4275. /** Sets the current log level. 0 = Debug, 1 = Info */
  4276. function setLogLevel(level) {
  4277. if (curLogLevel !== level)
  4278. console.log(consPrefix, "Setting log level to", level === 0 ? "Debug" : "Info");
  4279. curLogLevel = level;
  4280. setGlobalProp("logLevel", level);
  4281. }
  4282. /** 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 */
  4283. function getLogLevel(args) {
  4284. const minLogLvl = 0, maxLogLvl = 1;
  4285. if (typeof args.at(-1) === "number")
  4286. return clamp(args.splice(args.length - 1)[0], minLogLvl, maxLogLvl);
  4287. return LogLevel.Debug;
  4288. }
  4289. /**
  4290. * Logs all passed values to the console, as long as the log level is sufficient.
  4291. * @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 they shouldn't be.
  4292. */
  4293. function log(...args) {
  4294. if (curLogLevel <= getLogLevel(args))
  4295. console.log(consPrefix, ...args);
  4296. }
  4297. /**
  4298. * Logs all passed values to the console as info, as long as the log level is sufficient.
  4299. * @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 they shouldn't be.
  4300. */
  4301. function info(...args) {
  4302. if (curLogLevel <= getLogLevel(args))
  4303. console.info(consPrefix, ...args);
  4304. }
  4305. /** Logs all passed values to the console as a warning, no matter the log level. */
  4306. function warn(...args) {
  4307. console.warn(consPrefix, ...args);
  4308. }
  4309. /** Logs all passed values to the console as an error, no matter the log level. */
  4310. function error(...args) {
  4311. console.error(consPrefix, ...args);
  4312. }
  4313. //#SECTION misc
  4314. /**
  4315. * Returns the current domain as a constant string representation
  4316. * @throws Throws if script runs on an unexpected website
  4317. */
  4318. function getDomain() {
  4319. if (location.hostname.match(/^music\.youtube/))
  4320. return "ytm";
  4321. else if (location.hostname.match(/youtube\./))
  4322. return "yt";
  4323. else
  4324. throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
  4325. }
  4326. /** Returns a pseudo-random ID unique to each session */
  4327. function getSessionId() {
  4328. let sesId = window.sessionStorage.getItem("_bytm-session-id");
  4329. if (!sesId)
  4330. window.sessionStorage.setItem("_bytm-session-id", sesId = randomId(8, 36));
  4331. return sesId;
  4332. }
  4333. //#SECTION resources
  4334. /**
  4335. * Returns the URL of a resource by its name, as defined in `assets/resources.json`, from GM resource cache - [see GM.getResourceUrl docs](https://wiki.greasespot.net/GM.getResourceUrl)
  4336. * Falls back to a `raw.githubusercontent.com` URL or base64-encoded data URI if the resource is not available in the GM resource cache
  4337. */
  4338. function getResourceUrl(name) {
  4339. var _a;
  4340. return __awaiter(this, void 0, void 0, function* () {
  4341. let url = yield GM.getResourceUrl(name);
  4342. if (!url || url.length === 0) {
  4343. const resource = (_a = GM.info.script.resources) === null || _a === void 0 ? void 0 : _a[name].url;
  4344. if (typeof resource === "string") {
  4345. const resourceUrl = new URL(resource);
  4346. const resourcePath = resourceUrl.pathname;
  4347. if (resourcePath)
  4348. return `https://raw.githubusercontent.com/${repo}/${branch}${resourcePath}`;
  4349. }
  4350. warn(`Couldn't get blob URL nor external URL for @resource '${name}', trying to use base64-encoded fallback`);
  4351. // @ts-ignore
  4352. url = yield GM.getResourceUrl(name, false);
  4353. }
  4354. return url;
  4355. });
  4356. }
  4357. /**
  4358. * Returns the preferred locale of the user, provided it is supported by the userscript.
  4359. * Prioritizes `navigator.language`, then `navigator.languages`, then `"en_US"` as a fallback.
  4360. */
  4361. function getPreferredLocale() {
  4362. var _a;
  4363. const navLang = navigator.language.replace(/-/g, "_");
  4364. const navLangs = navigator.languages
  4365. .filter(lang => lang.match(/^[a-z]{2}(-|_)[A-Z]$/) !== null)
  4366. .map(lang => lang.replace(/-/g, "_"));
  4367. if (Object.entries(locales).find(([key]) => key === navLang))
  4368. return navLang;
  4369. for (const loc of navLangs) {
  4370. if (Object.entries(locales).find(([key]) => key === loc))
  4371. return loc;
  4372. }
  4373. // if navigator.languages has entries that aren't locale codes in the format xx_XX
  4374. if (navigator.languages.some(lang => lang.match(/^[a-z]{2}$/))) {
  4375. for (const lang of navLangs) {
  4376. const foundLoc = (_a = Object.entries(locales).find(([key]) => key.startsWith(lang))) === null || _a === void 0 ? void 0 : _a[0];
  4377. if (foundLoc)
  4378. return foundLoc;
  4379. }
  4380. }
  4381. return "en_US";
  4382. }
  4383. /** Returns the content behind the passed resource identifier to be assigned to an element's innerHTML property */
  4384. function resourceToHTMLString(resource) {
  4385. return __awaiter(this, void 0, void 0, function* () {
  4386. try {
  4387. const resourceUrl = yield getResourceUrl(resource);
  4388. if (!resourceUrl)
  4389. throw new Error(`Couldn't find URL for resource '${resource}'`);
  4390. return yield (yield fetchAdvanced(resourceUrl)).text();
  4391. }
  4392. catch (err) {
  4393. error("Couldn't get SVG element from resource:", err);
  4394. return null;
  4395. }
  4396. });
  4397. }
  4398. const selectorMap = new Map();
  4399. /**
  4400. * Calls the {@linkcode listener} as soon as the {@linkcode selector} exists in the DOM.
  4401. * Listeners are deleted when they are called once, unless `options.continuous` is set.
  4402. * Multiple listeners with the same selector may be registered.
  4403. * @param selector The selector to listen for
  4404. * @param options Used for switching to `querySelectorAll()` and for calling the listener continuously
  4405. * @template TElem The type of element that the listener will return as its argument (defaults to the generic type HTMLElement)
  4406. * @deprecated To be replaced with UserUtils' SelectorObserver class
  4407. */
  4408. function onSelectorOld(selector, options) {
  4409. let selectorMapItems = [];
  4410. if (selectorMap.has(selector))
  4411. selectorMapItems = selectorMap.get(selector);
  4412. // I don't feel like dealing with intersecting types, this should work just fine at runtime
  4413. // @ts-ignore
  4414. selectorMapItems.push(options);
  4415. selectorMap.set(selector, selectorMapItems);
  4416. checkSelectorExists(selector, selectorMapItems);
  4417. }
  4418. function checkSelectorExists(selector, options) {
  4419. const deleteIndices = [];
  4420. options.forEach((option, i) => {
  4421. try {
  4422. const elements = option.all ? document.querySelectorAll(selector) : document.querySelector(selector);
  4423. if ((elements !== null && elements instanceof NodeList && elements.length > 0) || elements !== null) {
  4424. // I don't feel like dealing with intersecting types, this should work just fine at runtime
  4425. // @ts-ignore
  4426. option.listener(elements);
  4427. if (!option.continuous)
  4428. deleteIndices.push(i);
  4429. }
  4430. }
  4431. catch (err) {
  4432. console.error(`Couldn't call listener for selector '${selector}'`, err);
  4433. }
  4434. });
  4435. if (deleteIndices.length > 0) {
  4436. const newOptsArray = options.filter((_, i) => !deleteIndices.includes(i));
  4437. if (newOptsArray.length === 0)
  4438. selectorMap.delete(selector);
  4439. else {
  4440. // once again laziness strikes
  4441. // @ts-ignore
  4442. selectorMap.set(selector, newOptsArray);
  4443. }
  4444. }
  4445. }
  4446. /**
  4447. * Initializes a MutationObserver that checks for all registered selectors whenever an element is added to or removed from the `<body>`
  4448. * @param options For fine-tuning what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
  4449. */
  4450. function initOnSelector(options = {}) {
  4451. const observer = new MutationObserver(() => {
  4452. for (const [selector, options] of selectorMap.entries())
  4453. checkSelectorExists(selector, options);
  4454. });
  4455. observer.observe(document.body, Object.assign({ subtree: true, childList: true }, options));
  4456. }
  4457. /**
  4458. * Constructs a URL from a base URL and a record of query parameters.
  4459. * If a value is null, the parameter will be valueless.
  4460. * All values will be stringified using their `toString()` method and then URI-encoded.
  4461. * @returns Returns a string instead of a URL object
  4462. */
  4463. function constructUrlString(baseUrl, params) {
  4464. return `${baseUrl}?${Object.entries(params).map(([key, val]) => `${key}${val === null ? "" : `=${encodeURIComponent(String(val))}`}`).join("&")}`;
  4465. }
  4466. /**
  4467. * Sends a request with the specified parameters and returns the response as a Promise.
  4468. * Ignores the CORS policy, contrary to fetch and fetchAdvanced.
  4469. */
  4470. function sendRequest(details) {
  4471. return new Promise((resolve, reject) => {
  4472. GM.xmlHttpRequest(Object.assign(Object.assign({}, details), { onload: resolve, onerror: reject, ontimeout: reject, onabort: reject }));
  4473. });
  4474. }
  4475. //#MARKER menu
  4476. let isWelcomeMenuOpen = false;
  4477. /** Adds the welcome menu to the DOM */
  4478. function addWelcomeMenu() {
  4479. return __awaiter(this, void 0, void 0, function* () {
  4480. //#SECTION backdrop & menu container
  4481. const backgroundElem = document.createElement("div");
  4482. backgroundElem.id = "bytm-welcome-menu-bg";
  4483. backgroundElem.classList.add("bytm-menu-bg");
  4484. backgroundElem.style.visibility = "hidden";
  4485. backgroundElem.style.display = "none";
  4486. const menuContainer = document.createElement("div");
  4487. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  4488. menuContainer.classList.add("bytm-menu");
  4489. menuContainer.id = "bytm-welcome-menu";
  4490. //#SECTION title bar
  4491. const headerElem = document.createElement("div");
  4492. headerElem.classList.add("bytm-menu-header");
  4493. const titleWrapperElem = document.createElement("div");
  4494. titleWrapperElem.id = "bytm-welcome-menu-title-wrapper";
  4495. const titleLogoElem = document.createElement("img");
  4496. titleLogoElem.id = "bytm-welcome-menu-title-logo";
  4497. titleLogoElem.classList.add("bytm-no-select");
  4498. titleLogoElem.src = yield getResourceUrl("img-logo");
  4499. const titleElem = document.createElement("h2");
  4500. titleElem.id = "bytm-welcome-menu-title";
  4501. titleElem.className = "bytm-menu-title";
  4502. titleElem.role = "heading";
  4503. titleElem.ariaLevel = "1";
  4504. titleWrapperElem.appendChild(titleLogoElem);
  4505. titleWrapperElem.appendChild(titleElem);
  4506. headerElem.appendChild(titleWrapperElem);
  4507. //#SECTION footer
  4508. const footerCont = document.createElement("div");
  4509. footerCont.id = "bytm-welcome-menu-footer-cont";
  4510. footerCont.className = "bytm-menu-footer-cont";
  4511. const openCfgElem = document.createElement("button");
  4512. openCfgElem.id = "bytm-welcome-menu-open-cfg";
  4513. openCfgElem.classList.add("bytm-btn");
  4514. openCfgElem.addEventListener("click", () => {
  4515. closeWelcomeMenu();
  4516. openCfgMenu();
  4517. });
  4518. const openChangelogElem = document.createElement("button");
  4519. openChangelogElem.id = "bytm-welcome-menu-open-changelog";
  4520. openChangelogElem.classList.add("bytm-btn");
  4521. openChangelogElem.addEventListener("click", () => __awaiter(this, void 0, void 0, function* () {
  4522. closeWelcomeMenu();
  4523. yield addCfgMenu();
  4524. openChangelogMenu("exit");
  4525. }));
  4526. const closeBtnElem = document.createElement("button");
  4527. closeBtnElem.id = "bytm-welcome-menu-footer-close";
  4528. closeBtnElem.classList.add("bytm-btn");
  4529. closeBtnElem.addEventListener("click", () => __awaiter(this, void 0, void 0, function* () {
  4530. closeWelcomeMenu();
  4531. }));
  4532. const leftButtonsCont = document.createElement("div");
  4533. leftButtonsCont.id = "bytm-menu-footer-left-buttons-cont";
  4534. leftButtonsCont.appendChild(openCfgElem);
  4535. leftButtonsCont.appendChild(openChangelogElem);
  4536. footerCont.appendChild(leftButtonsCont);
  4537. footerCont.appendChild(closeBtnElem);
  4538. //#SECTION content
  4539. const contentWrapper = document.createElement("div");
  4540. contentWrapper.id = "bytm-welcome-menu-content-wrapper";
  4541. // locale switcher
  4542. const localeCont = document.createElement("div");
  4543. localeCont.id = "bytm-welcome-menu-locale-cont";
  4544. const localeImg = document.createElement("img");
  4545. localeImg.id = "bytm-welcome-menu-locale-img";
  4546. localeImg.classList.add("bytm-no-select");
  4547. localeImg.src = yield getResourceUrl("img-globe");
  4548. const localeSelectElem = document.createElement("select");
  4549. localeSelectElem.id = "bytm-welcome-menu-locale-select";
  4550. for (const [locale, { name }] of Object.entries(locales)) {
  4551. const localeOptionElem = document.createElement("option");
  4552. localeOptionElem.value = locale;
  4553. localeOptionElem.textContent = name;
  4554. localeSelectElem.appendChild(localeOptionElem);
  4555. }
  4556. localeSelectElem.value = getFeatures().locale;
  4557. localeSelectElem.addEventListener("change", () => __awaiter(this, void 0, void 0, function* () {
  4558. const selectedLocale = localeSelectElem.value;
  4559. const feats = Object.assign({}, getFeatures());
  4560. feats.locale = selectedLocale;
  4561. saveFeatures(feats);
  4562. yield initTranslations(selectedLocale);
  4563. setLocale(selectedLocale);
  4564. retranslateWelcomeMenu();
  4565. }));
  4566. localeCont.appendChild(localeImg);
  4567. localeCont.appendChild(localeSelectElem);
  4568. contentWrapper.appendChild(localeCont);
  4569. // text
  4570. const textCont = document.createElement("div");
  4571. textCont.id = "bytm-welcome-menu-text-cont";
  4572. const textElem = document.createElement("p");
  4573. textElem.id = "bytm-welcome-menu-text";
  4574. const textElems = [];
  4575. const line1Elem = document.createElement("span");
  4576. line1Elem.id = "bytm-welcome-text-line1";
  4577. textElems.push(line1Elem);
  4578. const br1Elem = document.createElement("br");
  4579. textElems.push(br1Elem);
  4580. const line2Elem = document.createElement("span");
  4581. line2Elem.id = "bytm-welcome-text-line2";
  4582. textElems.push(line2Elem);
  4583. const br2Elem = document.createElement("br");
  4584. textElems.push(br2Elem);
  4585. const br3Elem = document.createElement("br");
  4586. textElems.push(br3Elem);
  4587. const line3Elem = document.createElement("span");
  4588. line3Elem.id = "bytm-welcome-text-line3";
  4589. textElems.push(line3Elem);
  4590. const br4Elem = document.createElement("br");
  4591. textElems.push(br4Elem);
  4592. const line4Elem = document.createElement("span");
  4593. line4Elem.id = "bytm-welcome-text-line4";
  4594. textElems.push(line4Elem);
  4595. const br5Elem = document.createElement("br");
  4596. textElems.push(br5Elem);
  4597. const br6Elem = document.createElement("br");
  4598. textElems.push(br6Elem);
  4599. const line5Elem = document.createElement("span");
  4600. line5Elem.id = "bytm-welcome-text-line5";
  4601. textElems.push(line5Elem);
  4602. textElems.forEach((elem) => textElem.appendChild(elem));
  4603. textCont.appendChild(textElem);
  4604. contentWrapper.appendChild(textCont);
  4605. //#SECTION finalize
  4606. menuContainer.appendChild(headerElem);
  4607. menuContainer.appendChild(contentWrapper);
  4608. menuContainer.appendChild(footerCont);
  4609. backgroundElem.appendChild(menuContainer);
  4610. document.body.appendChild(backgroundElem);
  4611. retranslateWelcomeMenu();
  4612. });
  4613. }
  4614. //#MARKER (re-)translate
  4615. /** Retranslates all elements inside the welcome menu */
  4616. function retranslateWelcomeMenu() {
  4617. const getLink = (href) => {
  4618. return [`<a href="${href}" class="bytm-link" target="_blank" rel="noopener noreferrer">`, "</a>"];
  4619. };
  4620. const changes = {
  4621. "#bytm-welcome-menu-title": (e) => e.textContent = t("welcome_menu_title", scriptInfo.name),
  4622. "#bytm-welcome-menu-title-close": (e) => e.ariaLabel = e.title = t("close_menu_tooltip"),
  4623. "#bytm-welcome-menu-open-cfg": (e) => {
  4624. e.textContent = t("config_menu");
  4625. e.ariaLabel = e.title = t("open_config_menu_tooltip");
  4626. },
  4627. "#bytm-welcome-menu-open-changelog": (e) => {
  4628. e.textContent = t("open_changelog");
  4629. e.ariaLabel = e.title = t("open_changelog_tooltip");
  4630. },
  4631. "#bytm-welcome-menu-footer-close": (e) => {
  4632. e.textContent = t("close");
  4633. e.ariaLabel = e.title = t("close_menu_tooltip");
  4634. },
  4635. "#bytm-welcome-text-line1": (e) => e.innerHTML = t("welcome_text_line_1"),
  4636. "#bytm-welcome-text-line2": (e) => e.innerHTML = t("welcome_text_line_2", scriptInfo.name),
  4637. "#bytm-welcome-text-line3": (e) => e.innerHTML = t("welcome_text_line_3", scriptInfo.name, ...getLink(`${pkg.hosts.greasyfork}/feedback`), ...getLink(pkg.hosts.openuserjs)),
  4638. "#bytm-welcome-text-line4": (e) => e.innerHTML = t("welcome_text_line_4", ...getLink(pkg.funding.url)),
  4639. "#bytm-welcome-text-line5": (e) => e.innerHTML = t("welcome_text_line_5", ...getLink(pkg.bugs.url)),
  4640. };
  4641. for (const [selector, cb] of Object.entries(changes)) {
  4642. const elem = document.querySelector(selector);
  4643. if (!elem) {
  4644. warn(`Couldn't find element ${selector} in welcome menu`);
  4645. continue;
  4646. }
  4647. cb(elem);
  4648. }
  4649. }
  4650. /** Closes the welcome menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  4651. function closeWelcomeMenu(evt) {
  4652. var _a;
  4653. if (!isWelcomeMenuOpen)
  4654. return;
  4655. isWelcomeMenuOpen = false;
  4656. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  4657. document.body.classList.remove("bytm-disable-scroll");
  4658. (_a = document.querySelector("ytmusic-app")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert");
  4659. const menuBg = document.querySelector("#bytm-welcome-menu-bg");
  4660. siteEvents.emit("welcomeMenuClosed");
  4661. if (!menuBg)
  4662. return warn("Couldn't find welcome menu background element");
  4663. menuBg.style.visibility = "hidden";
  4664. menuBg.style.display = "none";
  4665. }
  4666. //#MARKER open, show & close
  4667. /** Opens the welcome menu if it is closed */
  4668. function openWelcomeMenu() {
  4669. var _a;
  4670. if (isWelcomeMenuOpen)
  4671. return;
  4672. isWelcomeMenuOpen = true;
  4673. document.body.classList.add("bytm-disable-scroll");
  4674. (_a = document.querySelector("ytmusic-app")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true");
  4675. const menuBg = document.querySelector("#bytm-welcome-menu-bg");
  4676. if (!menuBg)
  4677. return warn("Couldn't find welcome menu background element");
  4678. menuBg.style.visibility = "visible";
  4679. menuBg.style.display = "block";
  4680. }
  4681. /** Shows the welcome menu and returns a promise that resolves when the menu is closed */
  4682. function showWelcomeMenu() {
  4683. return new Promise((resolve) => {
  4684. const unsub = siteEvents.on("welcomeMenuClosed", () => {
  4685. unsub();
  4686. resolve();
  4687. });
  4688. openWelcomeMenu();
  4689. });
  4690. }
  4691. {
  4692. // console watermark with sexy gradient
  4693. const styleGradient = "background: rgba(165, 38, 38, 1); background: linear-gradient(90deg, rgb(154, 31, 103) 0%, rgb(135, 31, 31) 40%, rgb(184, 64, 41) 100%);";
  4694. const styleCommon = "color: #fff; font-size: 1.5em; padding-left: 6px; padding-right: 6px;";
  4695. console.log();
  4696. console.log(`%c${scriptInfo.name}%cv${scriptInfo.version}%c\n\nBuild ${scriptInfo.buildNumber} ─ ${scriptInfo.namespace}`, `font-weight: bold; ${styleCommon} ${styleGradient}`, `background-color: #333; ${styleCommon}`, "padding: initial;");
  4697. console.log([
  4698. "Powered by:",
  4699. "─ Lots of ambition",
  4700. `─ My song metadata API: ${geniUrlBase}`,
  4701. "─ My userscript utility library: https://github.com/Sv443-Network/UserUtils",
  4702. "─ This tiny event listener library: https://github.com/ai/nanoevents",
  4703. ].join("\n"));
  4704. console.log();
  4705. }
  4706. let domLoaded = false;
  4707. const domain = getDomain();
  4708. /** Stuff that needs to be called ASAP, before anything async happens */
  4709. function preInit() {
  4710. log("Session ID:", getSessionId());
  4711. initInterface();
  4712. setLogLevel(defaultLogLevel);
  4713. if (domain === "ytm")
  4714. initBeforeUnloadHook();
  4715. init();
  4716. }
  4717. function init() {
  4718. var _a, _b;
  4719. return __awaiter(this, void 0, void 0, function* () {
  4720. try {
  4721. registerMenuCommands();
  4722. }
  4723. catch (e) {
  4724. }
  4725. try {
  4726. document.addEventListener("DOMContentLoaded", () => {
  4727. domLoaded = true;
  4728. });
  4729. const features = yield initConfig();
  4730. yield initTranslations((_a = features.locale) !== null && _a !== void 0 ? _a : "en_US");
  4731. setLocale((_b = features.locale) !== null && _b !== void 0 ? _b : "en_US");
  4732. setLogLevel(features.logLevel);
  4733. preInitLayout(features);
  4734. preInitBehavior(features);
  4735. preInitInput(features);
  4736. preInitSongLists(features);
  4737. if (features.disableBeforeUnloadPopup && domain === "ytm")
  4738. disableBeforeUnload();
  4739. if (!domLoaded)
  4740. document.addEventListener("DOMContentLoaded", onDomLoad);
  4741. else
  4742. onDomLoad();
  4743. if (features.rememberSongTime)
  4744. initRememberSongTime();
  4745. }
  4746. catch (err) {
  4747. error("General Error:", err);
  4748. }
  4749. // init menu separately from features
  4750. try {
  4751. void "TODO(v1.2):";
  4752. // initMenu();
  4753. }
  4754. catch (err) {
  4755. error("Couldn't initialize menu:", err);
  4756. }
  4757. });
  4758. }
  4759. /** Called when the DOM has finished loading and can be queried and altered by the userscript */
  4760. function onDomLoad() {
  4761. return __awaiter(this, void 0, void 0, function* () {
  4762. // post-build these double quotes are replaced by backticks (because if backticks are used here, the bundler converts them to double quotes)
  4763. addGlobalStyle(`.bytm-menu-bg {
  4764. --bytm-menu-bg: #333333;
  4765. --bytm-menu-bg-highlight: #252525;
  4766. --bytm-scroll-indicator-bg: rgba(10, 10, 10, 0.7);
  4767. --bytm-menu-separator-color: #797979;
  4768. --bytm-menu-border-radius: 10px;
  4769. }
  4770. #bytm-cfg-menu-bg {
  4771. --bytm-menu-height-max: 750px;
  4772. --bytm-menu-width-max: 1000px;
  4773. }
  4774. #bytm-changelog-menu-bg {
  4775. --bytm-menu-height-max: 800px;
  4776. --bytm-menu-width-max: 800px;
  4777. }
  4778. #bytm-export-menu-bg, #bytm-import-menu-bg {
  4779. --bytm-menu-height-max: 500px;
  4780. --bytm-menu-width-max: 600px;
  4781. }
  4782. #bytm-feat-help-menu-bg {
  4783. --bytm-menu-height-max: 400px;
  4784. --bytm-menu-width-max: 600px;
  4785. }
  4786. .bytm-menu-bg {
  4787. display: block;
  4788. position: fixed;
  4789. width: 100%;
  4790. height: 100%;
  4791. top: 0;
  4792. left: 0;
  4793. z-index: 5;
  4794. background-color: rgba(0, 0, 0, 0.6);
  4795. }
  4796. .bytm-menu {
  4797. position: fixed;
  4798. display: flex;
  4799. flex-direction: column;
  4800. width: calc(min(100% - 60px, var(--bytm-menu-width-max)));
  4801. border-radius: var(--bytm-menu-border-radius);
  4802. height: auto;
  4803. max-height: calc(min(100% - 40px, var(--bytm-menu-height-max)));
  4804. left: 50%;
  4805. top: 50%;
  4806. transform: translate(-50%, -50%);
  4807. z-index: 6;
  4808. color: #fff;
  4809. background-color: var(--bytm-menu-bg);
  4810. }
  4811. .bytm-menu-body {
  4812. padding: 20px;
  4813. }
  4814. #bytm-menu-opts {
  4815. display: flex;
  4816. flex-direction: column;
  4817. position: relative;
  4818. padding: 30px 0px;
  4819. overflow-y: auto;
  4820. }
  4821. .bytm-menu-header {
  4822. display: flex;
  4823. justify-content: space-between;
  4824. align-items: center;
  4825. margin-bottom: 6px;
  4826. padding: 15px 20px 15px 20px;
  4827. background-color: var(--bytm-menu-bg);
  4828. border: 2px solid var(--bytm-menu-separator-color);
  4829. border-style: none none solid none;
  4830. border-radius: var(--bytm-menu-border-radius) var(--bytm-menu-border-radius) 0px 0px;
  4831. }
  4832. .bytm-menu-header.small {
  4833. padding: 10px 15px;
  4834. }
  4835. .bytm-menu-titlecont {
  4836. display: flex;
  4837. align-items: center;
  4838. }
  4839. .bytm-menu-titlecont-no-title {
  4840. display: flex;
  4841. justify-content: flex-end;
  4842. align-items: center;
  4843. }
  4844. .bytm-menu-title {
  4845. position: relative;
  4846. display: inline-block;
  4847. font-size: 22px;
  4848. }
  4849. #bytm-menu-version {
  4850. position: absolute;
  4851. width: 100%;
  4852. bottom: -10px;
  4853. left: 0;
  4854. font-size: 10px;
  4855. font-weight: normal;
  4856. z-index: 7;
  4857. }
  4858. #bytm-menu-version .bytm-link {
  4859. color: #c6d2db;
  4860. }
  4861. #bytm-menu-linkscont {
  4862. display: flex;
  4863. align-items: center;
  4864. margin-left: 32px;
  4865. }
  4866. .bytm-menu-link {
  4867. display: inline-flex;
  4868. align-items: center;
  4869. cursor: pointer;
  4870. }
  4871. .bytm-menu-link:not(:last-of-type) {
  4872. margin-right: 10px;
  4873. }
  4874. .bytm-menu-link .bytm-menu-img {
  4875. position: relative;
  4876. border-radius: 50%;
  4877. bottom: 0px;
  4878. transition: bottom 0.15s ease-out;
  4879. }
  4880. .bytm-menu-link:hover .bytm-menu-img {
  4881. bottom: 5px;
  4882. }
  4883. .bytm-menu-close {
  4884. width: 32px;
  4885. height: 32px;
  4886. cursor: pointer;
  4887. }
  4888. .bytm-menu-close.small {
  4889. width: 24px;
  4890. height: 24px;
  4891. }
  4892. .bytm-menu-footer {
  4893. font-size: 17px;
  4894. text-decoration: underline;
  4895. }
  4896. .bytm-menu-footer.hidden {
  4897. display: none;
  4898. }
  4899. .bytm-menu-footer-cont {
  4900. display: flex;
  4901. flex-direction: row;
  4902. justify-content: space-between;
  4903. margin-top: 6px;
  4904. padding: 15px 20px;
  4905. background: var(--bytm-menu-bg);
  4906. background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, var(--bytm-menu-bg) 30%, var(--bytm-menu-bg) 100%);
  4907. border: 2px solid var(--bytm-menu-separator-color);
  4908. border-style: solid none none none;
  4909. border-radius: 0px 0px var(--bytm-menu-border-radius) var(--bytm-menu-border-radius);
  4910. }
  4911. #bytm-menu-footer-buttons-cont button:not(:last-of-type) {
  4912. margin-right: 15px;
  4913. }
  4914. .bytm-menu-footer-right {
  4915. display: flex;
  4916. flex-direction: row-reverse;
  4917. align-items: center;
  4918. margin-top: 15px;
  4919. }
  4920. #bytm-menu-footer-left-buttons-cont button:not(:last-of-type) {
  4921. margin-right: 15px;
  4922. }
  4923. #bytm-menu-scroll-indicator {
  4924. --bytm-scroll-indicator-padding: 5px;
  4925. position: sticky;
  4926. bottom: -15px;
  4927. left: 50%;
  4928. margin-top: calc(-32px - var(--bytm-scroll-indicator-padding) * 2);
  4929. padding: var(--bytm-scroll-indicator-padding);
  4930. transform: translateX(-50%);
  4931. width: 32px;
  4932. height: 32px;
  4933. z-index: 7;
  4934. background-color: var(--bytm-scroll-indicator-bg);
  4935. border-radius: 50%;
  4936. cursor: pointer;
  4937. }
  4938. .bytm-hidden {
  4939. visibility: hidden !important;
  4940. }
  4941. .bytm-ftconf-category-header {
  4942. font-size: 18px;
  4943. margin-top: 32px;
  4944. margin-bottom: 8px;
  4945. padding: 0px 20px;
  4946. }
  4947. .bytm-ftconf-category-header:first-of-type {
  4948. margin-top: 0;
  4949. }
  4950. .bytm-ftitem {
  4951. display: flex;
  4952. flex-direction: row;
  4953. justify-content: space-between;
  4954. align-items: center;
  4955. font-size: 1.4em;
  4956. padding: 8px 20px;
  4957. transition: background-color 0.15s ease-out;
  4958. }
  4959. .bytm-ftitem:hover {
  4960. background-color: var(--bytm-menu-bg-highlight);
  4961. }
  4962. .bytm-ftitem-leftside {
  4963. display: flex;
  4964. align-items: center;
  4965. min-height: 24px;
  4966. }
  4967. .bytm-ftconf-ctrl {
  4968. display: inline-flex;
  4969. align-items: center;
  4970. white-space: nowrap;
  4971. margin-left: 10px;
  4972. }
  4973. .bytm-ftconf-label {
  4974. user-select: none;
  4975. }
  4976. .bytm-slider-label {
  4977. margin-right: 10px;
  4978. }
  4979. .bytm-toggle-label {
  4980. padding-left: 10px;
  4981. padding-right: 5px;
  4982. }
  4983. .bytm-ftconf-input.bytm-hotkey-input {
  4984. cursor: pointer;
  4985. min-width: 50px;
  4986. }
  4987. .bytm-ftconf-input[type=number] {
  4988. width: 75px;
  4989. }
  4990. .bytm-ftconf-input[type=checkbox] {
  4991. margin-left: 5px;
  4992. }
  4993. #bytm-export-menu-text, #bytm-import-menu-text {
  4994. font-size: 1.6em;
  4995. margin-bottom: 15px;
  4996. }
  4997. .bytm-menu-footer-copied {
  4998. font-size: 1.6em;
  4999. margin-right: 15px;
  5000. }
  5001. #bytm-changelog-menu-body {
  5002. overflow-y: auto;
  5003. }
  5004. #bytm-export-menu-textarea, #bytm-import-menu-textarea {
  5005. width: 100%;
  5006. height: 150px;
  5007. resize: none;
  5008. }
  5009. .bytm-markdown-container {
  5010. display: flex;
  5011. flex-direction: column;
  5012. overflow-y: auto;
  5013. font-size: 1.5em;
  5014. line-height: 20px;
  5015. }
  5016. /* Markdown stuff */
  5017. .bytm-markdown-container kbd {
  5018. --bytm-easing: cubic-bezier(0.31, 0.58, 0.24, 1.15);
  5019. display: inline-block;
  5020. vertical-align: bottom;
  5021. padding: 4px;
  5022. padding-top: 2px;
  5023. font-size: 0.95em;
  5024. line-height: 11px;
  5025. background-color: #222;
  5026. border: 1px solid #777;
  5027. border-radius: 5px;
  5028. box-shadow: inset 0 -2px 0 #515559;
  5029. transition: padding 0.1s var(--bytm-easing), box-shadow 0.1s var(--bytm-easing);
  5030. }
  5031. .bytm-markdown-container kbd:active {
  5032. padding-bottom: 2px;
  5033. box-shadow: inset 0 0 0 initial;
  5034. }
  5035. .bytm-markdown-container kbd::selection {
  5036. background: rgba(0, 0, 0, 0);
  5037. }
  5038. .bytm-markdown-container code {
  5039. background-color: #222;
  5040. border-radius: 3px;
  5041. padding: 1px 5px;
  5042. }
  5043. .bytm-markdown-container h2 {
  5044. margin-bottom: 5px;
  5045. }
  5046. .bytm-markdown-container h2:not(:first-of-type) {
  5047. margin-top: 30px;
  5048. }
  5049. .bytm-markdown-container ul li::before {
  5050. content: "• ";
  5051. font-weight: bolder;
  5052. }
  5053. .bytm-markdown-container ul li > ul li::before {
  5054. white-space: pre;
  5055. content: " • ";
  5056. font-weight: bolder;
  5057. }
  5058. #bytm-feat-help-menu-desc, #bytm-feat-help-menu-text {
  5059. overflow-wrap: break-word;
  5060. white-space: pre-wrap;
  5061. padding: 10px 10px 15px 20px;
  5062. font-size: 1.5em;
  5063. }
  5064. #bytm-feat-help-menu-desc {
  5065. font-size: 1.65em;
  5066. padding-bottom: 5px;
  5067. }
  5068. .bytm-ftitem-help-btn {
  5069. width: 24px !important;
  5070. height: 24px !important;
  5071. }
  5072. .bytm-ftitem-help-btn svg {
  5073. width: 18px !important;
  5074. height: 18px !important;
  5075. }
  5076. .bytm-ftitem-help-btn svg > path {
  5077. fill: #b3bec7 !important;
  5078. }
  5079. hr {
  5080. display: block;
  5081. margin: 8px 0px 12px 0px;
  5082. border: revert;
  5083. }
  5084. .bytm-ftitem-adornment {
  5085. display: inline-flex;
  5086. justify-content: flex-start;
  5087. align-items: center;
  5088. margin-left: 8px;
  5089. }
  5090. #bytm-ftitem-locale-adornment svg path {
  5091. fill: #4595c7;
  5092. }
  5093. .bytm-hotkey-wrapper {
  5094. display: flex;
  5095. flex-direction: row;
  5096. align-items: center;
  5097. justify-content: flex-end;
  5098. }
  5099. .bytm-hotkey-reset {
  5100. font-size: 0.9em;
  5101. margin-left: 5px;
  5102. }
  5103. .bytm-hotkey-info {
  5104. font-size: 0.9em;
  5105. margin-right: 5px;
  5106. white-space: nowrap;
  5107. }
  5108. /* #MARKER misc */
  5109. .bytm-disable-scroll {
  5110. overflow: hidden !important;
  5111. }
  5112. .bytm-generic-btn {
  5113. display: inline-flex;
  5114. align-items: center;
  5115. justify-content: center;
  5116. position: relative;
  5117. vertical-align: middle;
  5118. cursor: pointer;
  5119. margin-left: 8px;
  5120. width: 36px;
  5121. height: 36px;
  5122. border: 1px solid transparent;
  5123. border-radius: 100%;
  5124. background-color: transparent;
  5125. transition: background-color 0.2s ease;
  5126. }
  5127. .bytm-generic-btn:hover {
  5128. background-color: rgba(255, 255, 255, 0.2);
  5129. }
  5130. .bytm-generic-btn:active {
  5131. background-color: #5f5f5f;
  5132. animation: flashBorder 0.4s ease 1;
  5133. }
  5134. @keyframes flashBorder {
  5135. 0% {
  5136. border: 1px solid transparent;
  5137. }
  5138. 20% {
  5139. border: 1px solid #727272;
  5140. }
  5141. 100% {
  5142. border: 1px solid transparent;
  5143. }
  5144. }
  5145. .bytm-generic-btn-img {
  5146. display: inline-block;
  5147. z-index: 10;
  5148. width: 24px;
  5149. height: 24px;
  5150. }
  5151. .bytm-spinner {
  5152. animation: rotate 1.2s linear infinite;
  5153. }
  5154. @keyframes rotate {
  5155. from {
  5156. transform: rotate(0deg);
  5157. }
  5158. to {
  5159. transform: rotate(360deg);
  5160. }
  5161. }
  5162. .bytm-anchor {
  5163. all: unset;
  5164. cursor: pointer;
  5165. }
  5166. /* ytmusic-logo a[bytm-animated="true"] .bytm-mod-logo-ellipse {
  5167. transform-origin: 12px 12px;
  5168. animation: rotate 1s ease-in-out infinite;
  5169. } */
  5170. ytmusic-logo a.bytm-logo-exchanged .bytm-mod-logo-path {
  5171. transform-origin: 12px 12px;
  5172. animation: rotate 1s ease-in-out;
  5173. }
  5174. ytmusic-logo a.bytm-logo-exchanged .bytm-mod-logo-img {
  5175. width: 24px;
  5176. height: 24px;
  5177. z-index: 1000;
  5178. position: absolute;
  5179. animation: rotate-fade-in 1s ease-in-out;
  5180. }
  5181. @keyframes rotate-fade-in {
  5182. 0% {
  5183. opacity: 0;
  5184. transform: rotate(0deg);
  5185. }
  5186. 30% {
  5187. opacity: 0;
  5188. }
  5189. 90% {
  5190. opacity: 1;
  5191. }
  5192. 100% {
  5193. transform: rotate(360deg);
  5194. }
  5195. }
  5196. .bytm-no-select {
  5197. user-select: none;
  5198. -ms-user-select: none;
  5199. -moz-user-select: none;
  5200. -webkit-user-select: none;
  5201. }
  5202. /* YTM does some weird styling that breaks everything, so this reverts all of BYTM's buttons to the browser default style */
  5203. button.bytm-btn {
  5204. padding: revert;
  5205. border: revert;
  5206. outline: revert;
  5207. font: revert;
  5208. text-transform: revert;
  5209. color: revert;
  5210. background: revert;
  5211. line-height: 1.4em;
  5212. }
  5213. .bytm-link, .bytm-markdown-container a {
  5214. color: #369bff;
  5215. text-decoration: none;
  5216. cursor: pointer;
  5217. }
  5218. .bytm-link:hover, .bytm-markdown-container a:hover {
  5219. text-decoration: underline;
  5220. }
  5221. /* #MARKER menu */
  5222. .bytm-cfg-menu-option {
  5223. display: block;
  5224. padding: 8px 0;
  5225. }
  5226. .bytm-cfg-menu-option-item {
  5227. display: flex;
  5228. flex-direction: row;
  5229. align-items: center;
  5230. font-size: 16px;
  5231. font-weight: 400;
  5232. line-height: 24px;
  5233. padding: var(--yt-compact-link-paper-item-padding, 0px 36px 0 16px);
  5234. min-height: var(--paper-item-min-height, 40px);
  5235. white-space: nowrap;
  5236. cursor: pointer;
  5237. }
  5238. .bytm-cfg-menu-option-item:hover {
  5239. background-color: var(--yt-spec-badge-chip-background, #3e3e3e);
  5240. }
  5241. .bytm-cfg-menu-option-icon {
  5242. width: 24px;
  5243. height: 24px;
  5244. margin-right: 16px;
  5245. display: flex;
  5246. align-items: center;
  5247. flex-direction: row;
  5248. flex: none;
  5249. }
  5250. .bytm-cfg-menu-option-text {
  5251. font-size: 1.4rem;
  5252. line-height: 2rem;
  5253. }
  5254. yt-multi-page-menu-section-renderer.ytd-multi-page-menu-renderer {
  5255. border-bottom: 1px solid var(--yt-spec-10-percent-layer, #3e3e3e);
  5256. }
  5257. /* #MARKER watermark */
  5258. #bytm-watermark {
  5259. font-size: 10px;
  5260. display: inline-block;
  5261. position: absolute;
  5262. left: 97px;
  5263. top: 45px;
  5264. z-index: 10;
  5265. color: white;
  5266. text-decoration: none;
  5267. cursor: pointer;
  5268. }
  5269. #bytm-watermark:hover {
  5270. text-decoration: underline;
  5271. }
  5272. /* #MARKER volume slider */
  5273. #bytm-vol-slider-cont {
  5274. position: relative;
  5275. }
  5276. #bytm-vol-slider-label {
  5277. opacity: 0.000001;
  5278. position: absolute;
  5279. font-size: 15px;
  5280. top: 50%;
  5281. left: 0;
  5282. transform: translate(calc(-50% - 10px), -50%);
  5283. text-align: right;
  5284. transition: opacity 0.2s ease;
  5285. }
  5286. #bytm-vol-slider-label.bytm-visible {
  5287. opacity: 1;
  5288. }
  5289. /* #MARKER scroll to active */
  5290. #bytm-scroll-to-active-btn-cont {
  5291. display: flex;
  5292. flex-direction: column;
  5293. justify-content: center;
  5294. align-items: center;
  5295. position: absolute;
  5296. right: 5px;
  5297. top: 0;
  5298. height: 100%;
  5299. }
  5300. #bytm-scroll-to-active-btn {
  5301. display: inline-flex;
  5302. align-items: center;
  5303. justify-content: center;
  5304. border-radius: 50%;
  5305. cursor: pointer;
  5306. }
  5307. #bytm-scroll-to-active-btn {
  5308. width: revert;
  5309. height: revert;
  5310. }
  5311. #bytm-scroll-to-active-btn .bytm-generic-btn-img {
  5312. padding: 4px;
  5313. }
  5314. /* #MARKER queue buttons */
  5315. #side-panel ytmusic-player-queue-item .song-info.ytmusic-player-queue-item {
  5316. position: relative;
  5317. }
  5318. #side-panel ytmusic-player-queue-item .bytm-queue-btn-container {
  5319. background: rgb(0, 0, 0);
  5320. background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, #030303 15%);
  5321. display: none;
  5322. position: absolute;
  5323. right: 0;
  5324. padding-left: 25px;
  5325. height: 100%;
  5326. }
  5327. #side-panel ytmusic-player-queue-item[selected] .bytm-queue-btn-container {
  5328. background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, #1D1D1D 15%);
  5329. }
  5330. .bytm-generic-list-queue-btn-container {
  5331. /* otherwise the queue buttons render over the currently playing song page */
  5332. z-index: 1;
  5333. }
  5334. #side-panel ytmusic-player-queue-item:hover .bytm-queue-btn-container,
  5335. ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer:hover .bytm-queue-btn-container,
  5336. ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer:hover .bytm-queue-btn-container {
  5337. display: inline-block;
  5338. }
  5339. ytmusic-responsive-list-item-renderer .title-column {
  5340. align-items: center;
  5341. }
  5342. #side-panel ytmusic-player-queue-item[play-button-state="loading"] .bytm-queue-btn-container,
  5343. #side-panel ytmusic-player-queue-item[play-button-state="playing"] .bytm-queue-btn-container,
  5344. #side-panel ytmusic-player-queue-item[play-button-state="paused"] .bytm-queue-btn-container {
  5345. /* using a var() with predefined value from YTM is not viable since the nesting changes the actual value of the variable */
  5346. background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, #030303 15%);
  5347. }
  5348. #side-panel ytmusic-player-queue-item[selected][play-button-state="loading"] .bytm-queue-btn-container,
  5349. #side-panel ytmusic-player-queue-item[selected][play-button-state="playing"] .bytm-queue-btn-container,
  5350. #side-panel ytmusic-player-queue-item[selected][play-button-state="paused"] .bytm-queue-btn-container {
  5351. /* using a var() with predefined value from YTM is not viable since the nesting changes the actual value of the variable */
  5352. background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, #1D1D1D 15%);
  5353. }
  5354. ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown[data-bytm-hidden=true] {
  5355. display: none !important;
  5356. }
  5357. ytmusic-responsive-list-item-renderer.bytm-has-queue-btns .bytm-generic-list-queue-btn-container {
  5358. visibility: hidden;
  5359. }
  5360. ytmusic-responsive-list-item-renderer.bytm-has-queue-btns .bytm-generic-list-queue-btn-container a.bytm-generic-btn {
  5361. visibility: hidden !important;
  5362. }
  5363. ytmusic-responsive-list-item-renderer.bytm-has-queue-btns:hover .bytm-generic-list-queue-btn-container {
  5364. visibility: visible;
  5365. }
  5366. ytmusic-responsive-list-item-renderer.bytm-has-queue-btns:hover .bytm-generic-list-queue-btn-container a.bytm-generic-btn {
  5367. visibility: visible !important;
  5368. }
  5369. #bytm-welcome-menu-bg {
  5370. --bytm-menu-height-max: 500px;
  5371. --bytm-menu-width-max: 700px;
  5372. }
  5373. #bytm-welcome-menu-title-wrapper {
  5374. display: flex;
  5375. flex-direction: row;
  5376. align-items: center;
  5377. }
  5378. #bytm-welcome-menu-title-logo {
  5379. width: 32px;
  5380. height: 32px;
  5381. margin-right: 20px;
  5382. }
  5383. #bytm-welcome-menu-content-wrapper {
  5384. overflow-y: auto;
  5385. }
  5386. #bytm-welcome-menu-locale-cont {
  5387. display: flex;
  5388. flex-direction: column;
  5389. align-items: center;
  5390. justify-content: flex-start;
  5391. }
  5392. #bytm-welcome-menu-locale-img {
  5393. width: 80px;
  5394. height: 80px;
  5395. margin-bottom: 10px;
  5396. }
  5397. #bytm-welcome-menu-text {
  5398. font-size: 1.6em;
  5399. padding: 8px 20px;
  5400. margin: 10px 0px;
  5401. line-height: 20px;
  5402. }
  5403. #bytm-welcome-menu-locale-select {
  5404. font-size: 1.6em;
  5405. }
  5406. #bytm-welcome-menu-footer-cont {
  5407. border-radius: 0px 0px var(--bytm-menu-border-radius) var(--bytm-menu-border-radius);
  5408. padding: 20px;
  5409. }`);
  5410. initObservers();
  5411. initOnSelector();
  5412. const features = getFeatures();
  5413. const ftInit = [];
  5414. yield checkVersion();
  5415. log(`DOM loaded. Initializing features for domain "${domain}"...`);
  5416. try {
  5417. if (domain === "ytm") {
  5418. disableDarkReader();
  5419. ftInit.push(initSiteEvents());
  5420. if (typeof (yield GM.getValue("bytm-installed")) !== "string") {
  5421. // open welcome menu with language selector
  5422. yield addWelcomeMenu();
  5423. info("Showing welcome menu");
  5424. yield showWelcomeMenu();
  5425. yield GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version }));
  5426. }
  5427. try {
  5428. ftInit.push(addCfgMenu()); // TODO(v1.2): remove
  5429. }
  5430. catch (err) {
  5431. error("Couldn't add menu:", err);
  5432. }
  5433. observers$1.body.addListener("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
  5434. listener: addConfigMenuOption,
  5435. });
  5436. if (features.arrowKeySupport)
  5437. ftInit.push(initArrowKeySkip());
  5438. if (features.removeUpgradeTab)
  5439. ftInit.push(removeUpgradeTab());
  5440. if (features.watermarkEnabled)
  5441. ftInit.push(addWatermark());
  5442. if (features.geniusLyrics)
  5443. ftInit.push(addMediaCtrlLyricsBtn());
  5444. if (features.deleteFromQueueButton || features.lyricsQueueButton)
  5445. ftInit.push(initQueueButtons());
  5446. if (features.anchorImprovements)
  5447. ftInit.push(addAnchorImprovements());
  5448. if (features.closeToastsTimeout > 0)
  5449. ftInit.push(initAutoCloseToasts());
  5450. if (features.removeShareTrackingParam)
  5451. ftInit.push(removeShareTrackingParam());
  5452. if (features.numKeysSkipToTime)
  5453. ftInit.push(initNumKeysSkip());
  5454. if (features.fixSpacing)
  5455. ftInit.push(fixSpacing());
  5456. if (features.scrollToActiveSongBtn)
  5457. ftInit.push(addScrollToActiveBtn());
  5458. ftInit.push(initVolumeFeatures());
  5459. }
  5460. if (["ytm", "yt"].includes(domain)) {
  5461. if (features.switchBetweenSites)
  5462. ftInit.push(initSiteSwitch(domain));
  5463. }
  5464. Promise.allSettled(ftInit).then(() => {
  5465. emitInterface("bytm:ready");
  5466. });
  5467. }
  5468. catch (err) {
  5469. error("Feature error:", err);
  5470. }
  5471. });
  5472. }
  5473. function registerMenuCommands() {
  5474. if (mode === "development") {
  5475. GM.registerMenuCommand("Reset config", () => __awaiter(this, void 0, void 0, function* () {
  5476. if (confirm("Reset the configuration to its default values?\nThis will automatically reload the page.")) {
  5477. yield clearConfig();
  5478. disableBeforeUnload();
  5479. location.reload();
  5480. }
  5481. }), "r");
  5482. GM.registerMenuCommand("List GM values", () => __awaiter(this, void 0, void 0, function* () {
  5483. const keys = yield GM.listValues();
  5484. console.log("GM values:");
  5485. if (keys.length === 0)
  5486. console.log(" No values found.");
  5487. for (const key of keys)
  5488. console.log(` ${key} -> ${yield GM.getValue(key)}`);
  5489. alert("See console.");
  5490. }), "l");
  5491. GM.registerMenuCommand("Delete all GM values", () => __awaiter(this, void 0, void 0, function* () {
  5492. if (confirm("Clear all GM values?\nSee console for details.")) {
  5493. const keys = yield GM.listValues();
  5494. console.log("Clearing GM values:");
  5495. if (keys.length === 0)
  5496. console.log(" No values found.");
  5497. for (const key of keys) {
  5498. yield GM.deleteValue(key);
  5499. console.log(` Deleted ${key}`);
  5500. }
  5501. }
  5502. }), "d");
  5503. GM.registerMenuCommand("Delete GM value by name", () => __awaiter(this, void 0, void 0, function* () {
  5504. const key = prompt("Enter the name of the GM value to delete.\nEmpty input cancels the operation.");
  5505. if (key && key.length > 0) {
  5506. const oldVal = yield GM.getValue(key);
  5507. yield GM.deleteValue(key);
  5508. console.log(`Deleted GM value '${key}' with previous value '${oldVal}'`);
  5509. }
  5510. }), "n");
  5511. GM.registerMenuCommand("Reset install timestamp", () => __awaiter(this, void 0, void 0, function* () {
  5512. yield GM.deleteValue("bytm-installed");
  5513. console.log("Reset install time.");
  5514. }), "t");
  5515. GM.registerMenuCommand("Reset version check timestamp", () => __awaiter(this, void 0, void 0, function* () {
  5516. yield GM.deleteValue("bytm-version-check");
  5517. console.log("Reset version check time.");
  5518. }), "v");
  5519. GM.registerMenuCommand("List active selector listeners", () => __awaiter(this, void 0, void 0, function* () {
  5520. const lines = [];
  5521. let listenersAmt = 0;
  5522. for (const [obsName, obs] of Object.entries(observers$1)) {
  5523. const listeners = obs.getAllListeners();
  5524. lines.push(`- "${obsName}" (${listeners.size} listeners):`);
  5525. [...listeners].forEach(([k, v]) => {
  5526. listenersAmt += v.length;
  5527. lines.push(` [${v.length}] ${k}`);
  5528. v.forEach(({ all, continuous }, i) => {
  5529. lines.push(` ${v.length > 1 && i !== v.length - 1 ? "├" : "└"}> ${continuous ? "continuous" : "single-shot"}, ${all ? "select multiple" : "select single"}`);
  5530. });
  5531. });
  5532. }
  5533. console.log(`Showing currently active listeners for ${Object.keys(observers$1).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
  5534. alert("See console.");
  5535. }), "s");
  5536. }
  5537. }
  5538. preInit();
  5539. })();