ui.js 154 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598
  1. // The UI in MTUI! Interfaces with the backend to form the complete mtui app.
  2. import {spawn} from 'node:child_process'
  3. import {readFile, writeFile} from 'node:fs/promises'
  4. import path from 'node:path'
  5. import url from 'node:url'
  6. import {orderBy} from 'natural-orderby'
  7. import open from 'open'
  8. import {Button, Form, ListScrollForm, TextInput} from 'tui-lib/ui/controls'
  9. import {Dialog} from 'tui-lib/ui/dialogs'
  10. import {Label, Pane, WrapLabel} from 'tui-lib/ui/presentation'
  11. import {DisplayElement, FocusElement} from 'tui-lib/ui/primitives'
  12. import * as ansi from 'tui-lib/util/ansi'
  13. import telc from 'tui-lib/util/telchars'
  14. import unic from 'tui-lib/util/unichars'
  15. import {getAllCrawlersForArg} from './crawlers.js'
  16. import {originalSymbol} from './socket.js'
  17. import processSmartPlaylist from './smart-playlist.js'
  18. import UndoManager from './undo-manager.js'
  19. import {
  20. commandExists,
  21. getSecFromTimestamp,
  22. getTimeStringsFromSec,
  23. promisifyProcess,
  24. shuffleArray,
  25. } from './general-util.js'
  26. import {
  27. cloneGrouplike,
  28. collapseGrouplike,
  29. countTotalTracks,
  30. findItemObject,
  31. flattenGrouplike,
  32. getCorrespondingFileForItem,
  33. getCorrespondingPlayableForFile,
  34. getFlatTrackList,
  35. getFlatGroupList,
  36. getItemPath,
  37. getNameWithoutTrackNumber,
  38. isGroup,
  39. isOpenable,
  40. isPlayable,
  41. isTrack,
  42. parentSymbol,
  43. reverseOrderOfGroups,
  44. searchForItem,
  45. shuffleOrderOfGroups,
  46. } from './playlist-utils.js'
  47. import {
  48. updateRestoredTracksUsingPlaylists,
  49. getWaitingTrackData
  50. } from './serialized-backend.js'
  51. /* text editor features disabled because theyre very much incomplete and havent
  52. * gotten much use from me or anyone afaik!
  53. const TuiTextEditor = require('tui-text-editor')
  54. */
  55. const input = {}
  56. const keyBindings = [
  57. ['isUp', telc.isUp],
  58. ['isDown', telc.isDown],
  59. ['isLeft', telc.isLeft],
  60. ['isRight', telc.isRight],
  61. ['isSelect', telc.isSelect],
  62. ['isBackspace', telc.isBackspace],
  63. ['isMenu', 'm'],
  64. ['isMenu', 'f'],
  65. ['isScrollToStart', 'g', {caseless: false}],
  66. ['isScrollToEnd', 'G', {caseless: false}],
  67. ['isScrollToStart', telc.isHome],
  68. ['isScrollToEnd', telc.isEnd],
  69. ['isTogglePause', telc.isSpace],
  70. ['isToggleLoop', 'l'],
  71. ['isStop', telc.isEscape],
  72. ['isVolumeUp', 'v', {caseless: false}],
  73. ['isVolumeDown', 'V', {caseless: false}],
  74. ['isSkipBack', telc.isControlUp],
  75. ['isSkipAhead', telc.isControlDown],
  76. ['isSkipBack', 'p'],
  77. ['isSkipAhead', 'n'],
  78. ['isFocusTabber', '['],
  79. ['isFocusQueue', ']'],
  80. ['isFocusPlaybackInfo', '|'],
  81. ['isNextTab', 't', {caseless: false}],
  82. ['isPreviousTab', 'T', {caseless: false}],
  83. ['isDownload', 'd'],
  84. ['isRemove', 'x'],
  85. ['isQueueAfterSelectedTrack', 'q'],
  86. ['isOpenThroughSystem', 'o'],
  87. ['isShuffleQueue', 's'],
  88. ['isClearQueue', 'c'],
  89. ['isFocusMenubar', ';'],
  90. // ['isFocusLabels', 'L', {caseless: false}], // todo: better key? to let isToggleLoop be caseless again
  91. ['isSelectUp', telc.isShiftUp],
  92. ['isSelectDown', telc.isShiftDown],
  93. ['isNextThemeColor', 'c', {caseless: false}],
  94. ['isPreviousThemeColor', 'C', {caseless: false}],
  95. ['isPreviousPlayer', telc.isMetaUp],
  96. ['isPreviousPlayer', [0x1b, 'p']],
  97. ['isNextPlayer', telc.isMetaDown],
  98. ['isNextPlayer', [0x1b, 'n']],
  99. ['isNewPlayer', [0x1b, 'c']],
  100. ['isRemovePlayer', [0x1b, 'x']],
  101. ['isActOnPlayer', [0x1b, 'a']],
  102. ['isActOnPlayer', [0x1b, '!']],
  103. ['isFocusTextEditor', [0x05]], // ^E
  104. ['isSaveTextEditor', [0x13]], // ^S
  105. ['isDeselectTextEditor', [0x18]], // ^X
  106. ['isDeselectTextEditor', telc.isEscape],
  107. // Number pad
  108. ['isUp', '8'],
  109. ['isDown', '2'],
  110. ['isLeft', '4'],
  111. ['isRight', '6'],
  112. ['isSpace', '5'],
  113. ['isTogglePause', '5'],
  114. ['isBackspace', '.'],
  115. ['isMenu', '+'],
  116. ['isMenu', '0'],
  117. ['isSkipBack', '1'],
  118. ['isSkipAhead', '3'],
  119. // Disabled because this is the jump key! Oops.
  120. // ['isVolumeDown', '/'],
  121. // ['isVolumeUp', '*'],
  122. ['isFocusTabber', '7'],
  123. ['isFocusQueue', '9'],
  124. ['isFocusMenubar', '*'],
  125. // HJKL
  126. ['isDown', 'j'],
  127. ['isUp', 'k'],
  128. // Don't use these for now... currently L is used for toggling loop.
  129. // May want to look into changing that (so we can re-enable these).
  130. // ['isLeft', 'h'],
  131. // ['isRight', 'l'],
  132. ]
  133. const addKey = (prop, keyOrFunc, {caseless = true} = {}) => {
  134. const oldFunc = input[prop] || (() => false)
  135. let newFunc
  136. if (typeof keyOrFunc === 'function') {
  137. newFunc = keyOrFunc
  138. } else if (typeof keyOrFunc === 'string') {
  139. const key = keyOrFunc
  140. if (caseless) {
  141. newFunc = input => input.toString().toLowerCase() === key.toLowerCase()
  142. } else {
  143. newFunc = input => input.toString() === key
  144. }
  145. } else if (Array.isArray(keyOrFunc)) {
  146. const buf = Buffer.from(keyOrFunc.map(k => typeof k === 'string' ? k.charCodeAt(0) : k))
  147. newFunc = keyBuf => keyBuf.equals(buf)
  148. }
  149. input[prop] = keyBuf => newFunc(keyBuf) || oldFunc(keyBuf)
  150. }
  151. for (const entry of keyBindings) {
  152. addKey(...entry)
  153. }
  154. // Some things just need to be overridden in order for the rest of tui-lib to
  155. // recognize our new keys.
  156. telc.isUp = input.isUp
  157. telc.isDown = input.isDown
  158. telc.isLeft = input.isLeft
  159. telc.isRight = input.isRight
  160. telc.isSelect = input.isSelect
  161. telc.isBackspace = input.isBackspace
  162. export default class AppElement extends FocusElement {
  163. constructor(backend, config = {}) {
  164. super()
  165. this.backend = backend
  166. this.telnetServer = null
  167. this.isPartyHost = false
  168. this.enableAutoDJ = false
  169. // this.playlistSources = []
  170. this.config = Object.assign({
  171. canControlPlayback: true,
  172. canControlQueue: true,
  173. canControlQueuePlayers: true,
  174. canProcessMetadata: true,
  175. canSuspend: true,
  176. themeColor: 4, // blue
  177. seekToStartThreshold: 3,
  178. showTabberPane: true,
  179. stopPlayingUponQuit: true,
  180. showPartyControls: false
  181. }, config)
  182. // TODO: Move edit mode stuff to the backend!
  183. this.undoManager = new UndoManager()
  184. this.markGrouplike = {name: 'Selected Items', items: []}
  185. this.cachedMarkStatuses = new Map()
  186. this.editMode = false
  187. this.timestampDictionary = new WeakMap()
  188. // We add this is a child later (so that it's on top of every element).
  189. this.menuLayer = new DisplayElement()
  190. this.menuLayer.clickThrough = true
  191. this.showContextMenu = this.showContextMenu.bind(this)
  192. this.menubar = new Menubar(this.showContextMenu)
  193. this.addChild(this.menubar)
  194. this.setThemeColor(this.config.themeColor)
  195. this.menubar.on('color', color => this.setThemeColor(color))
  196. this.tabberPane = new Pane()
  197. this.addChild(this.tabberPane)
  198. this.queuePane = new Pane()
  199. this.addChild(this.queuePane)
  200. /*
  201. this.textInfoPane = new Pane()
  202. this.addChild(this.textInfoPane)
  203. this.textEditor = new NotesTextEditor()
  204. this.textInfoPane.addChild(this.textEditor)
  205. this.textInfoPane.visible = false
  206. this.textEditor.on('deselect', () => {
  207. this.root.select(this.tabber)
  208. this.fixLayout()
  209. })
  210. */
  211. this.logPane = new Pane()
  212. this.addChild(this.logPane)
  213. this.log = new Log()
  214. this.logPane.addChild(this.log)
  215. this.logPane.visible = false
  216. this.log.on('log message', () => {
  217. this.logPane.visible = true
  218. this.fixLayout()
  219. })
  220. if (!this.config.showTabberPane) {
  221. this.tabberPane.visible = false
  222. }
  223. this.tabber = new Tabber()
  224. this.tabberPane.addChild(this.tabber)
  225. this.metadataStatusLabel = new Label()
  226. this.metadataStatusLabel.visible = false
  227. this.tabberPane.addChild(this.metadataStatusLabel)
  228. this.queueListingElement = new QueueListingElement(this)
  229. this.setupCommonGrouplikeListingEvents(this.queueListingElement)
  230. this.queuePane.addChild(this.queueListingElement)
  231. this.queueLengthLabel = new Label('')
  232. this.queuePane.addChild(this.queueLengthLabel)
  233. this.queueTimeLabel = new Label('')
  234. this.queuePane.addChild(this.queueTimeLabel)
  235. this.queueListingElement.on('select', _item => this.updateQueueLengthLabel())
  236. this.queueListingElement.on('open', item => this.openSpecialOrThroughSystem(item))
  237. this.queueListingElement.on('queue', item => this.play(item))
  238. this.queueListingElement.on('remove', item => this.unqueue(item))
  239. this.queueListingElement.on('shuffle', () => this.shuffleQueue())
  240. this.queueListingElement.on('clear', () => this.clearQueue())
  241. this.queueListingElement.on('select main listing',
  242. () => this.selected())
  243. if (this.config.showPartyControls) {
  244. const sharedSourcesListing = this.newGrouplikeListing()
  245. sharedSourcesListing.loadGrouplike(this.backend.sharedSourcesGrouplike)
  246. }
  247. this.playbackPane = new Pane()
  248. this.addChild(this.playbackPane)
  249. this.playbackForm = new ListScrollForm()
  250. this.playbackPane.addChild(this.playbackForm)
  251. this.playbackInfoElements = []
  252. this.partyTop = new DisplayElement()
  253. this.partyBottom = new DisplayElement()
  254. this.addChild(this.partyTop)
  255. this.addChild(this.partyBottom)
  256. this.partyTop.visible = false
  257. this.partyBottom.visible = false
  258. this.partyTopBanner = new PartyBanner(1)
  259. this.partyBottomBanner = new PartyBanner(-1)
  260. this.partyTop.addChild(this.partyTopBanner)
  261. this.partyBottom.addChild(this.partyBottomBanner)
  262. this.partyLabel = new Label('')
  263. this.partyTop.addChild(this.partyLabel)
  264. // Dialogs
  265. this.openPlaylistDialog = new OpenPlaylistDialog()
  266. this.setupDialog(this.openPlaylistDialog)
  267. this.openPlaylistDialog.on('source selected', source => this.loadPlaylistOrSource(source))
  268. this.openPlaylistDialog.on('source selected (new tab)', source => this.loadPlaylistOrSource(source, true))
  269. this.alertDialog = new AlertDialog()
  270. this.setupDialog(this.alertDialog)
  271. // Should be placed on top of everything else!
  272. this.addChild(this.menuLayer)
  273. this.whereControl = new InlineListPickerElement('Where?', [
  274. {value: 'after-selected', label: 'After selected track'},
  275. {value: 'next', label: 'After current track'},
  276. {value: 'end', label: 'At end of queue'},
  277. {value: 'distribute-evenly', label: 'Distributed across queue evenly'},
  278. {value: 'distribute-randomly', label: 'Distributed across queue randomly'},
  279. {value: 'before-selected', label: 'Before selected track'}
  280. ], this.showContextMenu)
  281. this.orderControl = new InlineListPickerElement('Order?', [
  282. {value: 'shuffle', label: 'Shuffle all'},
  283. {value: 'shuffle-groups', label: 'Shuffle order of groups'},
  284. {value: 'reverse', label: 'Reverse all'},
  285. {value: 'reverse-groups', label: 'Reverse order of groups'},
  286. {value: 'alphabetic', label: 'Alphabetically'},
  287. {value: 'alphabetic-groups', label: 'Alphabetize order of groups'},
  288. {value: 'normal', label: 'In order'}
  289. ], this.showContextMenu)
  290. this.menubar.buildItems([
  291. {text: 'mtui', menuItems: [
  292. {label: 'mtui (perpetual development)'},
  293. {divider: true},
  294. {label: 'Quit', action: () => this.shutdown()},
  295. this.config.canSuspend && {label: 'Suspend', action: () => this.suspend()}
  296. ]},
  297. {text: 'Playback', menuFn: () => {
  298. const { playingTrack } = this.SQP
  299. const { items } = this.SQP.queueGrouplike
  300. const curIndex = items.indexOf(playingTrack)
  301. const next = (curIndex >= 0) && items[curIndex + 1]
  302. const previous = (curIndex >= 0) && items[curIndex - 1]
  303. return [
  304. {label: playingTrack ? `("${playingTrack.name}")` : '(No track playing.)'},
  305. {divider: true},
  306. {element: this.volumeSlider},
  307. {divider: true},
  308. playingTrack && {element: this.playingControl},
  309. playingTrack && {element: this.loopingControl},
  310. playingTrack && {element: this.pauseNextControl},
  311. {element: this.autoDJControl},
  312. {divider: true},
  313. previous && {label: `Previous (${previous.name})`, action: () => this.SQP.playPrevious(playingTrack)},
  314. next && {label: `Next (${next.name})`, action: () => this.SQP.playNext(playingTrack)},
  315. !next && this.SQP.queueEndMode === 'loop' &&
  316. {label: `Next (loop queue)`, action: () => this.SQP.playNext(playingTrack)},
  317. next && {label: '- Play later', action: () => this.playLater(next)}
  318. ]
  319. }},
  320. {text: 'Queue', menuFn: () => {
  321. const { items } = this.SQP.queueGrouplike
  322. const curIndex = items.indexOf(this.playingTrack)
  323. return [
  324. {label: `(Queue - ${curIndex >= 0 ? `${curIndex + 1}/` : ''}${items.length} items.)`},
  325. {divider: true},
  326. {element: this.loopModeControl},
  327. {divider: true},
  328. items.length && {label: 'Shuffle', action: () => this.shuffleQueue()},
  329. items.length && {label: 'Clear', action: () => this.clearQueue()}
  330. ]
  331. }},
  332. {text: 'Multi', menuFn: () => {
  333. const { queuePlayers } = this.backend
  334. return [
  335. {key: 'heading', label: `(Multi-players - ${queuePlayers.length})`},
  336. {divider: true},
  337. ...queuePlayers.map((queuePlayer, index) => {
  338. const PIE = new PlaybackInfoElement(queuePlayer, this)
  339. PIE.displayMode = 'collapsed'
  340. PIE.updateTrack()
  341. return {key: 'qp' + index, element: PIE}
  342. }),
  343. {divider: true},
  344. {key: 'add-new-player', label: `Add new player`, action: () => this.addQueuePlayer().then(() => 'reload')}
  345. ]
  346. }}
  347. ])
  348. this.playingControl = new ToggleControl('Pause?', {
  349. setValue: val => this.SQP.setPause(val),
  350. getValue: () => this.SQP.player.isPaused,
  351. getEnabled: () => this.config.canControlPlayback
  352. })
  353. this.loopingControl = new ToggleControl('Loop current track?', {
  354. setValue: val => this.SQP.setLoop(val),
  355. getValue: () => this.SQP.player.isLooping,
  356. getEnabled: () => this.config.canControlPlayback
  357. })
  358. this.loopModeControl = new InlineListPickerElement('Loop queue?', [
  359. {value: 'end', label: 'Don\'t loop'},
  360. {value: 'loop', label: 'Loop (same order)'},
  361. {value: 'shuffle', label: 'Loop (shuffle)'}
  362. ], {
  363. setValue: val => {
  364. if (this.SQP) {
  365. this.SQP.queueEndMode = val
  366. }
  367. },
  368. getValue: () => this.SQP && this.SQP.queueEndMode,
  369. showContextMenu: this.showContextMenu
  370. })
  371. this.pauseNextControl = new ToggleControl('Pause when this track ends?', {
  372. setValue: val => this.SQP.setPauseNextTrack(val),
  373. getValue: () => this.SQP.pauseNextTrack,
  374. getEnabled: () => this.config.canControlPlayback
  375. })
  376. this.loopQueueControl = new ToggleControl('Loop queue?', {
  377. setValue: val => this.SQP.setLoopQueueAtEnd(val),
  378. getValue: () => this.SQP.loopQueueAtEnd,
  379. getEnabled: () => this.config.canControlPlayback
  380. })
  381. this.volumeSlider = new SliderElement('Volume', {
  382. setValue: val => this.SQP.setVolume(val),
  383. getValue: () => this.SQP.player.volume,
  384. getEnabled: () => this.config.canControlPlayback
  385. })
  386. this.autoDJControl = new ToggleControl('Enable Auto-DJ?', {
  387. setValue: val => (this.enableAutoDJ = val),
  388. getValue: () => this.enableAutoDJ,
  389. getEnabled: () => this.config.canControlPlayback
  390. })
  391. this.bindListeners()
  392. this.initialAttachListeners()
  393. // Also handy to be bound to the app.
  394. this.showContextMenu = this.showContextMenu.bind(this)
  395. this.queuePlayersToActOn = []
  396. this.selectQueuePlayer(this.backend.queuePlayers[0])
  397. }
  398. bindListeners() {
  399. for (const key of [
  400. 'handlePlayingDetails',
  401. 'handleReceivedTimeData',
  402. 'handleProcessMetadataProgress',
  403. 'handleQueueUpdated',
  404. 'handleAddedQueuePlayer',
  405. 'handleRemovedQueuePlayer',
  406. 'handleLogMessage',
  407. 'handleGotSharedSources',
  408. 'handleSharedSourcesUpdated',
  409. 'handleSetLoopQueueAtEnd'
  410. ]) {
  411. this[key] = this[key].bind(this)
  412. }
  413. }
  414. initialAttachListeners() {
  415. this.attachBackendListeners()
  416. for (const queuePlayer of this.backend.queuePlayers) {
  417. this.attachQueuePlayerListenersAndUI(queuePlayer)
  418. }
  419. }
  420. removeListeners() {
  421. this.removeBackendListeners()
  422. for (const queuePlayer of this.backend.queuePlayers) {
  423. // Don't update the UI - removeListeners is only called just before the
  424. // AppElement is done being used.
  425. this.removeQueuePlayerListenersAndUI(queuePlayer, false)
  426. }
  427. }
  428. attachQueuePlayerListenersAndUI(queuePlayer) {
  429. const PIE = new PlaybackInfoElement(queuePlayer, this)
  430. this.playbackInfoElements.push(PIE)
  431. this.playbackForm.addInput(PIE)
  432. this.fixLayout()
  433. PIE.on('seek back', () => PIE.queuePlayer.seekBack(5))
  434. PIE.on('seek ahead', () => PIE.queuePlayer.seekAhead(5))
  435. PIE.on('toggle pause', () => PIE.queuePlayer.togglePause())
  436. }
  437. removeQueuePlayerListenersAndUI(queuePlayer, updateUI = true) {
  438. if (updateUI) {
  439. const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
  440. if (PIE) {
  441. const PIEs = this.playbackInfoElements
  442. const oldIndex = PIEs.indexOf(PIE)
  443. if (this.playbackForm.curIndex > oldIndex) {
  444. this.playbackForm.curIndex--
  445. }
  446. PIEs.splice(oldIndex, 1)
  447. this.playbackForm.removeInput(PIE)
  448. if (this.SQP === queuePlayer) {
  449. const { queuePlayer } = PIEs[Math.min(oldIndex, PIEs.length - 1)]
  450. this.selectQueuePlayer(queuePlayer)
  451. }
  452. this.fixLayout()
  453. }
  454. }
  455. const index = this.queuePlayersToActOn.indexOf(queuePlayer)
  456. if (index >= 0) {
  457. this.queuePlayersToActOn.splice(index, 1)
  458. }
  459. queuePlayer.stopPlaying()
  460. }
  461. attachBackendListeners() {
  462. // Backend-specialized listeners
  463. this.backend.on('processMetadata progress', this.handleProcessMetadataProgress)
  464. this.backend.on('added queue player', this.handleAddedQueuePlayer)
  465. this.backend.on('removed queue player', this.handleRemovedQueuePlayer)
  466. this.backend.on('log message', this.handleLogMessage)
  467. this.backend.on('got shared sources', this.handleGotSharedSources)
  468. this.backend.on('shared sources updated', this.handleSharedSourcesUpdated)
  469. this.backend.on('set loop queue at end', this.handleSetLoopQueueAtEnd)
  470. // Backend as queue player proxy listeners
  471. this.backend.on('QP: playing details', this.handlePlayingDetails)
  472. this.backend.on('QP: received time data', this.handleReceivedTimeData)
  473. this.backend.on('QP: queue updated', this.handleQueueUpdated)
  474. }
  475. removeBackendListeners() {
  476. // Backend-specialized listeners
  477. this.backend.removeListener('processMetadata progress', this.handleProcessMetadataProgress)
  478. this.backend.removeListener('added queue player', this.handleAddedQueuePlayer)
  479. this.backend.removeListener('removed queue player', this.handleRemovedQueuePlayer)
  480. this.backend.removeListener('log message', this.handleLogMessage)
  481. this.backend.removeListener('got shared sources', this.handleGotSharedSources)
  482. this.backend.removeListener('shared sources updated', this.handleSharedSourcesUpdated)
  483. this.backend.removeListener('set loop queue at end', this.handleSetLoopQueueAtEnd)
  484. // Backend as queue player proxy listeners
  485. this.backend.removeListener('QP: playing details', this.handlePlayingDetails)
  486. this.backend.removeListener('QP: received time data', this.handleReceivedTimeData)
  487. this.backend.removeListener('QP: queue updated', this.handleQueueUpdated)
  488. }
  489. handleAddedQueuePlayer(queuePlayer) {
  490. this.attachQueuePlayerListenersAndUI(queuePlayer)
  491. }
  492. handleRemovedQueuePlayer(queuePlayer) {
  493. this.removeQueuePlayerListenersAndUI(queuePlayer)
  494. if (this.menubar.contextMenu) {
  495. setTimeout(() => this.menubar.contextMenu.reload(), 0)
  496. }
  497. }
  498. handleLogMessage(messageInfo) {
  499. this.log.newLogMessage(messageInfo)
  500. }
  501. handleGotSharedSources(socketId, sharedSources) {
  502. for (const grouplikeListing of this.tabber.tabberElements) {
  503. if (grouplikeListing.grouplike === this.backend.sharedSourcesGrouplike) {
  504. grouplikeListing.loadGrouplike(this.backend.sharedSourcesGrouplike, false)
  505. }
  506. }
  507. }
  508. handleSharedSourcesUpdated(socketId, partyGrouplike) {
  509. for (const grouplikeListing of this.tabber.tabberElements) {
  510. if (grouplikeListing.grouplike === partyGrouplike) {
  511. grouplikeListing.loadGrouplike(partyGrouplike, false)
  512. }
  513. }
  514. this.clearCachedMarkStatuses()
  515. }
  516. handleSetLoopQueueAtEnd() {
  517. this.updateQueueLengthLabel()
  518. }
  519. async handlePlayingDetails(queuePlayer, track, oldTrack, startTime) {
  520. const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
  521. if (PIE) {
  522. PIE.updateTrack()
  523. }
  524. if (queuePlayer === this.SQP) {
  525. this.updateQueueLengthLabel()
  526. this.queueListingElement.collapseTimestamps(oldTrack)
  527. if (track && this.queueListingElement.currentItem === oldTrack) {
  528. this.queueListingElement.selectAndShow(track)
  529. }
  530. }
  531. // Unfortunately, there isn't really any reliable way to make these work if
  532. // the containing queue isn't of the selected queue player.
  533. const timestampData = track && this.getTimestampData(track)
  534. if (timestampData && queuePlayer === this.SQP) {
  535. if (this.queueListingElement.currentItem === track) {
  536. this.queueListingElement.selectTimestampAtSec(track, startTime)
  537. }
  538. }
  539. if (track && this.enableAutoDJ) {
  540. queuePlayer.setVolumeMultiplier(0.5);
  541. const message = 'now playing: ' + getNameWithoutTrackNumber(track);
  542. if (await commandExists('espeak')) {
  543. await promisifyProcess(spawn('espeak', [message]));
  544. } else if (await commandExists('say')) {
  545. await promisifyProcess(spawn('say', [message]));
  546. }
  547. queuePlayer.fadeIn();
  548. }
  549. }
  550. handleReceivedTimeData(queuePlayer, timeData, oldTimeData) {
  551. const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
  552. if (PIE) {
  553. PIE.updateProgress()
  554. }
  555. if (queuePlayer === this.SQP) {
  556. this.updateQueueLengthLabel()
  557. this.updateQueueSelection(timeData, oldTimeData)
  558. }
  559. }
  560. handleProcessMetadataProgress(remaining) {
  561. this.metadataStatusLabel.text = `Processing metadata - ${remaining} to go.`
  562. this.updateQueueLengthLabel()
  563. }
  564. handleQueueUpdated() {
  565. this.queueListingElement.buildItems()
  566. }
  567. selectQueuePlayer(queuePlayer) {
  568. // You can use this.SQP as a shorthand to get this.
  569. this.selectedQueuePlayer = queuePlayer
  570. this.queueListingElement.loadGrouplike(queuePlayer.queueGrouplike)
  571. this.playbackForm.curIndex = this.playbackForm.inputs
  572. .findIndex(el => el.queuePlayer === queuePlayer)
  573. this.playbackForm.scrollSelectedElementIntoView()
  574. }
  575. selectNextQueuePlayer() {
  576. const { queuePlayers } = this.backend
  577. let index = queuePlayers.indexOf(this.SQP) + 1
  578. if (index >= queuePlayers.length) {
  579. index = 0
  580. }
  581. this.selectQueuePlayer(queuePlayers[index])
  582. }
  583. selectPreviousQueuePlayer() {
  584. const { queuePlayers } = this.backend
  585. let index = queuePlayers.indexOf(this.SQP) - 1
  586. if (index <= -1) {
  587. index = queuePlayers.length - 1
  588. }
  589. this.selectQueuePlayer(queuePlayers[index])
  590. }
  591. async addQueuePlayer() {
  592. if (!this.config.canControlQueuePlayers) {
  593. return false
  594. }
  595. const queuePlayer = await this.backend.addQueuePlayer()
  596. this.selectQueuePlayer(queuePlayer)
  597. }
  598. removeQueuePlayer(queuePlayer) {
  599. if (!this.config.canControlQueuePlayers) {
  600. return false
  601. }
  602. this.backend.removeQueuePlayer(queuePlayer)
  603. }
  604. toggleActOnQueuePlayer(queuePlayer) {
  605. const index = this.queuePlayersToActOn.indexOf(queuePlayer)
  606. if (index >= 0) {
  607. this.queuePlayersToActOn.splice(index, 1)
  608. } else {
  609. this.queuePlayersToActOn.push(queuePlayer)
  610. }
  611. for (const PIE of this.playbackInfoElements) {
  612. PIE.fixLayout()
  613. }
  614. }
  615. getPlaybackInfoElementForQueuePlayer(queuePlayer) {
  616. return this.playbackInfoElements
  617. .find(el => el.queuePlayer === queuePlayer)
  618. }
  619. selected() {
  620. if (this.tabberPane.visible) {
  621. this.root.select(this.tabber)
  622. } else {
  623. if (this.queueListingElement.selectable) {
  624. this.root.select(this.queueListingElement)
  625. } else {
  626. this.menubar.select()
  627. }
  628. }
  629. }
  630. newGrouplikeListing() {
  631. const grouplikeListing = new GrouplikeListingElement(this)
  632. this.tabber.addTab(grouplikeListing)
  633. this.tabber.selectTab(grouplikeListing)
  634. grouplikeListing.on('browse', item => this.browse(grouplikeListing, item))
  635. grouplikeListing.on('download', item => this.SQP.download(item))
  636. grouplikeListing.on('open', item => this.openSpecialOrThroughSystem(item))
  637. grouplikeListing.on('queue', (item, opts) => this.handleQueueOptions(item, opts))
  638. const updateListingsFor = item => {
  639. for (const grouplikeListing of this.tabber.tabberElements) {
  640. if (grouplikeListing.grouplike === item) {
  641. this.browse(grouplikeListing, item, false)
  642. }
  643. }
  644. }
  645. grouplikeListing.on('remove', item => {
  646. if (this.editMode) {
  647. const parent = item[parentSymbol]
  648. const index = parent.items.indexOf(item)
  649. this.undoManager.pushAction({
  650. activate: () => {
  651. parent.items.splice(index, 1)
  652. delete item[parentSymbol]
  653. updateListingsFor(item)
  654. updateListingsFor(parent)
  655. },
  656. undo: () => {
  657. parent.items.splice(index, 0, item)
  658. item[parentSymbol] = parent
  659. updateListingsFor(item)
  660. updateListingsFor(parent)
  661. }
  662. })
  663. }
  664. })
  665. grouplikeListing.on('mark', item => {
  666. if (this.editMode) {
  667. if (!this.markGrouplike.items.includes(item)) {
  668. this.undoManager.pushAction({
  669. activate: () => {
  670. this.markGrouplike.items.push(item)
  671. },
  672. undo: () => {
  673. this.markGrouplike.items.pop()
  674. }
  675. })
  676. } else {
  677. const index = this.markGrouplike.items.indexOf(item)
  678. this.undoManager.pushAction({
  679. activate: () => {
  680. this.markGrouplike.items.splice(index, 1)
  681. },
  682. undo: () => {
  683. this.markGrouplike.items.splice(index, 0, item)
  684. }
  685. })
  686. }
  687. }
  688. })
  689. grouplikeListing.on('paste', (item, {where = 'below'} = {}) => {
  690. if (this.editMode && this.markGrouplike.items.length) {
  691. let parent, index
  692. if (where === 'above') {
  693. parent = item[parentSymbol]
  694. index = parent.items.indexOf(item)
  695. } else if (where === 'below') {
  696. parent = item[parentSymbol]
  697. index = parent.items.indexOf(item) + 1
  698. }
  699. this.undoManager.pushAction({
  700. activate: () => {
  701. parent.items.splice(index, 0, ...cloneGrouplike(this.markGrouplike).items.map(
  702. item => Object.assign({}, item, {[parentSymbol]: parent})
  703. ))
  704. updateListingsFor(parent)
  705. },
  706. undo: () => {
  707. parent.items.splice(index, this.markGrouplike.items.length)
  708. updateListingsFor(parent)
  709. }
  710. })
  711. }
  712. })
  713. this.setupCommonGrouplikeListingEvents(grouplikeListing)
  714. return grouplikeListing
  715. }
  716. setupCommonGrouplikeListingEvents(grouplikeListing) {
  717. // Sets up event listeners that are common to ordinary grouplike listings
  718. // (made by newGrouplikeListing) as well as the queue grouplike listing.
  719. grouplikeListing.on('timestamp', (item, time) => this.playOrSeek(item, time))
  720. grouplikeListing.pathElement.on('select', (item, child) => this.revealInLibrary(item, child))
  721. grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing))
  722. /*
  723. grouplikeListing.on('select', item => this.editNotesFile(item, false))
  724. grouplikeListing.on('edit-notes', item => {
  725. this.revealInLibrary(item)
  726. this.editNotesFile(item, true)
  727. })
  728. */
  729. }
  730. showContextMenu(opts) {
  731. const menu = new ContextMenu(this.showContextMenu)
  732. this.menuLayer.addChild(menu)
  733. if (opts.beforeShowing) {
  734. opts.beforeShowing(menu)
  735. }
  736. menu.show(opts)
  737. return menu
  738. }
  739. browse(grouplikeListing, grouplike, ...args) {
  740. this.loadTimestampDataInGrouplike(grouplike)
  741. grouplikeListing.loadGrouplike(grouplike, ...args)
  742. }
  743. revealInLibrary(item, child) {
  744. if (!this.tabberPane.visible) {
  745. return
  746. }
  747. const tabberListing = this.tabber.currentElement
  748. this.root.select(tabberListing)
  749. const parent = item[parentSymbol]
  750. if (isGroup(item)) {
  751. tabberListing.loadGrouplike(item)
  752. if (child) {
  753. tabberListing.selectAndShow(child)
  754. }
  755. } else if (parent) {
  756. if (tabberListing.grouplike !== parent) {
  757. tabberListing.loadGrouplike(parent)
  758. }
  759. tabberListing.selectAndShow(item)
  760. }
  761. }
  762. revealInQueue(item) {
  763. const queueListing = this.queueListingElement
  764. if (queueListing.selectAndShow(item)) {
  765. this.root.select(queueListing)
  766. }
  767. }
  768. play(item) {
  769. if (!this.config.canControlQueue) {
  770. return
  771. }
  772. this.SQP.play(item)
  773. }
  774. playOrSeek(item, time) {
  775. if (!this.config.canControlQueue || !this.config.canControlPlayback) {
  776. return
  777. }
  778. this.SQP.playOrSeek(item, time)
  779. }
  780. unqueue(item) {
  781. if (!this.config.canControlQueue) {
  782. return
  783. }
  784. let focusItem = this.queueListingElement.currentItem
  785. focusItem = this.SQP.unqueue(item, focusItem)
  786. this.queueListingElement.buildItems()
  787. this.updateQueueLengthLabel()
  788. if (focusItem) {
  789. this.queueListingElement.selectAndShow(focusItem)
  790. }
  791. }
  792. playSooner(item) {
  793. if (!this.config.canControlQueue) {
  794. return
  795. }
  796. this.SQP.playSooner(item)
  797. // It may not have queued as soon as the user wants; in that case, they'll
  798. // want to queue it sooner again. Automatically reselect the track so that
  799. // this they don't have to navigate back to it by hand.
  800. this.queueListingElement.selectAndShow(item)
  801. }
  802. playLater(item) {
  803. if (!this.config.canControlQueue) {
  804. return
  805. }
  806. this.SQP.playLater(item)
  807. // Just for consistency with playSooner (you can press ^-L to quickly get
  808. // back to the current track).
  809. this.queueListingElement.selectAndShow(item)
  810. }
  811. clearQueuePast(item) {
  812. if (!this.config.canControlQueue) {
  813. return
  814. }
  815. this.SQP.clearQueuePast(item)
  816. this.queueListingElement.selectAndShow(item)
  817. }
  818. clearQueueUpTo(item) {
  819. if (!this.config.canControlQueue) {
  820. return
  821. }
  822. this.SQP.clearQueueUpTo(item)
  823. this.queueListingElement.selectAndShow(item)
  824. }
  825. shareWithParty(item) {
  826. this.backend.shareWithParty(item)
  827. }
  828. replaceMark(items) {
  829. this.markGrouplike.items = items.slice(0) // Don't share the array! :)
  830. this.emitMarkChanged()
  831. }
  832. unmarkAll() {
  833. this.markGrouplike.items = []
  834. this.emitMarkChanged()
  835. }
  836. markItem(item) {
  837. if (isGroup(item)) {
  838. for (const child of item.items) {
  839. this.markItem(child)
  840. }
  841. } else {
  842. const { items } = this.markGrouplike
  843. if (!items.includes(item)) {
  844. items.push(item)
  845. this.emitMarkChanged()
  846. }
  847. }
  848. }
  849. unmarkItem(item) {
  850. if (isGroup(item)) {
  851. for (const child of item.items) {
  852. this.unmarkItem(child)
  853. }
  854. } else {
  855. const { items } = this.markGrouplike
  856. if (items.includes(item)) {
  857. items.splice(items.indexOf(item), 1)
  858. this.emitMarkChanged()
  859. }
  860. }
  861. }
  862. getMarkStatus(item) {
  863. if (!this.cachedMarkStatuses.get(item)) {
  864. const { items } = this.markGrouplike
  865. let status
  866. if (isGroup(item)) {
  867. const tracks = flattenGrouplike(item).items
  868. if (tracks.every(track => items.includes(track))) {
  869. status = 'marked'
  870. } else if (tracks.some(track => items.includes(track))) {
  871. status = 'partial'
  872. } else {
  873. status = 'unmarked'
  874. }
  875. } else {
  876. if (items.includes(item)) {
  877. status = 'marked'
  878. } else {
  879. status = 'unmarked'
  880. }
  881. }
  882. this.cachedMarkStatuses.set(item, status)
  883. }
  884. return this.cachedMarkStatuses.get(item)
  885. }
  886. emitMarkChanged() {
  887. this.clearCachedMarkStatuses()
  888. this.emit('mark changed')
  889. }
  890. clearCachedMarkStatuses() {
  891. this.cachedMarkStatuses = new Map()
  892. this.scheduleDrawWithoutPropertyChange()
  893. }
  894. pauseAll() {
  895. if (!this.config.canControlPlayback) {
  896. return
  897. }
  898. for (const queuePlayer of this.backend.queuePlayers) {
  899. queuePlayer.setPause(true)
  900. }
  901. }
  902. resumeAll() {
  903. if (!this.config.canControlPlayback) {
  904. return
  905. }
  906. for (const queuePlayer of this.backend.queuePlayers) {
  907. queuePlayer.setPause(false)
  908. }
  909. }
  910. async createNotesFile(item) {
  911. if (!item[parentSymbol]) {
  912. return
  913. }
  914. if (!item.url) {
  915. return
  916. }
  917. if (getCorrespondingFileForItem(item, '.txt')) {
  918. return
  919. }
  920. let itemPath
  921. try {
  922. itemPath = url.fileURLToPath(item.url)
  923. } catch (error) {
  924. return
  925. }
  926. const dirname = path.dirname(itemPath)
  927. const extname = path.extname(itemPath)
  928. const basename = path.basename(itemPath, extname)
  929. const name = basename + '.txt'
  930. const filePath = path.join(dirname, name)
  931. const fileURL = url.pathToFileURL(filePath).toString()
  932. const file = {name, url: fileURL}
  933. await writeFile(filePath, '\n')
  934. const { items } = item[parentSymbol]
  935. items.splice(items.indexOf(item), 0, file)
  936. }
  937. /*
  938. async editNotesFile(item, focus) {
  939. if (!item) {
  940. return
  941. }
  942. // Creates it, if it doesn't exist.
  943. // We only do this when we're manually selecting the file (and expect to
  944. // focus it). Otherwise we'd create a notes file for every track hovered
  945. // over!
  946. if (focus) {
  947. await this.createNotesFile(item)
  948. }
  949. const doubleCheckItem = () => {
  950. const listing = this.root.selectedElement.directAncestors.find(el => el instanceof GrouplikeListingElement)
  951. return listing && listing.currentItem === item
  952. }
  953. if (!doubleCheckItem()) {
  954. return
  955. }
  956. const status = await this.textEditor.openItem(item, {doubleCheckItem})
  957. let fixLayout
  958. if (status === true) {
  959. this.textInfoPane.visible = true
  960. fixLayout = true
  961. } else if (status === false) {
  962. this.textInfoPane.visible = false
  963. fixLayout = true
  964. }
  965. if (focus && (status === true || status === null) && doubleCheckItem()) {
  966. this.root.select(this.textEditor)
  967. fixLayout = true
  968. }
  969. if (fixLayout) {
  970. this.fixLayout()
  971. }
  972. }
  973. */
  974. expandTimestamps(item, listing) {
  975. listing.expandTimestamps(item)
  976. }
  977. collapseTimestamps(item, listing) {
  978. listing.collapseTimestamps(item)
  979. }
  980. toggleTimestamps(item, listing) {
  981. listing.toggleTimestamps(item)
  982. }
  983. timestampsExpanded(item, listing) {
  984. return listing.timestampsExpanded(item)
  985. }
  986. hasTimestampsFile(item) {
  987. return !!this.getTimestampsFile(item)
  988. }
  989. getTimestampsFile(item) {
  990. // Only tracks have timestamp files!
  991. if (!isTrack(item)) {
  992. return false
  993. }
  994. return getCorrespondingFileForItem(item, '.timestamps.txt')
  995. }
  996. async loadTimestampDataInGrouplike(grouplike) {
  997. // Only load data for a grouplike once.
  998. if (this.timestampDictionary.has(grouplike)) {
  999. return
  1000. }
  1001. this.timestampDictionary.set(grouplike, true)
  1002. // There's no parallelization here, but like, whateeeever.
  1003. for (const item of grouplike.items) {
  1004. if (!isTrack(item)) {
  1005. continue
  1006. }
  1007. if (this.timestampDictionary.has(item)) {
  1008. continue
  1009. }
  1010. if (!this.hasTimestampsFile(item)) {
  1011. this.timestampDictionary.set(item, false)
  1012. continue
  1013. }
  1014. this.timestampDictionary.set(item, null)
  1015. const data = await this.readTimestampData(item)
  1016. this.timestampDictionary.set(item, data)
  1017. }
  1018. }
  1019. getTimestampData(item) {
  1020. return this.timestampDictionary.get(item) || null
  1021. }
  1022. getTimestampAtSec(item, sec) {
  1023. const timestampData = this.getTimestampData(item)
  1024. if (!timestampData) {
  1025. return null
  1026. }
  1027. // Just like, start from the end, man.
  1028. // Why doesn't JavaScript have a findIndexFromEnd function???
  1029. for (let i = timestampData.length - 1; i >= 0; i--) {
  1030. const ts = timestampData[i];
  1031. if (
  1032. ts.timestamp <= sec &&
  1033. ts.timestampEnd >= sec
  1034. ) {
  1035. return ts
  1036. }
  1037. }
  1038. return null
  1039. }
  1040. async readTimestampData(item) {
  1041. const file = this.getTimestampsFile(item)
  1042. if (!file) {
  1043. return null
  1044. }
  1045. let filePath
  1046. try {
  1047. filePath = url.fileURLToPath(new URL(file.url))
  1048. } catch (error) {
  1049. return null
  1050. }
  1051. let contents
  1052. try {
  1053. contents = (await readFile(filePath)).toString()
  1054. } catch (error) {
  1055. return null
  1056. }
  1057. if (contents.startsWith('[')) {
  1058. try {
  1059. return JSON.parse(contents)
  1060. } catch (error) {
  1061. return null
  1062. }
  1063. }
  1064. const lines = contents.split('\n')
  1065. .filter(line => !line.startsWith('#'))
  1066. .filter(line => line)
  1067. const metadata = this.backend.getMetadataFor(item)
  1068. const duration = (metadata ? metadata.duration : Infinity)
  1069. const data = lines
  1070. .map(line => line.match(/^\s*([0-9:.]+)\s*(\S.*)\s*$/))
  1071. .filter(match => match)
  1072. .map(match => ({timestamp: getSecFromTimestamp(match[1]), comment: match[2]}))
  1073. .filter(({ timestamp: sec }) => !isNaN(sec))
  1074. .map((cur, i, arr) =>
  1075. (i + 1 === arr.length
  1076. ? {...cur, timestampEnd: duration}
  1077. : {...cur, timestampEnd: arr[i + 1].timestamp}))
  1078. return data
  1079. }
  1080. openSpecialOrThroughSystem(item) {
  1081. if (item.url.endsWith('.json')) {
  1082. return this.loadPlaylistOrSource(item.url, true)
  1083. /*
  1084. } else if (item.url.endsWith('.txt')) {
  1085. if (this.textInfoPane.visible) {
  1086. this.root.select(this.textEditor)
  1087. }
  1088. */
  1089. } else {
  1090. return this.openThroughSystem(item)
  1091. }
  1092. }
  1093. openThroughSystem(item) {
  1094. if (!isOpenable(item)) {
  1095. return
  1096. }
  1097. open(item.url)
  1098. }
  1099. set actOnAllPlayers(val) {
  1100. if (val) {
  1101. this.queuePlayersToActOn = this.backend.queuePlayers.slice()
  1102. } else {
  1103. this.queuePlayersToActOn = []
  1104. }
  1105. }
  1106. get actOnAllPlayers() {
  1107. return this.queuePlayersToActOn.length === this.backend.queuePlayers.length
  1108. }
  1109. willActOnQueuePlayer(queuePlayer) {
  1110. if (this.queuePlayersToActOn.length) {
  1111. if (this.queuePlayersToActOn.includes(queuePlayer)) {
  1112. return 'marked'
  1113. }
  1114. } else if (queuePlayer === this.SQP) {
  1115. return '=SQP'
  1116. }
  1117. }
  1118. skipBackOrSeekToStart() {
  1119. // Perform the same action - skipping to the previous track or seeking to
  1120. // the start of the current track - for all target queue players. If any is
  1121. // past an arbitrary time position (default 3 seconds), seek to start; if
  1122. // all are before this position, skip to previous.
  1123. let maxCurSec = 0
  1124. this.forEachQueuePlayerToActOn(qp => {
  1125. if (qp.timeData) {
  1126. let effectiveCurSec = qp.timeData.curSecTotal
  1127. const ts = (qp.timeData &&
  1128. this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
  1129. if (ts) {
  1130. effectiveCurSec -= ts.timestamp
  1131. }
  1132. maxCurSec = Math.max(maxCurSec, effectiveCurSec)
  1133. }
  1134. })
  1135. if (Math.floor(maxCurSec) < this.config.seekToStartThreshold) {
  1136. this.skipBack()
  1137. } else {
  1138. this.seekToStart()
  1139. }
  1140. }
  1141. seekToStart() {
  1142. this.actOnQueuePlayers(qp => qp.seekToStart())
  1143. this.actOnQueuePlayers(qp => {
  1144. if (!qp.playingTrack) {
  1145. return
  1146. }
  1147. const ts = (qp.timeData &&
  1148. this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
  1149. if (ts) {
  1150. qp.seekTo(ts.timestamp)
  1151. return
  1152. }
  1153. qp.seekToStart()
  1154. })
  1155. }
  1156. skipBack() {
  1157. this.actOnQueuePlayers(qp => {
  1158. if (!qp.playingTrack) {
  1159. return
  1160. }
  1161. const ts = (qp.timeData &&
  1162. this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
  1163. if (ts) {
  1164. const timestampData = this.getTimestampData(qp.playingTrack)
  1165. const playingTimestampIndex = timestampData.indexOf(ts)
  1166. const previous = timestampData[playingTimestampIndex - 1]
  1167. if (previous) {
  1168. qp.seekTo(previous.timestamp)
  1169. return
  1170. }
  1171. }
  1172. qp.playPrevious(qp.playingTrack, true)
  1173. })
  1174. }
  1175. skipAhead() {
  1176. this.actOnQueuePlayers(qp => {
  1177. if (!qp.playingTrack) {
  1178. return
  1179. }
  1180. const ts = (qp.timeData &&
  1181. this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
  1182. if (ts) {
  1183. const timestampData = this.getTimestampData(qp.playingTrack)
  1184. const playingTimestampIndex = timestampData.indexOf(ts)
  1185. const next = timestampData[playingTimestampIndex + 1]
  1186. if (next) {
  1187. qp.seekTo(next.timestamp)
  1188. return
  1189. }
  1190. }
  1191. qp.playNext(qp.playingTrack, true)
  1192. })
  1193. }
  1194. actOnQueuePlayers(fn) {
  1195. this.forEachQueuePlayerToActOn(queuePlayer => {
  1196. fn(queuePlayer)
  1197. const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
  1198. if (PIE) {
  1199. PIE.updateProgress()
  1200. }
  1201. })
  1202. }
  1203. forEachQueuePlayerToActOn(fn) {
  1204. const actOn = this.queuePlayersToActOn.length ? this.queuePlayersToActOn : [this.SQP]
  1205. actOn.forEach(fn)
  1206. }
  1207. showMenuForItemElement(el, listing) {
  1208. // const { editMode } = this
  1209. const { canControlQueue, canProcessMetadata } = this.config
  1210. // const anyMarked = editMode && this.markGrouplike.items.length > 0
  1211. const generatePageForItem = item => {
  1212. const emitControls = play => () => {
  1213. this.handleQueueOptions(item, {
  1214. where: this.whereControl.curValue,
  1215. order: this.orderControl.curValue,
  1216. play: play
  1217. })
  1218. }
  1219. const itemPath = getItemPath(item)
  1220. const [rootGroup, _partySources, sharedGroup] = itemPath
  1221. // This is the hack mentioned in the todo!!!!
  1222. if (this.config.showPartyControls && rootGroup.isPartySources) {
  1223. const playlists = this.tabber.tabberElements
  1224. .map(grouplikeListing => getItemPath(grouplikeListing.grouplike)[0])
  1225. .filter(root => !root.isPartySources)
  1226. let possibleChoices = []
  1227. if (item.downloaderArg) {
  1228. possibleChoices = getFlatTrackList({items: playlists})
  1229. } else if (item.items) {
  1230. possibleChoices = getFlatGroupList({items: playlists})
  1231. }
  1232. if (possibleChoices) {
  1233. item = findItemObject(item, possibleChoices)
  1234. }
  1235. if (!item) {
  1236. return [
  1237. {label: `(Couldn't find this in your music)`}
  1238. ]
  1239. }
  1240. }
  1241. // const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt')
  1242. const timestampsItem = this.hasTimestampsFile(item) && (this.timestampsExpanded(item, listing)
  1243. ? {label: 'Collapse saved timestamps', action: () => this.collapseTimestamps(item, listing)}
  1244. : {label: 'Expand saved timestamps', action: () => this.expandTimestamps(item, listing)}
  1245. )
  1246. const isQueued = this.SQP.queueGrouplike.items.includes(item)
  1247. if (listing.grouplike.isTheQueue && isTrack(item)) {
  1248. return [
  1249. item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal in library', action: () => this.revealInLibrary(item)},
  1250. timestampsItem,
  1251. {divider: true},
  1252. canControlQueue && {label: 'Play later', action: () => this.playLater(item)},
  1253. canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)},
  1254. {divider: true},
  1255. canControlQueue && {label: 'Clear past this track', action: () => this.clearQueuePast(item)},
  1256. canControlQueue && {label: 'Clear up to this track', action: () => this.clearQueueUpTo(item)},
  1257. {divider: true},
  1258. {label: 'Autoscroll', action: () => listing.toggleAutoscroll()},
  1259. {divider: true},
  1260. canControlQueue && {label: 'Remove from queue', action: () => this.unqueue(item)}
  1261. ]
  1262. } else {
  1263. const numTracks = countTotalTracks(item)
  1264. const { string: durationString } = this.backend.getDuration(item)
  1265. return [
  1266. // A label that just shows some brief information about the item.
  1267. {label:
  1268. `(${item.name ? `"${item.name}"` : 'Unnamed'} - ` +
  1269. (isGroup(item) ? ` ${numTracks} track${numTracks === 1 ? '' : 's'}, ` : '') +
  1270. durationString +
  1271. ')',
  1272. keyboardIdentifier: item.name,
  1273. isPageSwitcher: true
  1274. },
  1275. // The actual controls!
  1276. {divider: true},
  1277. // TODO: Don't emit these on the element (and hence receive them from
  1278. // the listing) - instead, handle their behavior directly. We'll want
  1279. // to move the "mark"/"paste" (etc) code into separate functions,
  1280. // instead of just defining their behavior inside the listing event
  1281. // handlers.
  1282. // editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')},
  1283. // anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})},
  1284. // anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})},
  1285. // anyMarked && !this.isReal && {label: 'Paste', action: () => this.emit('paste')}, // No "above" or "elow" in the label because the "fake" item/row will be replaced (it'll disappear, since there'll be an item in the group)
  1286. // {divider: true},
  1287. ...((this.config.showPartyControls && !rootGroup.isPartySources)
  1288. ? [
  1289. {label: 'Share with party', action: () => this.shareWithParty(item)},
  1290. {divider: true}
  1291. ]
  1292. : [
  1293. canControlQueue && isPlayable(item) && {element: this.whereControl},
  1294. canControlQueue && isGroup(item) && {element: this.orderControl},
  1295. canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)},
  1296. canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)},
  1297. {divider: true},
  1298. canProcessMetadata && isGroup(item) && {label: 'Process metadata (new entries)', action: () => setTimeout(() => this.processMetadata(item, false))},
  1299. canProcessMetadata && isGroup(item) && {label: 'Process metadata (reprocess)', action: () => setTimeout(() => this.processMetadata(item, true))},
  1300. canProcessMetadata && isTrack(item) && {label: 'Process metadata', action: () => setTimeout(() => this.processMetadata(item, true))},
  1301. isOpenable(item) && item.url.endsWith('.json') && {label: 'Open (JSON Playlist)', action: () => this.openSpecialOrThroughSystem(item)},
  1302. isOpenable(item) && {label: 'Open (System)', action: () => this.openThroughSystem(item)},
  1303. // !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)},
  1304. // hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)},
  1305. canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)},
  1306. isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)},
  1307. {divider: true},
  1308. timestampsItem,
  1309. ...(item === this.markGrouplike
  1310. ? [{label: 'Deselect all', action: () => this.unmarkAll()}]
  1311. : [
  1312. this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)},
  1313. this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)}
  1314. ])
  1315. ])
  1316. ]
  1317. }
  1318. }
  1319. const pages = [
  1320. this.markGrouplike.items.length && generatePageForItem(this.markGrouplike),
  1321. el.item && generatePageForItem(el.item)
  1322. ].filter(Boolean)
  1323. // TODO: Implement this! :P
  1324. // const isMarked = false
  1325. this.showContextMenu({
  1326. x: el.absLeft,
  1327. y: el.absTop + 1,
  1328. pages
  1329. })
  1330. }
  1331. async loadPlaylistOrSource(sourceOrPlaylist, newTab = false) {
  1332. if (this.openPlaylistDialog.visible) {
  1333. this.openPlaylistDialog.close()
  1334. }
  1335. this.alertDialog.showMessage('Opening playlist...', false)
  1336. let grouplike
  1337. if (typeof sourceOrPlaylist === 'object' && isGroup(sourceOrPlaylist) || sourceOrPlaylist.source) {
  1338. grouplike = sourceOrPlaylist
  1339. } else {
  1340. try {
  1341. grouplike = await this.openPlaylist(sourceOrPlaylist)
  1342. } catch (error) {
  1343. if (error === 'unknown argument') {
  1344. this.alertDialog.showMessage('Could not figure out how to load a playlist from: ' + sourceOrPlaylist)
  1345. } else if (typeof error === 'string') {
  1346. this.alertDialog.showMessage(error)
  1347. } else {
  1348. throw error
  1349. }
  1350. return
  1351. }
  1352. }
  1353. this.alertDialog.close()
  1354. grouplike = await processSmartPlaylist(grouplike)
  1355. // this.playlistSources.push(grouplike)
  1356. // updateRestoredTracksUsingPlaylists(this.backend, this.playlistSources)
  1357. if (!this.tabber.currentElement || newTab && this.tabber.currentElement.grouplike) {
  1358. const grouplikeListing = this.newGrouplikeListing()
  1359. grouplikeListing.loadGrouplike(grouplike)
  1360. } else {
  1361. this.tabber.currentElement.loadGrouplike(grouplike)
  1362. }
  1363. }
  1364. openPlaylist(arg) {
  1365. const crawlers = getAllCrawlersForArg(arg)
  1366. if (crawlers.length === 0) {
  1367. throw 'unknown argument'
  1368. }
  1369. const crawler = crawlers[0]
  1370. return crawler(arg)
  1371. }
  1372. setupDialog(dialog) {
  1373. dialog.visible = false
  1374. this.addChild(dialog)
  1375. dialog.on('cancelled', () => {
  1376. dialog.close()
  1377. })
  1378. }
  1379. async shutdown() {
  1380. if (this.config.stopPlayingUponQuit) {
  1381. await this.backend.stopPlayingAll()
  1382. }
  1383. /*
  1384. await this.textEditor.save()
  1385. */
  1386. this.emit('quitRequested')
  1387. }
  1388. suspend() {
  1389. if (this.config.canSuspend) {
  1390. this.emit('suspendRequested')
  1391. }
  1392. }
  1393. fixLayout() {
  1394. if (this.parent) {
  1395. this.fillParent()
  1396. }
  1397. this.menubar.fixLayout()
  1398. let topY = this.contentH
  1399. if (this.partyBottom.visible) {
  1400. this.partyBottom.w = this.contentW
  1401. this.partyBottom.h = 1
  1402. this.partyBottom.x = 0
  1403. this.partyBottom.y = topY - this.partyBottom.h
  1404. topY = this.partyBottom.top
  1405. this.partyBottomBanner.w = this.partyBottom.w
  1406. }
  1407. this.playbackPane.w = this.contentW
  1408. this.playbackPane.h = 5
  1409. this.playbackPane.x = 0
  1410. this.playbackPane.y = topY - this.playbackPane.h
  1411. topY = this.playbackPane.top
  1412. for (const PIE of this.playbackInfoElements) {
  1413. if (this.playbackInfoElements.length === 1) {
  1414. PIE.displayMode = 'expanded'
  1415. } else {
  1416. PIE.displayMode = 'collapsed'
  1417. }
  1418. }
  1419. this.playbackForm.fillParent()
  1420. this.playbackForm.fixLayout()
  1421. let bottomY = 1
  1422. if (this.partyTop.visible) {
  1423. this.partyTop.w = this.contentW
  1424. this.partyTop.h = 1
  1425. this.partyTop.x = 0
  1426. this.partyTop.y = 1
  1427. bottomY = this.partyTop.bottom
  1428. this.partyTopBanner.w = this.partyTop.w
  1429. this.partyTopBanner.y = this.partyTop.contentH - 1
  1430. this.alignPartyLabel()
  1431. }
  1432. const leftWidth = Math.max(Math.floor(0.7 * this.contentW), this.contentW - 80)
  1433. /*
  1434. if (this.textInfoPane.visible) {
  1435. this.textInfoPane.w = leftWidth
  1436. if (this.textEditor.isSelected) {
  1437. this.textInfoPane.h = 8
  1438. } else {
  1439. this.textEditor.w = this.textInfoPane.contentW
  1440. this.textEditor.rebuildUiLines()
  1441. this.textInfoPane.h = Math.min(8, this.textEditor.getOptimalHeight() + 2)
  1442. }
  1443. this.textEditor.fillParent()
  1444. this.textEditor.fixLayout()
  1445. }
  1446. */
  1447. if (this.logPane.visible) {
  1448. this.logPane.w = leftWidth
  1449. this.logPane.h = 6
  1450. this.log.fillParent()
  1451. this.log.fixAllLayout()
  1452. }
  1453. if (this.tabberPane.visible) {
  1454. this.tabberPane.w = leftWidth
  1455. this.tabberPane.y = bottomY
  1456. this.tabberPane.h = topY - this.tabberPane.y
  1457. if (this.logPane.visible) {
  1458. this.tabberPane.h -= this.logPane.h
  1459. this.logPane.y = this.tabberPane.bottom
  1460. }
  1461. /*
  1462. if (this.textInfoPane.visible) {
  1463. this.tabberPane.h -= this.textInfoPane.h
  1464. this.textInfoPane.y = this.tabberPane.bottom
  1465. }
  1466. */
  1467. this.queuePane.x = this.tabberPane.right
  1468. this.queuePane.w = this.contentW - this.tabberPane.right
  1469. } else {
  1470. this.queuePane.x = 0
  1471. this.queuePane.w = this.contentW
  1472. /*
  1473. if (this.textInfoPane.visible) {
  1474. this.textInfoPane.y = bottomY
  1475. }
  1476. */
  1477. }
  1478. this.queuePane.y = bottomY
  1479. this.queuePane.h = topY - this.queuePane.y
  1480. topY = this.queuePane.y
  1481. this.tabber.fillParent()
  1482. if (this.metadataStatusLabel.visible) {
  1483. this.tabber.h--
  1484. this.metadataStatusLabel.y = this.tabberPane.contentH - 1
  1485. }
  1486. this.tabber.fixLayout()
  1487. this.queueListingElement.fillParent()
  1488. this.queueListingElement.h -= 2
  1489. this.updateQueueLengthLabel()
  1490. this.menuLayer.fillParent()
  1491. }
  1492. alignPartyLabel() {
  1493. this.partyLabel.centerInParent()
  1494. this.partyLabel.y = 0
  1495. }
  1496. attachAsServerHost(telnetServer) {
  1497. this.isPartyHost = true
  1498. this.attachAsServer(telnetServer)
  1499. }
  1500. attachAsServerClient(telnetServer) {
  1501. this.isPartyHost = false
  1502. this.attachAsServer(telnetServer)
  1503. }
  1504. attachAsServer(telnetServer) {
  1505. this.telnetServer = telnetServer
  1506. this.updatePartyLabel()
  1507. this.telnetServer.on('joined', () => this.updatePartyLabel())
  1508. this.telnetServer.on('left', () => this.updatePartyLabel())
  1509. this.partyTop.visible = true
  1510. this.partyBottom.visible = true
  1511. this.fixLayout()
  1512. }
  1513. updatePartyLabel() {
  1514. const clients = this.telnetServer.sockets.length
  1515. const clientsMsg = clients === 1 ? '1-ish connection' : `${clients}-ish connections`
  1516. let msg = `${process.env.USER} playing for ${clientsMsg}`
  1517. this.partyLabel.text = ` ${msg} `
  1518. this.alignPartyLabel()
  1519. }
  1520. keyPressed(keyBuf) {
  1521. if (keyBuf[0] === 0x03) { // Ctrl-C
  1522. this.shutdown()
  1523. return
  1524. } else if (keyBuf[0] === 0x1a) { // Ctrl-Z
  1525. this.suspend()
  1526. return
  1527. }
  1528. if ((telc.isEscape(keyBuf) || telc.isBackspace(keyBuf)) && this.menubar.isSelected) {
  1529. this.menubar.restoreSelection()
  1530. return
  1531. }
  1532. if (this.config.canControlPlayback) {
  1533. if ((telc.isLeft(keyBuf) || telc.isRight(keyBuf)) && this.menubar.isSelected) {
  1534. return // le sigh
  1535. } else if (input.isRight(keyBuf)) {
  1536. this.actOnQueuePlayers(qp => qp.seekAhead(10))
  1537. } else if (input.isLeft(keyBuf)) {
  1538. this.actOnQueuePlayers(qp => qp.seekBack(10))
  1539. } else if (input.isTogglePause(keyBuf)) {
  1540. this.actOnQueuePlayers(qp => qp.togglePause())
  1541. } else if (input.isToggleLoop(keyBuf)) {
  1542. this.actOnQueuePlayers(qp => qp.toggleLoop())
  1543. } else if (input.isVolumeUp(keyBuf)) {
  1544. this.actOnQueuePlayers(qp => qp.volUp())
  1545. } else if (input.isVolumeDown(keyBuf)) {
  1546. this.actOnQueuePlayers(qp => qp.volDown())
  1547. } else if (input.isStop(keyBuf)) {
  1548. this.actOnQueuePlayers(qp => qp.stopPlaying())
  1549. } else if (input.isSkipBack(keyBuf)) {
  1550. this.skipBackOrSeekToStart()
  1551. } else if (input.isSkipAhead(keyBuf)) {
  1552. this.skipAhead()
  1553. }
  1554. }
  1555. if (input.isFocusTabber(keyBuf) && this.tabberPane.visible && this.tabber.selectable) {
  1556. this.root.select(this.tabber)
  1557. } else if (input.isFocusQueue(keyBuf) && this.queueListingElement.selectable) {
  1558. this.root.select(this.queueListingElement)
  1559. } else if (input.isFocusPlaybackInfo(keyBuf) && this.backend.queuePlayers.length > 1) {
  1560. this.root.select(this.playbackForm)
  1561. } else if (input.isFocusMenubar(keyBuf)) {
  1562. if (this.menubar.isSelected) {
  1563. this.menubar.restoreSelection()
  1564. } else {
  1565. // If we've got a menu open, close it, restoring selection to the
  1566. // element selected before the menu was opened, so the menubar will
  1567. // see that as the previously selected element (instead of the context
  1568. // menu - which will be closed irregardless and gone when the menubar
  1569. // tries to restore the selection).
  1570. if (this.menuLayer.children[0]) {
  1571. this.menuLayer.children[0].close()
  1572. }
  1573. this.menubar.select()
  1574. }
  1575. } else if (this.editMode && keyBuf.equals(Buffer.from([14]))) { // ctrl-N
  1576. this.newEmptyTab()
  1577. } else if (keyBuf.equals(Buffer.from([15]))) { // ctrl-O
  1578. this.openPlaylistDialog.open()
  1579. } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from([20]))) { // ctrl-T
  1580. this.cloneCurrentTab()
  1581. } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from([23]))) { // ctrl-W
  1582. if (this.tabber.tabberElements.length > 1) {
  1583. this.closeCurrentTab()
  1584. }
  1585. } else if (telc.isCharacter(keyBuf, 'u')) {
  1586. this.undoManager.undoLastAction()
  1587. } else if (telc.isCharacter(keyBuf, 'U')) {
  1588. this.undoManager.redoLastUndoneAction()
  1589. } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from(['t'.charCodeAt(0)]))) {
  1590. this.tabber.nextTab()
  1591. } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from(['T'.charCodeAt(0)]))) {
  1592. this.tabber.previousTab()
  1593. } else if (input.isPreviousPlayer(keyBuf)) {
  1594. this.selectPreviousQueuePlayer()
  1595. } else if (input.isNextPlayer(keyBuf)) {
  1596. this.selectNextQueuePlayer()
  1597. } else if (input.isNewPlayer(keyBuf)) {
  1598. this.addQueuePlayer()
  1599. } else if (input.isRemovePlayer(keyBuf)) {
  1600. this.removeQueuePlayer(this.SQP)
  1601. } else if (input.isActOnPlayer(keyBuf)) {
  1602. this.toggleActOnQueuePlayer(this.SQP)
  1603. } else {
  1604. super.keyPressed(keyBuf)
  1605. }
  1606. }
  1607. newEmptyTab() {
  1608. const listing = this.newGrouplikeListing()
  1609. listing.loadGrouplike({
  1610. name: 'New Playlist',
  1611. items: []
  1612. })
  1613. }
  1614. cloneCurrentTab() {
  1615. const grouplike = this.tabber.currentElement.grouplike
  1616. const listing = this.newGrouplikeListing()
  1617. listing.loadGrouplike(grouplike)
  1618. }
  1619. closeCurrentTab() {
  1620. const listing = this.tabber.currentElement
  1621. let index
  1622. this.undoManager.pushAction({
  1623. activate: () => {
  1624. index = this.tabber.currentElementIndex
  1625. this.tabber.closeTab(this.tabber.currentElement)
  1626. },
  1627. undo: () => {
  1628. this.tabber.addTab(listing, index)
  1629. this.tabber.selectTab(listing)
  1630. }
  1631. })
  1632. }
  1633. shuffleQueue() {
  1634. this.SQP.shuffleQueue()
  1635. }
  1636. clearQueue() {
  1637. this.SQP.clearQueue()
  1638. this.queueListingElement.selectNone()
  1639. this.updateQueueLengthLabel()
  1640. if (this.queueListingElement.isSelected && !this.queueListingElement.selectable) {
  1641. this.root.select(this.tabber)
  1642. }
  1643. }
  1644. // TODO: I'd like to name/incorporate this function better.. for now it's
  1645. // just directly moved from the old event listener on grouplikeListings for
  1646. // 'queue'.
  1647. handleQueueOptions(item, {where = 'end', order = 'normal', play = false, skip = false} = {}) {
  1648. if (!this.config.canControlQueue) {
  1649. return
  1650. }
  1651. const passedItem = item
  1652. let { playingTrack } = this.SQP
  1653. if (skip && playingTrack === item) {
  1654. this.SQP.playNext(playingTrack)
  1655. }
  1656. const oldName = item.name
  1657. if (isGroup(item)) {
  1658. switch (order) {
  1659. case 'shuffle':
  1660. item = {
  1661. name: `${oldName} (shuffled)`,
  1662. items: shuffleArray(flattenGrouplike(item).items)
  1663. }
  1664. break
  1665. case 'shuffle-groups':
  1666. item = shuffleOrderOfGroups(item)
  1667. item.name = `${oldName} (group order shuffled)`
  1668. break
  1669. case 'reverse':
  1670. item = {
  1671. name: `${oldName} (reversed)`,
  1672. items: flattenGrouplike(item).items.reverse()
  1673. }
  1674. break
  1675. case 'reverse-groups':
  1676. item = reverseOrderOfGroups(item)
  1677. item.name = `${oldName} (group order reversed)`
  1678. break
  1679. case 'alphabetic':
  1680. item = {
  1681. name: `${oldName} (alphabetic)`,
  1682. items: orderBy(
  1683. flattenGrouplike(item).items,
  1684. t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '')
  1685. )
  1686. }
  1687. break
  1688. case 'alphabetic-groups':
  1689. item = {
  1690. name: `${oldName} (group order alphabetic)`,
  1691. items: orderBy(
  1692. collapseGrouplike(item).items,
  1693. t => t.name.replace(/[^a-zA-Z0-9]/g, '')
  1694. )
  1695. }
  1696. break
  1697. }
  1698. } else {
  1699. // Make it into a grouplike that just contains itself.
  1700. item = {name: oldName, items: [item]}
  1701. }
  1702. if (where === 'next' || where === 'after-selected' || where === 'before-selected' || where === 'end') {
  1703. const selected = this.queueListingElement.currentItem
  1704. let afterItem = null
  1705. if (where === 'next') {
  1706. afterItem = playingTrack
  1707. } else if (where === 'after-selected') {
  1708. afterItem = selected
  1709. } else if (where === 'before-selected') {
  1710. const { items } = this.SQP.queueGrouplike
  1711. const index = items.indexOf(selected)
  1712. if (index === 0) {
  1713. afterItem = 'FRONT'
  1714. } else if (index > 0) {
  1715. afterItem = items[index - 1]
  1716. }
  1717. }
  1718. this.SQP.queue(item, afterItem, {
  1719. movePlayingTrack: order === 'normal' || order === 'alphabetic'
  1720. })
  1721. if (isTrack(passedItem)) {
  1722. this.queueListingElement.selectAndShow(passedItem)
  1723. } else {
  1724. this.queueListingElement.selectAndShow(selected)
  1725. }
  1726. } else if (where.startsWith('distribute-')) {
  1727. this.SQP.distributeQueue(item, {
  1728. how: where.slice('distribute-'.length)
  1729. })
  1730. }
  1731. this.updateQueueLengthLabel()
  1732. if (play) {
  1733. this.play(item)
  1734. }
  1735. }
  1736. async processMetadata(item, reprocess = false) {
  1737. if (!this.config.canProcessMetadata) {
  1738. return
  1739. }
  1740. if (this.clearMetadataStatusTimeout) {
  1741. clearTimeout(this.clearMetadataStatusTimeout)
  1742. }
  1743. this.metadataStatusLabel.text = 'Processing metadata...'
  1744. this.metadataStatusLabel.visible = true
  1745. this.fixLayout()
  1746. const counter = await this.backend.processMetadata(item, reprocess)
  1747. const tracksMsg = (counter === 1) ? '1 track' : `${counter} tracks`
  1748. this.metadataStatusLabel.text = `Done processing metadata of ${tracksMsg}!`
  1749. this.clearMetadataStatusTimeout = setTimeout(() => {
  1750. this.clearMetadataStatusTimeout = null
  1751. this.metadataStatusLabel.text = ''
  1752. this.metadataStatusLabel.visible = false
  1753. this.fixLayout()
  1754. }, 3000)
  1755. }
  1756. updateQueueLengthLabel() {
  1757. if (!this.SQP) {
  1758. this.queueTimeLabel.text = ''
  1759. return
  1760. }
  1761. const { playingTrack, timeData, queueEndMode } = this.SQP
  1762. const { items } = this.SQP.queueGrouplike
  1763. const {
  1764. currentInput: currentInput,
  1765. currentItem: selectedTrack
  1766. } = this.queueListingElement
  1767. const isTimestamp = (currentInput instanceof TimestampGrouplikeItemElement)
  1768. let trackRemainSec = 0
  1769. let trackPassedSec = 0
  1770. if (timeData) {
  1771. const { curSecTotal = 0, lenSecTotal = 0 } = timeData
  1772. trackRemainSec = lenSecTotal - curSecTotal
  1773. trackPassedSec = curSecTotal
  1774. }
  1775. const playingIndex = items.indexOf(playingTrack)
  1776. const selectedIndex = items.indexOf(selectedTrack)
  1777. const timestampData = playingTrack && this.getTimestampData(playingTrack)
  1778. // This will be set to a list of tracks, which will later be used to
  1779. // calculate a particular duration (as described below) to be shown in
  1780. // the time label.
  1781. let durationRange
  1782. // This will be added to the calculated duration before it is displayed.
  1783. // It's used to account for the time of the current track, if that is
  1784. // relevant to the particular duration being calculated.
  1785. let durationAdd
  1786. // This will be stuck behind the final duration when it is displayed. It's
  1787. // used to indicate the "direction" of the calculated duration to the user.
  1788. let durationSymbol
  1789. // Depending on which track is selected relative to which track is playing
  1790. // (and on whether any track is playing at all), display...
  1791. if (!playingTrack) {
  1792. // Full length of the queue.
  1793. durationRange = items
  1794. durationAdd = 0
  1795. durationSymbol = ''
  1796. } else if (
  1797. selectedIndex === playingIndex &&
  1798. (!isTimestamp || currentInput.isCurrentTimestamp)
  1799. ) {
  1800. // Remaining length of the queue.
  1801. if (timeData) {
  1802. durationRange = items.slice(playingIndex + 1)
  1803. durationAdd = trackRemainSec
  1804. } else {
  1805. durationRange = items.slice(playingIndex)
  1806. durationAdd = 0
  1807. }
  1808. durationSymbol = ''
  1809. } else if (
  1810. selectedIndex < playingIndex ||
  1811. (isTimestamp && currentInput.data.timestamp <= trackPassedSec)
  1812. ) {
  1813. // Time since the selected track ended.
  1814. durationRange = items.slice(selectedIndex + 1, playingIndex)
  1815. durationAdd = trackPassedSec // defaults to 0: no need to check timeData
  1816. durationSymbol = '-'
  1817. if (isTimestamp) {
  1818. if (selectedIndex < playingIndex) {
  1819. durationRange.unshift(items[selectedIndex])
  1820. }
  1821. durationAdd -= currentInput.data.timestampEnd
  1822. }
  1823. } else if (
  1824. selectedIndex > playingIndex ||
  1825. (isTimestamp && currentInput.data.timestamp > trackPassedSec)
  1826. ) {
  1827. // Time until the selected track begins.
  1828. if (timeData) {
  1829. if (selectedIndex === playingIndex) {
  1830. durationRange = []
  1831. durationAdd = -trackPassedSec
  1832. } else {
  1833. durationRange = items.slice(playingIndex + 1, selectedIndex)
  1834. durationAdd = trackRemainSec
  1835. }
  1836. } else {
  1837. durationRange = items.slice(playingIndex, selectedIndex)
  1838. durationAdd = 0
  1839. }
  1840. if (isTimestamp) {
  1841. durationAdd += currentInput.data.timestamp
  1842. }
  1843. durationSymbol = '+'
  1844. }
  1845. // Use the duration* variables to calculate and display the specified
  1846. // duration.
  1847. const { seconds: durationCalculated, approxSymbol } = this.backend.getDuration({items: durationRange})
  1848. const durationTotal = durationCalculated + durationAdd
  1849. const { duration: durationString } = getTimeStringsFromSec(0, durationTotal)
  1850. this.queueTimeLabel.text = `(${durationSymbol + durationString + approxSymbol})`
  1851. if (playingTrack) {
  1852. let trackPart
  1853. let trackPartShort
  1854. let trackPartReallyShort
  1855. {
  1856. const distance = Math.abs(selectedIndex - playingIndex)
  1857. let insertString
  1858. let insertStringShort
  1859. if (selectedIndex < playingIndex) {
  1860. insertString = ` (-${distance})`
  1861. insertStringShort = `-${distance}`
  1862. } else if (selectedIndex > playingIndex) {
  1863. insertString = ` (+${distance})`
  1864. insertStringShort = `+${distance}`
  1865. } else {
  1866. insertString = ''
  1867. insertStringShort = ''
  1868. }
  1869. trackPart = `${playingIndex + 1 + insertString} / ${items.length}`
  1870. trackPartShort = (insertString
  1871. ? `${playingIndex + 1 + insertStringShort}/${items.length}`
  1872. : `${playingIndex + 1}/${items.length}`)
  1873. trackPartReallyShort = (insertString
  1874. ? insertStringShort
  1875. : `#${playingIndex + 1}`)
  1876. }
  1877. let timestampPart
  1878. if (isTimestamp && selectedIndex === playingIndex) {
  1879. const selectedTimestampIndex = timestampData.indexOf(currentInput.data)
  1880. const found = timestampData.findIndex(ts => ts.timestamp > trackPassedSec)
  1881. const playingTimestampIndex = (found >= 0 ? found - 1 : 0)
  1882. const distance = Math.abs(selectedTimestampIndex - playingTimestampIndex)
  1883. let insertString
  1884. if (selectedTimestampIndex < playingTimestampIndex) {
  1885. insertString = ` (-${distance})`
  1886. } else if (selectedTimestampIndex > playingTimestampIndex) {
  1887. insertString = ` (+${distance})`
  1888. } else {
  1889. insertString = ''
  1890. }
  1891. timestampPart = `${playingTimestampIndex + 1 + insertString} / ${timestampData.length}`
  1892. }
  1893. let queueLoopPart
  1894. let queueLoopPartShort
  1895. if (selectedIndex === playingIndex) {
  1896. switch (queueEndMode) {
  1897. case 'loop':
  1898. queueLoopPart = 'Repeat'
  1899. queueLoopPartShort = 'R'
  1900. break
  1901. case 'shuffle':
  1902. queueLoopPart = 'Shuffle'
  1903. queueLoopPartShort = 'S'
  1904. break
  1905. case 'end':
  1906. default:
  1907. break
  1908. }
  1909. }
  1910. let partsTogether
  1911. const all = () => `(${this.SQP.playSymbol} ${partsTogether})`
  1912. const tooWide = () => all().length > this.queuePane.contentW
  1913. // goto irl
  1914. determineParts: {
  1915. if (timestampPart) {
  1916. if (queueLoopPart) {
  1917. partsTogether = `${trackPart} : ${timestampPart} »${queueLoopPartShort}`
  1918. } else {
  1919. partsTogether = `(${this.SQP.playSymbol} ${trackPart} : ${timestampPart})`
  1920. }
  1921. break determineParts
  1922. }
  1923. if (queueLoopPart) includeQueueLoop: {
  1924. partsTogether = `${trackPart} » ${queueLoopPart}`
  1925. if (tooWide()) {
  1926. partsTogether = `${trackPart} »${queueLoopPartShort}`
  1927. if (tooWide()) {
  1928. break includeQueueLoop
  1929. }
  1930. }
  1931. break determineParts
  1932. }
  1933. partsTogether = trackPart
  1934. if (tooWide()) {
  1935. partsTogether = trackPartShort
  1936. if (tooWide()) {
  1937. partsTogether = trackPartReallyShort
  1938. }
  1939. }
  1940. }
  1941. this.queueLengthLabel.text = all()
  1942. } else {
  1943. this.queueLengthLabel.text = `(${items.length})`
  1944. }
  1945. // Layout stuff to position the length and time labels correctly.
  1946. this.queueLengthLabel.centerInParent()
  1947. this.queueTimeLabel.centerInParent()
  1948. this.queueLengthLabel.y = this.queuePane.contentH - 2
  1949. this.queueTimeLabel.y = this.queuePane.contentH - 1
  1950. }
  1951. updateQueueSelection(timeData, oldTimeData) {
  1952. if (!timeData) {
  1953. return
  1954. }
  1955. const { playingTrack } = this.SQP
  1956. const { form } = this.queueListingElement
  1957. const { currentInput } = form
  1958. if (!currentInput || currentInput.item !== playingTrack) {
  1959. return
  1960. }
  1961. const timestamps = this.getTimestampData(playingTrack)
  1962. if (!timestamps) {
  1963. return
  1964. }
  1965. const tsOld = oldTimeData &&
  1966. this.getTimestampAtSec(playingTrack, oldTimeData.curSecTotal)
  1967. const tsNew =
  1968. this.getTimestampAtSec(playingTrack, timeData.curSecTotal)
  1969. if (
  1970. tsNew !== tsOld &&
  1971. currentInput instanceof TimestampGrouplikeItemElement &&
  1972. currentInput.data === tsOld
  1973. ) {
  1974. const index = form.inputs.findIndex(el => (
  1975. el.item === playingTrack &&
  1976. el instanceof TimestampGrouplikeItemElement &&
  1977. el.data === tsNew
  1978. ))
  1979. if (index === -1) {
  1980. return
  1981. }
  1982. form.curIndex = index
  1983. if (form.isSelected) {
  1984. form.updateSelectedElement()
  1985. }
  1986. form.scrollSelectedElementIntoView()
  1987. }
  1988. }
  1989. setThemeColor(color) {
  1990. this.themeColor = color
  1991. this.menubar.color = color
  1992. }
  1993. get SQP() {
  1994. // Just a convenient shorthand.
  1995. return this.selectedQueuePlayer
  1996. }
  1997. get selectedQueuePlayer() { return this.getDep('selectedQueuePlayer') }
  1998. set selectedQueuePlayer(v) { this.setDep('selectedQueuePlayer', v) }
  1999. }
  2000. class GrouplikeListingElement extends Form {
  2001. // TODO: This is a Form, which means that it captures the tab key. The result
  2002. // of this is that you cannot use Tab to navigate the top-level application.
  2003. // Accordingly, I've made AppElement a FocusElement and not a Form and re-
  2004. // factored calls of addInput to addChild. However, I'm not sure that this is
  2005. // the "correct" or most intuitive behavior. Should the tab key be usable to
  2006. // navigate the entire interface? I don't know. I've gone with the current
  2007. // behavior (GrouplikeListingElement as a Form) because it feels right at the
  2008. // moment, but we'll see, I suppose.
  2009. //
  2010. // In order to let tab navigate through all UI elements (or rather, the top-
  2011. // level application as well as GrouplikeListingElements, which are a sort of
  2012. // nested Form), the AppElement would have to be changed to be a Form again
  2013. // (replacing addChild with addInput where appropriate). Furthermore, while
  2014. // the GrouplikeListingElement should stay as a Form subclass, it should be
  2015. // modified so that it does not capture tab if there is no next element to
  2016. // select, and vice versa for shift-tab and the previous element. This should
  2017. // probably be implemented in tui-lib as a flag on Form (captureTabOnEnds,
  2018. // or something).
  2019. //
  2020. // (PS AppElement apparently used a "this.form" property, instead of directly
  2021. // inheriting from Form, apparently. That's more or less adjacent to the
  2022. // point. It's removed now. You'll have to add it back, if wanted.)
  2023. //
  2024. // August 15th, 2018
  2025. constructor(app) {
  2026. super()
  2027. this.grouplike = null
  2028. this.app = app
  2029. this.form = this.getNewForm()
  2030. this.addInput(this.form)
  2031. this.form.on('select', input => {
  2032. if (input && this.pathElement) {
  2033. this.pathElement.showItem(input.item)
  2034. this.autoscroll()
  2035. this.emit('select', input.item)
  2036. }
  2037. })
  2038. this.jumpElement = new ListingJumpElement()
  2039. this.addChild(this.jumpElement)
  2040. this.jumpElement.visible = false
  2041. this.oldFocusedIndex = null // To restore to, if a jump is canceled.
  2042. this.previousJumpValue = '' // To default to, if the user doesn't enter anything.
  2043. this.jumpElement.on('cancel', () => this.hideJumpElement(true))
  2044. this.jumpElement.on('change', value => this.handleJumpValue(value, false))
  2045. this.jumpElement.on('confirm', value => this.handleJumpValue(value, true))
  2046. this.pathElement = new PathElement()
  2047. this.addInput(this.pathElement)
  2048. this.commentLabel = new WrapLabel()
  2049. this.addChild(this.commentLabel)
  2050. this.grouplikeData = new WeakMap()
  2051. this.autoscrollOffset = null
  2052. this.expandedTimestamps = []
  2053. }
  2054. getNewForm() {
  2055. return new GrouplikeListingForm(this.app)
  2056. }
  2057. fixLayout() {
  2058. this.commentLabel.w = this.contentW
  2059. this.form.w = this.contentW
  2060. this.form.h = this.contentH
  2061. this.form.y = this.commentLabel.bottom
  2062. this.form.h -= this.commentLabel.h
  2063. this.form.h -= 1 // For the path element
  2064. if (this.jumpElement.visible) this.form.h -= 1
  2065. this.form.fixLayout() // Respond to being resized
  2066. this.autoscroll()
  2067. this.form.scrollSelectedElementIntoView()
  2068. this.pathElement.y = this.contentH - 1
  2069. this.pathElement.w = this.contentW
  2070. this.jumpElement.y = this.pathElement.y - 1
  2071. this.jumpElement.w = this.contentW
  2072. }
  2073. selected() {
  2074. this.curIndex = 0
  2075. this.root.select(this.form)
  2076. this.emit('select', this.currentItem)
  2077. }
  2078. clicked(button) {
  2079. if (button === 'left') {
  2080. this.selected()
  2081. return false
  2082. }
  2083. }
  2084. get selectable() {
  2085. return this.form.selectable
  2086. }
  2087. keyPressed(keyBuf) {
  2088. // Just about everything here depends on the grouplike existing, so let's
  2089. // not continue if it doesn't!
  2090. if (!this.grouplike) {
  2091. return
  2092. }
  2093. if (telc.isBackspace(keyBuf)) {
  2094. this.loadParentGrouplike()
  2095. } else if (telc.isCharacter(keyBuf, '/') || keyBuf[0] === 6) { // '/', ctrl-F
  2096. this.showJumpElement()
  2097. } else if (input.isScrollToStart(keyBuf)) {
  2098. this.form.selectAndShow(this.grouplike.items[0])
  2099. this.form.scrollToBeginning()
  2100. } else if (input.isScrollToEnd(keyBuf)) {
  2101. this.form.selectAndShow(this.grouplike.items[this.grouplike.items.length - 1])
  2102. } else if (keyBuf[0] === 12) { // ctrl-L
  2103. if (this.grouplike.isTheQueue) {
  2104. this.form.selectAndShow(this.app.SQP.playingTrack)
  2105. /*
  2106. } else {
  2107. this.toggleExpandLabels()
  2108. */
  2109. }
  2110. } else if (keyBuf[0] === 1) { // ctrl-A
  2111. this.toggleMarkAll()
  2112. } else {
  2113. return super.keyPressed(keyBuf)
  2114. }
  2115. }
  2116. loadGrouplike(grouplike, resetIndex = true) {
  2117. this.saveGrouplikeData()
  2118. this.grouplike = grouplike
  2119. this.buildItems(resetIndex)
  2120. this.restoreGrouplikeData()
  2121. if (this.root.select) this.hideJumpElement()
  2122. }
  2123. saveGrouplikeData() {
  2124. if (isGroup(this.grouplike)) {
  2125. this.grouplikeData.set(this.grouplike, {
  2126. scrollItems: this.form.scrollItems,
  2127. currentItem: this.currentItem,
  2128. expandedTimestamps: this.expandedTimestamps
  2129. })
  2130. }
  2131. }
  2132. restoreGrouplikeData() {
  2133. if (this.grouplikeData.has(this.grouplike)) {
  2134. const data = this.grouplikeData.get(this.grouplike)
  2135. this.form.scrollItems = data.scrollItems
  2136. this.form.selectAndShow(data.currentItem)
  2137. this.form.fixLayout()
  2138. this.expandedTimestamps = data.expandedTimestamps
  2139. this.buildTimestampItems()
  2140. }
  2141. }
  2142. selectNone() {
  2143. // nb: this is unrelated to the actual track selection system!
  2144. // just clears the form selection
  2145. this.pathElement.showItem(null)
  2146. this.form.curIndex = 0
  2147. this.form.scrollItems = 0
  2148. }
  2149. toggleMarkAll() {
  2150. const { items } = this.grouplike
  2151. const actions = []
  2152. const tracks = flattenGrouplike(this.grouplike).items
  2153. if (items.every(item => this.app.getMarkStatus(item) !== 'unmarked')) {
  2154. if (this.app.markGrouplike.items.length > tracks.length) {
  2155. actions.push({label: 'Remove from selection', action: () => this.app.unmarkItem(this.grouplike)})
  2156. }
  2157. actions.push({label: 'Clear selection', action: () => this.app.unmarkAll()})
  2158. } else {
  2159. actions.push({label: 'Add to selection', action: () => this.app.markItem(this.grouplike)})
  2160. if (this.app.markGrouplike.items.some(item => !tracks.includes(item))) {
  2161. actions.push({label: 'Replace selection', action: () => {
  2162. this.app.unmarkAll()
  2163. this.app.markItem(this.grouplike)
  2164. }})
  2165. }
  2166. }
  2167. if (actions.length === 1) {
  2168. actions[0].action()
  2169. } else {
  2170. const el = this.form.inputs[this.form.curIndex]
  2171. this.app.showContextMenu({
  2172. x: el.absLeft,
  2173. y: el.absTop + 1,
  2174. items: actions
  2175. })
  2176. }
  2177. }
  2178. /*
  2179. toggleExpandLabels() {
  2180. this.expandLabels = !this.expandLabels
  2181. for (const input of this.form.inputs) {
  2182. if (!(input instanceof InteractiveGrouplikeItemElement)) {
  2183. continue
  2184. }
  2185. if (!input.labelsSelected) {
  2186. input.expandLabels = this.expandLabels
  2187. input.computeText()
  2188. }
  2189. }
  2190. }
  2191. */
  2192. toggleAutoscroll() {
  2193. if (this.autoscrollOffset === null) {
  2194. this.autoscrollOffset = this.form.curIndex - this.form.scrollItems
  2195. this.form.wheelMode = 'selection'
  2196. } else {
  2197. this.autoscrollOffset = null
  2198. this.form.wheelMode = 'scroll'
  2199. }
  2200. }
  2201. autoscroll() {
  2202. if (this.autoscrollOffset !== null) {
  2203. const distanceFromTop = this.form.curIndex - this.form.scrollItems
  2204. const delta = this.autoscrollOffset - distanceFromTop
  2205. this.form.scrollItems -= delta
  2206. this.form.fixLayout()
  2207. }
  2208. }
  2209. expandTimestamps(item) {
  2210. if (this.grouplike && this.grouplike.items.includes(item)) {
  2211. const ET = this.expandedTimestamps
  2212. if (!ET.includes(item)) {
  2213. this.expandedTimestamps.push(item)
  2214. this.buildTimestampItems()
  2215. if (this.currentItem === item) {
  2216. if (this.isSelected) {
  2217. this.form.selectInput(this.form.inputs[this.form.curIndex + 1])
  2218. } else {
  2219. this.form.curIndex += 1
  2220. }
  2221. }
  2222. }
  2223. }
  2224. }
  2225. collapseTimestamps(item) {
  2226. const ET = this.expandedTimestamps // :alien:
  2227. if (ET.includes(item)) {
  2228. const restore = (this.currentItem === item)
  2229. ET.splice(ET.indexOf(item), 1)
  2230. this.buildTimestampItems()
  2231. if (restore) {
  2232. const { form } = this
  2233. const index = form.inputs.findIndex(inp => inp.item === item)
  2234. form.curIndex = index
  2235. if (form.isSelected) {
  2236. form.updateSelectedElement()
  2237. }
  2238. form.scrollSelectedElementIntoView()
  2239. }
  2240. }
  2241. }
  2242. toggleTimestamps(item) {
  2243. if (this.timestampsExpanded(item)) {
  2244. this.collapseTimestamps(item)
  2245. } else {
  2246. this.expandTimestamps(item)
  2247. }
  2248. }
  2249. timestampsExpanded(item) {
  2250. this.updateTimestamps()
  2251. return this.expandedTimestamps.includes(item)
  2252. }
  2253. selectTimestampAtSec(item, sec) {
  2254. this.expandTimestamps(item)
  2255. const { form } = this
  2256. let index = form.inputs.findIndex(el => (
  2257. el.item === item &&
  2258. el instanceof TimestampGrouplikeItemElement &&
  2259. el.data.timestamp >= sec
  2260. ))
  2261. if (index === -1) {
  2262. index = form.inputs.findIndex(el => el.item === item)
  2263. if (index === -1) {
  2264. return
  2265. }
  2266. }
  2267. form.curIndex = index
  2268. if (form.isSelected) {
  2269. form.updateSelectedElement()
  2270. }
  2271. form.scrollSelectedElementIntoView()
  2272. }
  2273. updateTimestamps() {
  2274. const ET = this.expandedTimestamps
  2275. if (ET) {
  2276. this.expandedTimestamps = ET.filter(item => this.grouplike.items.includes(item))
  2277. }
  2278. }
  2279. restoreSelectedInput(restoreInput) {
  2280. const { form } = this
  2281. const { inputs, currentInput } = form
  2282. if (currentInput === restoreInput) {
  2283. return
  2284. }
  2285. let inputToSelect
  2286. if (inputs.includes(restoreInput)) {
  2287. inputToSelect = restoreInput
  2288. } else if (restoreInput instanceof InteractiveGrouplikeItemElement) {
  2289. inputToSelect = inputs.find(input =>
  2290. input.item === restoreInput.item &&
  2291. input instanceof InteractiveGrouplikeItemElement
  2292. )
  2293. } else if (restoreInput instanceof TimestampGrouplikeItemElement) {
  2294. inputToSelect = inputs.find(input =>
  2295. input.data === restoreInput.data &&
  2296. input instanceof TimestampGrouplikeItemElement
  2297. )
  2298. }
  2299. if (!inputToSelect) {
  2300. return
  2301. }
  2302. form.curIndex = inputs.indexOf(inputToSelect)
  2303. if (form.isSelected) {
  2304. form.updateSelectedElement()
  2305. }
  2306. form.scrollSelectedElementIntoView()
  2307. }
  2308. buildTimestampItems(restoreInput = this.currentInput) {
  2309. const form = this.form
  2310. // Clear up any existing timestamp items, since we're about to generate new
  2311. // ones!
  2312. form.children = form.children.filter(child => !(child instanceof TimestampGrouplikeItemElement))
  2313. form.inputs = form.inputs.filter(child => !(child instanceof TimestampGrouplikeItemElement))
  2314. this.updateTimestamps()
  2315. if (!this.expandedTimestamps) {
  2316. // Well that's going to have obvious consequences.
  2317. return
  2318. }
  2319. for (const item of this.expandedTimestamps) {
  2320. // Find the main item element. The items we're about to generate will be
  2321. // inserted after it.
  2322. const mainElementIndex = form.inputs.findIndex(el => (
  2323. el instanceof InteractiveGrouplikeItemElement &&
  2324. el.item === item
  2325. ))
  2326. const timestampData = this.app.getTimestampData(item)
  2327. // Oh no.
  2328. // TODO: This should probably error report lol.
  2329. if (!timestampData) {
  2330. continue
  2331. }
  2332. // Generate some items! Just go over the data list and generate one for
  2333. // each timestamp.
  2334. const tsElements = timestampData.map(ts => {
  2335. const el = new TimestampGrouplikeItemElement(item, ts, timestampData, this.app)
  2336. el.on('pressed', () => this.emit('timestamp', item, ts.timestamp))
  2337. if (this.grouplike.isTheQueue) {
  2338. el.hideMetadata = true
  2339. }
  2340. return el
  2341. })
  2342. // Stick 'em in. Form doesn't implement an "insert input" function because
  2343. // why would life be easy, so we'll mangle the inputs array ourselves.
  2344. form.inputs.splice(mainElementIndex + 1, 0, ...tsElements)
  2345. let previousIndex = mainElementIndex
  2346. for (const el of tsElements) {
  2347. // We do addChild rather than a simple splice because addChild does more
  2348. // stuff than just sticking it in the array (e.g. setting the child's
  2349. // .parent property). What if addInput gets updated to do more stuff in
  2350. // a similar fashion? Well, then we're scr*wed! :)
  2351. form.addChild(el, previousIndex + 1)
  2352. previousIndex++
  2353. }
  2354. }
  2355. this.restoreSelectedInput(restoreInput)
  2356. this.scheduleDrawWithoutPropertyChange()
  2357. this.fixAllLayout()
  2358. }
  2359. buildItems(resetIndex = false) {
  2360. if (!this.grouplike) {
  2361. throw new Error('Attempted to call buildItems before a grouplike was loaded')
  2362. }
  2363. this.commentLabel.text = this.grouplike.comment || ''
  2364. const restoreInput = this.form.currentInput
  2365. const wasSelected = this.isSelected
  2366. const form = this.form
  2367. // Just outright scrap the old items - don't deal with any selection stuff
  2368. // (as a result of removeInput) yet.
  2369. form.children = form.children.filter(child => !form.inputs.includes(child));
  2370. form.inputs = []
  2371. const parent = this.grouplike[parentSymbol]
  2372. if (parent) {
  2373. const upButton = new BasicGrouplikeItemElement(`Up (to ${parent.name || 'unnamed group'})`)
  2374. upButton.on('pressed', () => this.loadParentGrouplike())
  2375. form.addInput(upButton)
  2376. }
  2377. if (this.grouplike.items.length) {
  2378. // Add an element for controlling this whole group. Particularly handy
  2379. // for operating on the top-level group, which itself is not contained
  2380. // within any groups (so you can't browse a parent and access its menu
  2381. // from there).
  2382. if (!this.grouplike.isTheQueue) {
  2383. const ownElement = new BasicGrouplikeItemElement(`This group: ${this.grouplike.name || '(Unnamed group)'}`)
  2384. ownElement.item = this.grouplike
  2385. ownElement.app = this.app
  2386. ownElement.isGroup = true
  2387. ownElement.on('pressed', () => {
  2388. ownElement.emit('menu', ownElement)
  2389. })
  2390. this.addEventListeners(ownElement)
  2391. form.addInput(ownElement)
  2392. }
  2393. // Add the elements for all the actual items within this playlist.
  2394. for (const item of this.grouplike.items) {
  2395. if (!isPlayable(item) && getCorrespondingPlayableForFile(item)) {
  2396. continue
  2397. }
  2398. const itemElement = new InteractiveGrouplikeItemElement(item, this.app)
  2399. this.addEventListeners(itemElement)
  2400. form.addInput(itemElement)
  2401. if (this.grouplike.isTheQueue) {
  2402. itemElement.hideMetadata = true
  2403. itemElement.text = getNameWithoutTrackNumber(item)
  2404. }
  2405. }
  2406. } else if (!this.grouplike.isTheQueue) {
  2407. form.addInput(new BasicGrouplikeItemElement('(This group is empty)'))
  2408. }
  2409. if (wasSelected) {
  2410. if (resetIndex) {
  2411. form.scrollItems = 0
  2412. form.selectInput(form.inputs[form.firstItemIndex])
  2413. } else {
  2414. this.root.select(form)
  2415. }
  2416. }
  2417. this.buildTimestampItems(restoreInput)
  2418. // Just to make the selected-track-info bar fill right away (if it wasn't
  2419. // already filled by a previous this.curIndex set).
  2420. /* eslint-disable-next-line no-self-assign */
  2421. form.curIndex = form.curIndex
  2422. this.fixAllLayout()
  2423. }
  2424. addEventListeners(itemElement) {
  2425. for (const evtName of [
  2426. 'browse',
  2427. 'download',
  2428. 'edit-notes',
  2429. 'mark',
  2430. 'menu',
  2431. 'open',
  2432. 'paste',
  2433. 'queue',
  2434. 'remove',
  2435. 'unqueue'
  2436. ]) {
  2437. itemElement.on(evtName, (...data) => this.emit(evtName, itemElement.item, ...data))
  2438. }
  2439. itemElement.on('toggle-timestamps', () => this.toggleTimestamps(itemElement.item))
  2440. /*
  2441. itemElement.on('unselected labels', () => {
  2442. if (!this.expandLabels) {
  2443. itemElement.expandLabels = false
  2444. itemElement.computeText()
  2445. }
  2446. })
  2447. */
  2448. }
  2449. loadParentGrouplike() {
  2450. if (!this.grouplike) {
  2451. return
  2452. }
  2453. const parent = this.grouplike[parentSymbol]
  2454. if (parent) {
  2455. const form = this.form
  2456. const oldGrouplike = this.grouplike
  2457. this.loadGrouplike(parent)
  2458. form.curIndex = form.firstItemIndex
  2459. this.restoreGrouplikeData()
  2460. const index = form.inputs.findIndex(inp => inp.item === oldGrouplike)
  2461. if (typeof index === 'number') {
  2462. form.curIndex = index
  2463. }
  2464. form.updateSelectedElement()
  2465. form.scrollSelectedElementIntoView()
  2466. }
  2467. }
  2468. selectAndShow(item) {
  2469. return this.form.selectAndShow(item)
  2470. }
  2471. handleJumpValue(value, isConfirm) {
  2472. // If the user doesn't enter anything, we won't perform a search -- unless
  2473. // the user just pressed enter. If that's the case, we'll search for
  2474. // whatever was previously entered into the form. This is to strike a
  2475. // balance between keeping the jump form simple and unsurprising but also
  2476. // powerful, i.e. to support easy "repeated" searches (see the below
  2477. // cmoment about search match prioritization).
  2478. if (!value.length && isConfirm && this.previousJumpValue) {
  2479. value = this.previousJumpValue
  2480. }
  2481. const grouplike = {items: this.form.inputs.map(inp => inp.item)}
  2482. // We prioritize searching past the index that the user opened the jump
  2483. // element from (oldFocusedIndex). This is so that it's more practical
  2484. // to do a "repeated" search, wherein the user searches for the same
  2485. // value over and over, each time jumping to the next match, until they
  2486. // have found the one they're looking for.
  2487. const preferredStartIndex = this.oldFocusedIndex
  2488. const item = searchForItem(grouplike, value, preferredStartIndex)
  2489. if (item) {
  2490. this.form.curIndex = this.form.inputs.findIndex(inp => inp.item === item)
  2491. this.form.scrollSelectedElementIntoView()
  2492. } else {
  2493. // TODO: Feedback that the search failed.. right now we just close the
  2494. // jump-to menu, which might not be right.
  2495. }
  2496. if (isConfirm) {
  2497. this.previousJumpValue = value
  2498. this.hideJumpElement()
  2499. }
  2500. }
  2501. showJumpElement() {
  2502. this.oldFocusedIndex = this.form.curIndex
  2503. this.jumpElement.visible = true
  2504. this.root.select(this.jumpElement)
  2505. this.fixLayout()
  2506. }
  2507. hideJumpElement(isCancel) {
  2508. if (this.jumpElement.visible) {
  2509. if (isCancel) {
  2510. this.form.curIndex = this.oldFocusedIndex
  2511. this.form.scrollSelectedElementIntoView()
  2512. }
  2513. this.jumpElement.visible = false
  2514. if (this.jumpElement.isSelected) {
  2515. this.root.select(this)
  2516. }
  2517. this.fixLayout()
  2518. }
  2519. }
  2520. unselected() {
  2521. this.hideJumpElement(true)
  2522. }
  2523. get tabberLabel() {
  2524. if (this.grouplike) {
  2525. return this.grouplike.name || 'Unnamed group'
  2526. } else {
  2527. return 'No group open'
  2528. }
  2529. }
  2530. get currentItem() {
  2531. const element = this.currentInput
  2532. return element && element.item
  2533. }
  2534. get currentInput() {
  2535. return this.form.currentInput
  2536. }
  2537. }
  2538. class GrouplikeListingForm extends ListScrollForm {
  2539. constructor(app) {
  2540. super('vertical')
  2541. this.app = app
  2542. this.dragInputs = []
  2543. this.selectMode = null
  2544. this.keyboardDragDirection = null
  2545. this.captureTab = false
  2546. }
  2547. keyPressed(keyBuf) {
  2548. if (this.inputs.length === 0) {
  2549. return
  2550. }
  2551. if (input.isSelectUp(keyBuf)) {
  2552. this.selectUp()
  2553. } else if (input.isSelectDown(keyBuf)) {
  2554. this.selectDown()
  2555. } else {
  2556. if (telc.isUp(keyBuf) || telc.isDown(keyBuf)) {
  2557. this.keyboardDragDirection = null
  2558. }
  2559. return super.keyPressed(keyBuf)
  2560. }
  2561. }
  2562. set curIndex(newIndex) {
  2563. this.setDep('curIndex', newIndex)
  2564. this.emit('select', this.inputs[this.curIndex])
  2565. }
  2566. get curIndex() {
  2567. return this.getDep('curIndex')
  2568. }
  2569. get firstItemIndex() {
  2570. return Math.max(0, this.inputs.findIndex(el => el instanceof InteractiveGrouplikeItemElement))
  2571. }
  2572. get currentInput() {
  2573. return this.inputs[this.curIndex]
  2574. }
  2575. selectAndShow(item) {
  2576. const index = this.inputs.findIndex(inp => inp.item === item)
  2577. if (index >= 0) {
  2578. this.curIndex = index
  2579. if (this.isSelected) {
  2580. this.updateSelectedElement()
  2581. }
  2582. this.scrollSelectedElementIntoView()
  2583. return true
  2584. }
  2585. return false
  2586. }
  2587. clicked(button, allData) {
  2588. const { line, ctrl } = allData
  2589. if (button === 'left') {
  2590. this.dragStartLine = line - this.absTop + this.scrollItems
  2591. this.dragStartIndex = this.inputs.findIndex(inp => inp.absTop === line - 1)
  2592. if (this.dragStartIndex >= 0) {
  2593. const input = this.inputs[this.dragStartIndex]
  2594. if (!(input instanceof InteractiveGrouplikeItemElement)) {
  2595. this.dragStartIndex = -1
  2596. return
  2597. }
  2598. const { item } = input
  2599. if (this.app.getMarkStatus(item) === 'unmarked') {
  2600. if (!ctrl) {
  2601. this.app.unmarkAll()
  2602. }
  2603. this.selectMode = 'select'
  2604. } else {
  2605. this.selectMode = 'deselect'
  2606. }
  2607. if (ctrl) {
  2608. this.dragInputs = [item]
  2609. this.dragEnteredRange(item)
  2610. } else {
  2611. this.dragInputs = []
  2612. }
  2613. this.oldMarkedItems = this.app.markGrouplike.items.slice()
  2614. }
  2615. } else if (button === 'drag-left' && this.dragStartIndex >= 0) {
  2616. const offset = (line - this.absTop + this.scrollItems) - this.dragStartLine
  2617. const rangeA = this.dragStartIndex
  2618. const rangeB = this.dragStartIndex + offset
  2619. const inputs = ((rangeA < rangeB)
  2620. ? this.inputs.slice(rangeA, rangeB + 1)
  2621. : this.inputs.slice(rangeB, rangeA + 1))
  2622. let enteredRange = inputs.filter(inp => !this.dragInputs.includes(inp))
  2623. let leftRange = this.dragInputs.filter(inp => !inputs.includes(inp))
  2624. for (const { item } of enteredRange) {
  2625. this.dragEnteredRange(item)
  2626. }
  2627. for (const { item } of leftRange) {
  2628. this.dragLeftRange(item)
  2629. }
  2630. if (this.inputs[rangeB]) {
  2631. this.root.select(this.inputs[rangeB])
  2632. }
  2633. this.dragInputs = inputs
  2634. } else if (button === 'release') {
  2635. this.dragStartIndex = -1
  2636. } else {
  2637. return super.clicked(button, allData)
  2638. }
  2639. }
  2640. dragEnteredRange(item) {
  2641. if (this.selectMode === 'select') {
  2642. this.app.markItem(item)
  2643. } else if (this.selectMode === 'deselect') {
  2644. this.app.unmarkItem(item)
  2645. }
  2646. }
  2647. dragLeftRange(item) {
  2648. if (this.selectMode === 'select') {
  2649. if (!this.oldMarkedItems.includes(item)) {
  2650. this.app.unmarkItem(item)
  2651. }
  2652. } else if (this.selectMode === 'deselect') {
  2653. if (this.oldMarkedItems.includes(item)) {
  2654. this.app.markItem(item)
  2655. }
  2656. }
  2657. }
  2658. selectUp() {
  2659. this.handleKeyboardSelect(-1)
  2660. }
  2661. selectDown() {
  2662. this.handleKeyboardSelect(+1)
  2663. }
  2664. handleKeyboardSelect(direction) {
  2665. const move = () => {
  2666. if (direction === +1) {
  2667. this.nextInput()
  2668. } else {
  2669. this.previousInput()
  2670. }
  2671. this.scrollSelectedElementIntoView()
  2672. }
  2673. const getItem = () => {
  2674. const input = this.inputs[this.curIndex]
  2675. if (input instanceof InteractiveGrouplikeItemElement) {
  2676. return input.item
  2677. } else {
  2678. return null
  2679. }
  2680. }
  2681. if (!this.keyboardDragDirection) {
  2682. const item = getItem()
  2683. if (!item) {
  2684. move()
  2685. return
  2686. }
  2687. this.keyboardDragDirection = direction
  2688. this.oldMarkedItems = (this.inputs
  2689. .filter(input => input.item && this.app.getMarkStatus(input.item) !== 'unmarked')
  2690. .map(input => input.item))
  2691. if (this.app.getMarkStatus(item) === 'unmarked') {
  2692. this.selectMode = 'select'
  2693. } else {
  2694. this.selectMode = 'deselect'
  2695. }
  2696. this.dragEnteredRange(item)
  2697. }
  2698. if (direction === this.keyboardDragDirection) {
  2699. move()
  2700. const item = getItem()
  2701. if (!item) {
  2702. return
  2703. }
  2704. this.dragEnteredRange(item)
  2705. } else {
  2706. const item = getItem()
  2707. if (!item) {
  2708. move()
  2709. return
  2710. }
  2711. this.dragLeftRange(item)
  2712. move()
  2713. }
  2714. }
  2715. }
  2716. class BasicGrouplikeItemElement extends Button {
  2717. constructor(text) {
  2718. super()
  2719. this._text = this._rightText = ''
  2720. this.text = text
  2721. this.rightText = ''
  2722. this.drawText = ''
  2723. }
  2724. fixLayout() {
  2725. this.w = this.parent.contentW
  2726. this.h = 1
  2727. this.computeText()
  2728. }
  2729. set text(val) {
  2730. if (this._text !== val) {
  2731. this._text = val
  2732. this.computeText()
  2733. }
  2734. }
  2735. get text() {
  2736. return this._text
  2737. }
  2738. set rightText(val) {
  2739. if (this._rightText !== val) {
  2740. this._rightText = val
  2741. this.computeText()
  2742. }
  2743. }
  2744. get rightText() {
  2745. return this._rightText
  2746. }
  2747. getFormattedRightText() {
  2748. return this.rightText
  2749. }
  2750. getRightTextColumns() {
  2751. return ansi.measureColumns(this.rightText)
  2752. }
  2753. getMinLeftTextColumns() {
  2754. return 12
  2755. }
  2756. getLeftPadding() {
  2757. return 2
  2758. }
  2759. getSelfSelected() {
  2760. return this.isSelected
  2761. }
  2762. computeText() {
  2763. let w = this.w - this.x - this.getLeftPadding()
  2764. // Also make space for the right text - if we choose to show it.
  2765. const rightTextCols = this.getRightTextColumns()
  2766. const showRightText = (w - rightTextCols > this.getMinLeftTextColumns())
  2767. if (showRightText) {
  2768. w -= rightTextCols
  2769. }
  2770. let text = ansi.trimToColumns(this.text, w)
  2771. const width = ansi.measureColumns(this.text)
  2772. if (width < w) {
  2773. text += ' '.repeat(w - width)
  2774. }
  2775. if (showRightText) {
  2776. text += this.getFormattedRightText()
  2777. }
  2778. text += ansi.resetAttributes()
  2779. this.drawText = text
  2780. }
  2781. drawTo(writable) {
  2782. const isCurrentInput = this.parent.inputs[this.parent.curIndex] === this
  2783. // This line's commented out for now, so it'll show as selected (but
  2784. // dimmed) even if you don't have the listing selected. To change that,
  2785. // uncomment this and add it to the isCurrentInput line.
  2786. // const isListingSelected = this.parent.parent.isSelected
  2787. const isSelfSelected = this.getSelfSelected()
  2788. if (isSelfSelected) {
  2789. writable.write(ansi.invert())
  2790. } else if (isCurrentInput) {
  2791. // technically cheating - isPlayable is defined on InteractiveGrouplikeElement
  2792. if (this.isPlayable === false) {
  2793. writable.write(ansi.setAttributes([ansi.A_INVERT, ansi.C_BLACK, ansi.A_BRIGHT]))
  2794. } else {
  2795. writable.write(ansi.setAttributes([ansi.A_INVERT, ansi.A_DIM]))
  2796. }
  2797. }
  2798. writable.write(ansi.moveCursor(this.absTop, this.absLeft))
  2799. this.writeStatus(writable)
  2800. writable.write(this.drawText)
  2801. }
  2802. writeStatus(writable) {
  2803. // Add a couple spaces. This is less than the padding of the status text
  2804. // of elements which represent real playlist items; that's to distinguish
  2805. // "fake" rows from actual playlist items.
  2806. writable.write(' ')
  2807. this.drawX += 2
  2808. }
  2809. keyPressed(keyBuf) {
  2810. // This function is overridden by InteractiveGrouplikeItemElement, but
  2811. // it's still specified here that only enter counts as an action key.
  2812. // By default for buttons, the space key also works, but since in this app
  2813. // space is generally bound to mean "pause" instead of "select", we don't
  2814. // check if space is pressed here.
  2815. if (telc.isEnter(keyBuf) || input.isMenu(keyBuf)) {
  2816. this.emit('pressed')
  2817. }
  2818. }
  2819. clicked(button) {
  2820. super.clicked(button)
  2821. }
  2822. }
  2823. class InlineListPickerElement extends FocusElement {
  2824. // And you thought my class names couldn't get any worse...
  2825. // This is an element that looks something like the following:
  2826. // Fruit? [Apple]
  2827. // (Imagine that "[Apple]" just looks like "Apple" written in blue text.)
  2828. // If you press the element (like a button), it'll pick the next item in its
  2829. // list of options, like "Banana" or "Canteloupe" in this example. The arrow
  2830. // keys also work to move through the list. You typically don't want to put
  2831. // too many items in the list, since there's no visual way of telling what's
  2832. // next or previous. (That's the point, it's inline.) This element is mainly
  2833. // useful in forms or ContextMenus.
  2834. constructor(labelText, options, optsOrShowContextMenu = null) {
  2835. super()
  2836. this.labelText = labelText
  2837. this.options = options
  2838. if (typeof optsOrShowContextMenu === 'function') {
  2839. this.showContextMenu = optsOrShowContextMenu
  2840. }
  2841. if (typeof optsOrShowContextMenu === 'object') {
  2842. const opts = optsOrShowContextMenu
  2843. this.showContextMenu = opts.showContextMenu
  2844. this.getValue = opts.getValue
  2845. this.setValue = opts.setValue
  2846. }
  2847. this.keyboardIdentifier = this.labelText
  2848. this.curIndex = 0
  2849. this.refreshValue()
  2850. }
  2851. fixLayout() {
  2852. // We want to fill the parent's width, but also fit ourselves, so we need
  2853. // to determine the ideal width which would fit us but not leave extra
  2854. // space.
  2855. const longestOptionLength = this.options.reduce(
  2856. (soFar, { label }) => Math.max(soFar, ansi.measureColumns(label)), 0)
  2857. const idealWidth = (
  2858. ansi.measureColumns(this.labelText) + longestOptionLength + 4)
  2859. // Then we use whichever is greater - our ideal width or the width of the
  2860. // parent - as our own width. The parent should respect our needs by
  2861. // growing if necessary. :) (ContextMenu does this, which is where you'd
  2862. // typically embed this element.)
  2863. // I shall fill you, parent, even beyond your own bounds!!!
  2864. this.w = Math.max(this.parent.contentW, idealWidth)
  2865. // Height is always just 1.
  2866. this.h = 1
  2867. }
  2868. drawTo(writable) {
  2869. if (this.isSelected) {
  2870. writable.write(ansi.invert())
  2871. }
  2872. const curOption = this.options[this.curIndex].label.toString()
  2873. let drawX = 0
  2874. writable.write(ansi.moveCursor(this.absTop, this.absLeft))
  2875. writable.write(this.labelText + ' ')
  2876. drawX += ansi.measureColumns(this.labelText) + 1
  2877. writable.write(ansi.setAttributes([ansi.A_BRIGHT, ansi.C_BLUE]))
  2878. writable.write(' ' + curOption + ' ')
  2879. drawX += ansi.measureColumns(curOption) + 2
  2880. writable.write(ansi.setForeground(ansi.C_RESET))
  2881. writable.write(' '.repeat(Math.max(0, this.w - drawX)))
  2882. writable.write(ansi.resetAttributes())
  2883. }
  2884. keyPressed(keyBuf) {
  2885. if (telc.isSelect(keyBuf) || telc.isRight(keyBuf)) {
  2886. this.nextOption()
  2887. } else if (telc.isLeft(keyBuf)) {
  2888. this.previousOption()
  2889. } else if (input.isMenu(keyBuf) && this.showContextMenu) {
  2890. this.showMenu()
  2891. } else {
  2892. return true
  2893. }
  2894. return false
  2895. }
  2896. clicked(button) {
  2897. if (button === 'left') {
  2898. if (this.isSelected) {
  2899. this.nextOption()
  2900. } else {
  2901. this.root.select(this)
  2902. }
  2903. } else if (button === 'right') {
  2904. this.showMenu()
  2905. } else if (button === 'scroll-up') {
  2906. this.previousOption()
  2907. } else if (button === 'scroll-down') {
  2908. this.nextOption()
  2909. } else {
  2910. return true
  2911. }
  2912. return false
  2913. }
  2914. showMenu() {
  2915. this.showContextMenu({
  2916. x: this.absLeft + ansi.measureColumns(this.labelText) + 1,
  2917. y: this.absTop + 1,
  2918. items: this.options.map(({ label }, index) => ({
  2919. label,
  2920. action: () => {
  2921. this.curIndex = index
  2922. },
  2923. isDefault: index === this.curIndex
  2924. }))
  2925. })
  2926. }
  2927. refreshValue() {
  2928. if (this.getValue) {
  2929. const value = this.getValue()
  2930. const index = this.options.findIndex(opt => opt.value === value)
  2931. if (index >= 0) {
  2932. this.curIndex = index
  2933. }
  2934. }
  2935. }
  2936. nextOption() {
  2937. this.curIndex++
  2938. if (this.curIndex === this.options.length) {
  2939. this.curIndex = 0
  2940. }
  2941. if (this.setValue) {
  2942. this.setValue(this.curValue)
  2943. }
  2944. }
  2945. previousOption() {
  2946. this.curIndex--
  2947. if (this.curIndex < 0) {
  2948. this.curIndex = this.options.length - 1
  2949. }
  2950. if (this.setValue) {
  2951. this.setValue(this.curValue)
  2952. }
  2953. }
  2954. get curValue() {
  2955. return this.options[this.curIndex].value
  2956. }
  2957. get curIndex() { return this.getDep('curIndex') }
  2958. set curIndex(v) { this.setDep('curIndex', v) }
  2959. }
  2960. // Quite hacky, but ATM I can't think of any way to neatly tie getDep/setDep
  2961. // into the slider and toggle elements.
  2962. const drawAfter = (fn, thisObj) => (...args) => {
  2963. const ret = fn(...args)
  2964. thisObj.scheduleDrawWithoutPropertyChange()
  2965. return ret
  2966. }
  2967. class SliderElement extends FocusElement {
  2968. // Same general principle and usage as InlineListPickerElement, but for
  2969. // changing a numeric value.
  2970. constructor(labelText, {setValue, getValue, maxValue = 100, percent = true, getEnabled = () => true}) {
  2971. super()
  2972. this.labelText = labelText
  2973. this.setValue = drawAfter(setValue, this)
  2974. this.getValue = getValue
  2975. this.getEnabled = getEnabled
  2976. this.maxValue = maxValue
  2977. this.percent = percent
  2978. this.keyboardIdentifier = this.labelText
  2979. }
  2980. fixLayout() {
  2981. const idealWidth = ansi.measureColumns(
  2982. this.labelText +
  2983. ' ' + this.getValueString(this.maxValue) +
  2984. ' ' + this.getNumString(this.maxValue) +
  2985. ' '
  2986. )
  2987. this.w = Math.max(this.parent.contentW, idealWidth)
  2988. this.h = 1
  2989. }
  2990. drawTo(writable) {
  2991. const enabled = this.getEnabled()
  2992. if (this.isSelected) {
  2993. writable.write(ansi.invert())
  2994. }
  2995. let drawX = 0
  2996. writable.write(ansi.moveCursor(this.absTop, this.absLeft))
  2997. if (!enabled) {
  2998. writable.write(ansi.setAttributes([ansi.A_DIM, ansi.C_WHITE]))
  2999. }
  3000. writable.write(this.labelText + ' ')
  3001. drawX += ansi.measureColumns(this.labelText) + 1
  3002. if (enabled) {
  3003. writable.write(ansi.setAttributes([ansi.A_BRIGHT, ansi.C_BLUE]))
  3004. }
  3005. writable.write(' ')
  3006. drawX += 1
  3007. const valueString = this.getValueString(this.getValue())
  3008. writable.write(valueString)
  3009. drawX += valueString.length
  3010. const numString = this.getNumString(this.getValue())
  3011. writable.write(' ' + numString + ' ')
  3012. drawX += numString.length + 2
  3013. if (enabled) {
  3014. writable.write(ansi.setForeground(ansi.C_RESET))
  3015. }
  3016. writable.write(' '.repeat(Math.max(0, this.w - drawX)))
  3017. writable.write(ansi.resetAttributes())
  3018. }
  3019. getValueString(value) {
  3020. const maxLength = 10
  3021. let length = Math.round(value / this.maxValue * maxLength)
  3022. if (value < this.maxValue && length === maxLength) {
  3023. length--
  3024. }
  3025. if (value > 0 && length === 0) {
  3026. length++
  3027. }
  3028. return (
  3029. '[' +
  3030. '-'.repeat(length) +
  3031. ' '.repeat(maxLength - length) +
  3032. ']'
  3033. )
  3034. }
  3035. getNumString(value) {
  3036. const maxValueString = Math.round(this.maxValue).toString()
  3037. const valueString = Math.round(value).toString()
  3038. const paddedString = valueString.padStart(maxValueString.length)
  3039. return paddedString + (this.percent ? '%' : '')
  3040. }
  3041. keyPressed(keyBuf) {
  3042. const enabled = this.getEnabled()
  3043. if (enabled && telc.isRight(keyBuf)) {
  3044. this.increment()
  3045. } else if (enabled && telc.isLeft(keyBuf)) {
  3046. this.decrement()
  3047. } else {
  3048. return true
  3049. }
  3050. return false
  3051. }
  3052. clicked(button) {
  3053. if (!this.getEnabled()) {
  3054. return
  3055. }
  3056. if (button === 'left') {
  3057. if (this.isSelected) {
  3058. if (this.getValue() === this.maxValue) {
  3059. this.setValue(0)
  3060. } else {
  3061. this.increment()
  3062. }
  3063. } else {
  3064. this.root.select(this)
  3065. }
  3066. } else if (button === 'scroll-up') {
  3067. this.increment()
  3068. } else if (button === 'scroll-down') {
  3069. this.decrement()
  3070. }
  3071. }
  3072. increment() {
  3073. this.setValue(this.getValue() + this.step)
  3074. }
  3075. decrement() {
  3076. this.setValue(this.getValue() - this.step)
  3077. }
  3078. get step() {
  3079. return this.maxValue / 10
  3080. }
  3081. }
  3082. class ToggleControl extends FocusElement {
  3083. constructor(labelText, {setValue, getValue, getEnabled = () => true}) {
  3084. super()
  3085. this.labelText = labelText
  3086. this.setValue = drawAfter(setValue, this)
  3087. this.getValue = getValue
  3088. this.getEnabled = getEnabled
  3089. this.keyboardIdentifier = this.labelText
  3090. }
  3091. keyPressed(keyBuf) {
  3092. if (input.isSelect(keyBuf) && this.getEnabled()) {
  3093. this.toggle()
  3094. }
  3095. }
  3096. clicked(button) {
  3097. if (!this.getEnabled()) {
  3098. return
  3099. }
  3100. if (button === 'left') {
  3101. if (this.isSelected) {
  3102. this.toggle()
  3103. } else {
  3104. this.root.select(this)
  3105. }
  3106. } else if (button === 'scroll-up' || button === 'scroll-down') {
  3107. this.toggle()
  3108. } else {
  3109. return true
  3110. }
  3111. return false
  3112. }
  3113. // Note: ToggleControl doesn't specify refreshValue because it doesn't have an
  3114. // internal state for the current value. It sets and draws based on the value
  3115. // getter provided externally.
  3116. toggle() {
  3117. this.setValue(!this.getValue())
  3118. }
  3119. fixLayout() {
  3120. // Same general principle as ToggleControl - fill the parent, but always
  3121. // fit ourselves!
  3122. this.w = Math.max(this.parent.contentW, this.labelText.length + 5)
  3123. this.h = 1
  3124. }
  3125. drawTo(writable) {
  3126. if (this.isSelected) {
  3127. writable.write(ansi.invert())
  3128. }
  3129. if (!this.getEnabled()) {
  3130. writable.write(ansi.setAttributes([ansi.C_WHITE, ansi.A_DIM]))
  3131. }
  3132. writable.write(ansi.moveCursor(this.absTop, this.absLeft))
  3133. writable.write(this.getValue() ? '[X] ' : '[.] ')
  3134. writable.write(this.labelText)
  3135. writable.write(' '.repeat(this.w - (this.labelText.length + 4)))
  3136. writable.write(ansi.resetAttributes())
  3137. }
  3138. }
  3139. class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
  3140. constructor(item, app) {
  3141. super(item.name)
  3142. this.item = item
  3143. this.app = app
  3144. this.hideMetadata = false
  3145. /*
  3146. this.expandLabels = false
  3147. this.labelsSelected = false
  3148. this.selectedLabelIndex = 0
  3149. */
  3150. }
  3151. drawTo(writable) {
  3152. this.rightText = ''
  3153. if (!this.hideMetadata) {
  3154. const metadata = this.app.backend.getMetadataFor(this.item)
  3155. if (metadata) {
  3156. const durationString = getTimeStringsFromSec(0, metadata.duration).duration
  3157. this.rightText = ` (${durationString}) `
  3158. }
  3159. }
  3160. super.drawTo(writable)
  3161. }
  3162. selected() {
  3163. this.computeText()
  3164. }
  3165. /*
  3166. unselected() {
  3167. this.unselectLabels()
  3168. }
  3169. getLabelTexts() {
  3170. const separator = this.isSelected ? '' : ''
  3171. let labels = []
  3172. // let labels = ['Voice', 'Woof']
  3173. if (this.expandLabels && this.labelsSelected) {
  3174. labels = ['+', ...labels]
  3175. }
  3176. return labels.map((label, i) => {
  3177. return [
  3178. label,
  3179. separator + (this.expandLabels
  3180. ? (this.labelsSelected && i === this.selectedLabelIndex
  3181. ? `<${label}>`
  3182. : ` ${label} `)
  3183. : label[0])
  3184. ]
  3185. })
  3186. }
  3187. getLabelColor(label) {
  3188. if (label === '+') {
  3189. return ansi.C_BLACK
  3190. } else {
  3191. return 30 + (label.charCodeAt(0) % 7)
  3192. }
  3193. }
  3194. getFormattedRightText() {
  3195. const labelTexts = this.getLabelTexts()
  3196. if (labelTexts.length) {
  3197. const lastColor = this.getLabelColor(labelTexts[labelTexts.length - 1][0])
  3198. return (this.isSelected ? ' ' : '') +
  3199. ansi.resetAttributes() +
  3200. (this.isSelected ? '' : ' ') +
  3201. ansi.setAttributes(this.isSelected ? [ansi.A_BRIGHT, 7] : []) +
  3202. labelTexts.map(([ label, labelText ], i, arr) => {
  3203. let text = ''
  3204. if (this.isSelected) {
  3205. text += ansi.setBackground(this.getLabelColor(label))
  3206. } else {
  3207. text += ansi.setForeground(this.getLabelColor(label))
  3208. }
  3209. text += labelText[0]
  3210. // text += ansi.resetAttributes()
  3211. text += ansi.setForeground(ansi.C_RESET)
  3212. text += ansi.setBackground(this.getLabelColor(label))
  3213. text += labelText.slice(1)
  3214. return text
  3215. }).join('') +
  3216. ansi.setAttributes([ansi.A_RESET, this.isSelected ? 0 : lastColor]) +
  3217. '▎' +
  3218. ansi.resetAttributes() +
  3219. super.getFormattedRightText()
  3220. } else {
  3221. return super.getFormattedRightText()
  3222. }
  3223. }
  3224. getRightTextColumns() {
  3225. const labelTexts = this.getLabelTexts()
  3226. return labelTexts
  3227. .reduce((acc, [l, lt]) => acc + lt.length, 0) +
  3228. (labelTexts.length ? 2 : 0) +
  3229. super.getRightTextColumns()
  3230. }
  3231. getMinLeftTextColumns() {
  3232. return this.expandLabels ? 0 : super.getMinLeftTextColumns()
  3233. }
  3234. */
  3235. getLeftPadding() {
  3236. return 3
  3237. }
  3238. /*
  3239. getSelfSelected() {
  3240. return !this.labelsSelected && super.getSelfSelected()
  3241. }
  3242. */
  3243. keyPressed(keyBuf) {
  3244. /*
  3245. if (this.labelsSelected) {
  3246. if (input.isRight(keyBuf)) {
  3247. this.selectNextLabel()
  3248. } else if (input.isLeft(keyBuf)) {
  3249. this.selectPreviousLabel()
  3250. } else if (telc.isEscape(keyBuf) || input.isFocusLabels(keyBuf)) {
  3251. this.unselectLabels()
  3252. return false
  3253. }
  3254. } else */ if (input.isDownload(keyBuf)) {
  3255. this.emit('download')
  3256. } else if (input.isQueueAfterSelectedTrack(keyBuf)) {
  3257. this.emit('queue', {where: 'next-selected'})
  3258. } else if (input.isOpenThroughSystem(keyBuf)) {
  3259. this.emit('open')
  3260. } else if (telc.isEnter(keyBuf)) {
  3261. if (isGroup(this.item)) {
  3262. this.emit('browse')
  3263. } else if (this.app.hasTimestampsFile(this.item)) {
  3264. this.emit('toggle-timestamps')
  3265. } else if (isTrack(this.item)) {
  3266. this.emit('queue', {where: 'next', play: true})
  3267. } else if (!this.isPlayable) {
  3268. this.emit('open')
  3269. }
  3270. } else if (input.isRemove(keyBuf)) {
  3271. this.emit('remove')
  3272. } else if (input.isMenu(keyBuf)) {
  3273. this.emit('menu', this)
  3274. /*
  3275. } else if (input.isFocusTextEditor(keyBuf)) {
  3276. this.emit('edit-notes')
  3277. } else if (input.isFocusLabels(keyBuf)) {
  3278. this.labelsSelected = true
  3279. this.expandLabels = true
  3280. this.selectedLabelIndex = 0
  3281. */
  3282. }
  3283. }
  3284. /*
  3285. unselectLabels() {
  3286. this.labelsSelected = false
  3287. this.emit('unselected labels')
  3288. this.computeText()
  3289. }
  3290. selectNextLabel() {
  3291. this.selectedLabelIndex++
  3292. if (this.selectedLabelIndex >= this.getLabelTexts().length) {
  3293. this.selectedLabelIndex = 0
  3294. }
  3295. this.computeText()
  3296. }
  3297. selectPreviousLabel() {
  3298. this.selectedLabelIndex--
  3299. if (this.selectedLabelIndex < 0) {
  3300. this.selectedLabelIndex = this.getLabelTexts().length - 1
  3301. }
  3302. this.computeText()
  3303. }
  3304. */
  3305. clicked(button, {ctrl}) {
  3306. if (button === 'left') {
  3307. if (this.isSelected) {
  3308. if (ctrl) {
  3309. return
  3310. }
  3311. if (this.isGroup) {
  3312. this.emit('browse')
  3313. } else if (this.isTrack) {
  3314. this.emit('queue', {where: 'next', play: true})
  3315. } else if (!this.isPlayable) {
  3316. this.emit('open')
  3317. }
  3318. return false
  3319. } else {
  3320. this.parent.selectInput(this)
  3321. }
  3322. } else if (button === 'right') {
  3323. this.parent.selectInput(this)
  3324. this.emit('menu', this)
  3325. return false
  3326. }
  3327. }
  3328. writeStatus(writable) {
  3329. const markStatus = this.app.getMarkStatus(this.item)
  3330. const color = this.app.themeColor + 30
  3331. if (this.isGroup) {
  3332. // The ANSI attributes here will apply to the rest of the line, too.
  3333. // (We don't reset the active attributes until after drawing the rest of
  3334. // the line.)
  3335. if (markStatus === 'marked' || markStatus === 'partial') {
  3336. writable.write(ansi.setAttributes([color + 10]))
  3337. } else {
  3338. writable.write(ansi.setAttributes([color, ansi.A_BRIGHT]))
  3339. }
  3340. } else if (this.isTrack) {
  3341. if (markStatus === 'marked') {
  3342. writable.write(ansi.setAttributes([ansi.C_WHITE + 10, ansi.C_BLACK, ansi.A_BRIGHT]))
  3343. }
  3344. } else if (!this.isPlayable) {
  3345. if (markStatus === 'marked') {
  3346. writable.write(ansi.setAttributes([ansi.C_WHITE + 10, ansi.C_BLACK, ansi.A_BRIGHT]))
  3347. } else {
  3348. writable.write(ansi.setAttributes([ansi.A_DIM]))
  3349. }
  3350. }
  3351. this.drawX += 3
  3352. const braille = '⠈⠐⠠⠄⠂⠁'
  3353. const brailleChar = braille[Math.floor(Date.now() / 250) % 6]
  3354. const record = this.app.backend.getRecordFor(this.item)
  3355. if (markStatus === 'marked') {
  3356. writable.write('+')
  3357. } else if (markStatus === 'partial') {
  3358. writable.write('*')
  3359. } else {
  3360. writable.write(' ')
  3361. }
  3362. if (this.isGroup) {
  3363. writable.write('G')
  3364. } else if (!this.isPlayable) {
  3365. writable.write('F')
  3366. } else if (record.downloading) {
  3367. writable.write(brailleChar)
  3368. } else if (this.app.SQP.playingTrack === this.item) {
  3369. writable.write('\u25B6')
  3370. } else if (this.app.hasTimestampsFile(this.item)) {
  3371. writable.write(':')
  3372. } else {
  3373. writable.write(' ')
  3374. }
  3375. writable.write(' ')
  3376. }
  3377. get isGroup() {
  3378. return isGroup(this.item)
  3379. }
  3380. get isTrack() {
  3381. return isTrack(this.item)
  3382. }
  3383. get isPlayable() {
  3384. return isPlayable(this.item)
  3385. }
  3386. }
  3387. class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement {
  3388. constructor(item, timestampData, tsDataArray, app) {
  3389. super('')
  3390. this.app = app
  3391. this.data = timestampData
  3392. this.tsData = tsDataArray
  3393. this.item = item
  3394. this.hideMetadata = false
  3395. }
  3396. drawTo(writable) {
  3397. const { data, tsData } = this
  3398. const metadata = this.app.backend.getMetadataFor(this.item)
  3399. const last = tsData[tsData.length - 1]
  3400. const duration = ((metadata && metadata.duration)
  3401. || last.timestampEnd !== Infinity && last.timestampEnd
  3402. || last.timestamp)
  3403. const strings = getTimeStringsFromSec(data.timestamp, duration)
  3404. this.text = (
  3405. /*
  3406. (trackDuration
  3407. ? `(${strings.timeDone} - ${strings.percentDone})`
  3408. : `(${strings.timeDone})`) +
  3409. */
  3410. `(${strings.timeDone})` +
  3411. (data.comment
  3412. ? ` ${data.comment}`
  3413. : '')
  3414. )
  3415. if (!this.hideMetadata) {
  3416. const durationString = (data.timestampEnd === Infinity
  3417. ? 'to end'
  3418. : getTimeStringsFromSec(0, data.timestampEnd - data.timestamp).duration)
  3419. // Try to line up so there's one column of negative padding - the duration
  3420. // of the timestamp(s) should start one column before the duration of the
  3421. // actual track. This makes for a nice nested look!
  3422. const rightPadding = ' '.repeat(duration > 3600 ? 4 : 2)
  3423. this.rightText = ` (${durationString})` + rightPadding
  3424. }
  3425. super.drawTo(writable)
  3426. }
  3427. writeStatus(writable) {
  3428. let parts = []
  3429. const color = ansi.setAttributes([ansi.A_BRIGHT, 30 + this.app.themeColor])
  3430. const reset = ansi.setAttributes([ansi.C_RESET])
  3431. if (this.isCurrentTimestamp) {
  3432. parts = [
  3433. color,
  3434. ' ',
  3435. // reset,
  3436. '\u25B6 ',
  3437. // color,
  3438. ' '
  3439. ]
  3440. } else {
  3441. parts = [
  3442. color,
  3443. ' ',
  3444. reset,
  3445. ':',
  3446. color,
  3447. ' '
  3448. ]
  3449. }
  3450. for (const part of parts) {
  3451. writable.write(part)
  3452. }
  3453. this.drawX += 4
  3454. }
  3455. get isCurrentTimestamp() {
  3456. const { SQP } = this.app
  3457. return (
  3458. SQP.playingTrack === this.item &&
  3459. SQP.timeData &&
  3460. SQP.timeData.curSecTotal >= this.data.timestamp &&
  3461. SQP.timeData.curSecTotal < this.data.timestampEnd
  3462. )
  3463. }
  3464. getLeftPadding() {
  3465. return 4
  3466. }
  3467. }
  3468. class ListingJumpElement extends Form {
  3469. constructor() {
  3470. super()
  3471. this.label = new Label('Jump to: ')
  3472. this.addChild(this.label)
  3473. this.input = new TextInput()
  3474. this.addInput(this.input)
  3475. this.input.on('confirm', value => this.emit('confirm', value))
  3476. this.input.on('change', value => this.emit('change', value))
  3477. this.input.on('cancel', () => this.emit('cancel'))
  3478. }
  3479. selected() {
  3480. this.input.value = ''
  3481. this.input.keepCursorInRange()
  3482. this.root.select(this.input)
  3483. }
  3484. fixLayout() {
  3485. this.input.x = this.label.right
  3486. this.input.w = this.contentW - this.input.x
  3487. }
  3488. keyPressed(keyBuf) {
  3489. const val = super.keyPressed(keyBuf)
  3490. if (typeof val !== 'undefined') {
  3491. return val
  3492. }
  3493. // Don't bubble escape.
  3494. if (telc.isEscape(keyBuf)) {
  3495. return false
  3496. }
  3497. }
  3498. }
  3499. class PathElement extends ListScrollForm {
  3500. constructor() {
  3501. // TODO: Once we've got the horizontal scrollbar draw working, perhaps
  3502. // enable this? Well probably not. This is more a TODO to just, well,
  3503. // implement that horizontal scrollbar drawing anyway.
  3504. super('horizontal', false)
  3505. this.captureTab = false
  3506. }
  3507. showItem(item) {
  3508. while (this.inputs.length) {
  3509. this.removeInput(this.inputs[0])
  3510. }
  3511. if (!item) {
  3512. return
  3513. }
  3514. const itemPath = getItemPath(item)
  3515. const parentPath = itemPath.slice(0, -1)
  3516. for (let i = 0; i < parentPath.length; i++) {
  3517. const pathItem = parentPath[i]
  3518. const nextItem = itemPath[i + 1]
  3519. const isFirst = (i === 0)
  3520. const element = new PathItemElement(pathItem, isFirst)
  3521. element.on('select', () => this.emit('select', pathItem, nextItem))
  3522. element.fixLayout()
  3523. this.addInput(element)
  3524. }
  3525. this.curIndex = this.inputs.length - 1
  3526. this.scrollToEnd()
  3527. this.fixLayout()
  3528. }
  3529. }
  3530. class PathItemElement extends FocusElement {
  3531. constructor(item, isFirst) {
  3532. super()
  3533. this.item = item
  3534. this.isFirst = isFirst
  3535. this.arrowLabel = new Label(isFirst ? 'In: ' : ' > ')
  3536. this.addChild(this.arrowLabel)
  3537. this.button = new Button(item.name || '(Unnamed)')
  3538. this.addChild(this.button)
  3539. this.button.on('pressed', () => {
  3540. this.emit('select')
  3541. })
  3542. }
  3543. selected() {
  3544. this.root.select(this.button)
  3545. }
  3546. clicked(button) {
  3547. if (button === 'left') {
  3548. this.emit('select')
  3549. }
  3550. }
  3551. fixLayout() {
  3552. const text = this.item.name || '(Unnamed)'
  3553. const maxWidth = this.parent ? this.parent.contentW : Infinity
  3554. this.arrowLabel.fixLayout()
  3555. const maxButtonWidth = maxWidth - this.arrowLabel.w
  3556. if (text.length > maxButtonWidth) {
  3557. this.button.text = unic.ELLIPSIS + text.slice(-(maxButtonWidth - 1))
  3558. } else {
  3559. this.button.text = text
  3560. }
  3561. this.button.fixLayout()
  3562. this.w = this.button.w + this.arrowLabel.w
  3563. this.button.x = this.arrowLabel.right
  3564. this.h = 1
  3565. }
  3566. }
  3567. class QueueListingElement extends GrouplikeListingElement {
  3568. getNewForm() {
  3569. return new QueueListingForm(this.app)
  3570. }
  3571. keyPressed(keyBuf) {
  3572. if (input.isShuffleQueue(keyBuf)) {
  3573. this.emit('shuffle')
  3574. } else if (input.isClearQueue(keyBuf)) {
  3575. this.emit('clear')
  3576. } else {
  3577. return super.keyPressed(keyBuf)
  3578. }
  3579. }
  3580. }
  3581. class QueueListingForm extends GrouplikeListingForm {
  3582. updateSelectedElement() {
  3583. if (this.inputs.length) {
  3584. super.updateSelectedElement()
  3585. } else {
  3586. this.emit('select main listing')
  3587. }
  3588. }
  3589. }
  3590. class PlaybackInfoElement extends FocusElement {
  3591. constructor(queuePlayer, app) {
  3592. super()
  3593. this.queuePlayer = queuePlayer
  3594. this.app = app
  3595. this.displayMode = 'expanded'
  3596. this.timeData = {}
  3597. this.queuePlayerIndex = 0
  3598. this.queuePlayerSelected = false
  3599. this.progressBarLabel = new Label('')
  3600. this.addChild(this.progressBarLabel)
  3601. this.progressTextLabel = new Label('')
  3602. this.addChild(this.progressTextLabel)
  3603. this.trackNameLabel = new Label('')
  3604. this.addChild(this.trackNameLabel)
  3605. this.downloadLabel = new Label('')
  3606. this.addChild(this.downloadLabel)
  3607. this.queuePlayerIndexLabel = new Label('')
  3608. this.addChild(this.queuePlayerIndexLabel)
  3609. this.remainingTracksLabel = new Label('')
  3610. this.addChild(this.remainingTracksLabel)
  3611. this.updateTrack()
  3612. this.updateProgress()
  3613. this.handleQueueUpdated = this.handleQueueUpdated.bind(this)
  3614. this.attachQueuePlayerListeners()
  3615. }
  3616. attachQueuePlayerListeners() {
  3617. this.queuePlayer.on('queue updated', this.handleQueueUpdated)
  3618. }
  3619. removeQueuePlayerListeners() {
  3620. this.queuePlayer.removeListener('queue updated', this.handleQueueUpdated)
  3621. }
  3622. handleQueueUpdated() {
  3623. this.updateProgress()
  3624. this.updateTrack()
  3625. }
  3626. fixLayout() {
  3627. this.refreshProgressText()
  3628. if (this.displayMode === 'expanded') {
  3629. this.fixLayoutExpanded()
  3630. } else if (this.displayMode === 'collapsed') {
  3631. this.fixLayoutCollapsed()
  3632. }
  3633. }
  3634. fixLayoutExpanded() {
  3635. if (this.parent) {
  3636. this.fillParent()
  3637. }
  3638. this.queuePlayerIndexLabel.visible = false
  3639. this.remainingTracksLabel.visible = false
  3640. this.downloadLabel.visible = true
  3641. this.trackNameLabel.y = 0
  3642. this.progressBarLabel.y = 1
  3643. this.progressTextLabel.y = this.progressBarLabel.y
  3644. this.downloadLabel.y = 2
  3645. if (this.currentTrack) {
  3646. const dl = this.currentTrack.downloaderArg
  3647. let dlText = dl.slice(Math.max(dl.length - this.w + 20, 0))
  3648. if (dlText !== dl) {
  3649. dlText = unic.ELLIPSIS + dlText
  3650. }
  3651. this.downloadLabel.text = `(From: ${dlText})`
  3652. }
  3653. for (const el of [
  3654. this.progressTextLabel,
  3655. this.trackNameLabel,
  3656. this.downloadLabel
  3657. ]) {
  3658. el.x = Math.round((this.w - el.w) / 2)
  3659. }
  3660. }
  3661. fixLayoutCollapsed() {
  3662. if (this.parent) {
  3663. this.w = Math.max(30, this.parent.contentW)
  3664. }
  3665. this.h = 1
  3666. this.queuePlayerIndexLabel.visible = true
  3667. this.remainingTracksLabel.visible = true
  3668. this.downloadLabel.visible = false
  3669. const why = this.app.willActOnQueuePlayer(this.queuePlayer)
  3670. const index = this.app.backend.queuePlayers.indexOf(this.queuePlayer)
  3671. const msg = (why ? '!' : ' ') + index
  3672. this.queuePlayerIndexLabel.text = (this.app.SQP === this.queuePlayer
  3673. ? `<${msg}>`
  3674. : ` ${msg} `)
  3675. if (why === 'marked') {
  3676. this.queuePlayerIndexLabel.textAttributes = [ansi.A_BRIGHT]
  3677. } else {
  3678. this.queuePlayerIndexLabel.textAttributes = []
  3679. }
  3680. this.queuePlayerIndexLabel.x = 1
  3681. this.queuePlayerIndexLabel.y = 0
  3682. this.trackNameLabel.x = this.queuePlayerIndexLabel.right + 1
  3683. this.trackNameLabel.y = 0
  3684. this.progressBarLabel.y = 0
  3685. this.progressBarLabel.x = 0
  3686. this.remainingTracksLabel.x = this.contentW - this.remainingTracksLabel.w - 1
  3687. this.remainingTracksLabel.y = 0
  3688. this.progressTextLabel.x = this.remainingTracksLabel.x - this.progressTextLabel.w - 1
  3689. this.progressTextLabel.y = 0
  3690. this.refreshTrackText(this.progressTextLabel.x - 2 - this.trackNameLabel.x)
  3691. this.refreshProgressText()
  3692. }
  3693. clicked(button) {
  3694. if (button === 'scroll-up') {
  3695. this.emit('seek back')
  3696. } else if (button === 'scroll-down') {
  3697. this.emit('seek ahead')
  3698. } else if (button === 'left') {
  3699. if (this.displayMode === 'expanded') {
  3700. this.emit('toggle pause')
  3701. } else if (this.isSelected) {
  3702. this.showMenu()
  3703. } else {
  3704. this.root.select(this)
  3705. }
  3706. }
  3707. }
  3708. keyPressed(keyBuf) {
  3709. if (input.isSelect(keyBuf)) {
  3710. this.showMenu()
  3711. return false
  3712. }
  3713. }
  3714. showMenu() {
  3715. const fn = this.showContextMenu || this.app.showContextMenu
  3716. fn({
  3717. x: this.absLeft,
  3718. y: this.absTop + 1,
  3719. items: [
  3720. {
  3721. label: 'Select',
  3722. action: () => {
  3723. this.app.selectQueuePlayer(this.queuePlayer)
  3724. this.parent.fixLayout()
  3725. }
  3726. },
  3727. {
  3728. label: (this.app.willActOnQueuePlayer(this.queuePlayer) === 'marked'
  3729. ? 'Remove from multiple-player selection'
  3730. : 'Add to multiple-player selection'),
  3731. action: () => {
  3732. this.app.toggleActOnQueuePlayer(this.queuePlayer)
  3733. this.parent.fixLayout()
  3734. }
  3735. },
  3736. this.app.backend.queuePlayers.length > 1 && {
  3737. label: 'Delete',
  3738. action: () => {
  3739. this.app.removeQueuePlayer(this.queuePlayer)
  3740. }
  3741. }
  3742. ]
  3743. })
  3744. }
  3745. refreshProgressText() {
  3746. const { player, timeData } = this.queuePlayer
  3747. this.remainingTracksLabel.text = (this.queuePlayer.playingTrack
  3748. ? `(+${this.queuePlayer.remainingTracks})`
  3749. : `(${this.queuePlayer.remainingTracks})`)
  3750. if (!timeData) {
  3751. return
  3752. }
  3753. const { timeDone, duration, lenSecTotal, curSecTotal } = timeData
  3754. this.timeData = timeData
  3755. this.curSecTotal = curSecTotal
  3756. this.lenSecTotal = lenSecTotal
  3757. this.volume = player.volume
  3758. this.isLooping = player.isLooping
  3759. this.isPaused = player.isPaused
  3760. if (duration) {
  3761. this.progressBarLabel.text = '-'.repeat(Math.floor(this.w / lenSecTotal * curSecTotal))
  3762. this.progressTextLabel.text = timeDone + ' / ' + duration
  3763. } else {
  3764. this.progressBarLabel.text = ''
  3765. this.progressTextLabel.text = timeDone
  3766. }
  3767. if (player.isLooping) {
  3768. this.progressTextLabel.text += ' [Looping]'
  3769. }
  3770. if (player.volume !== 100) {
  3771. this.progressTextLabel.text += ` [Volume: ${Math.round(player.volume)}%]`
  3772. }
  3773. }
  3774. refreshTrackText(maxNameWidth = Infinity) {
  3775. const { playingTrack } = this.queuePlayer
  3776. const waitingTrackData = getWaitingTrackData(this.queuePlayer)
  3777. if (playingTrack) {
  3778. this.currentTrack = playingTrack
  3779. const { name } = playingTrack
  3780. if (ansi.measureColumns(name) > maxNameWidth) {
  3781. this.trackNameLabel.text = ansi.trimToColumns(name, maxNameWidth) + unic.ELLIPSIS
  3782. } else {
  3783. this.trackNameLabel.text = playingTrack.name
  3784. }
  3785. this.progressBarLabel.text = ''
  3786. this.progressTextLabel.text = '(Starting..)'
  3787. this.timeData = {}
  3788. } else if (waitingTrackData) {
  3789. const { name } = waitingTrackData
  3790. this.clearInfoText()
  3791. this.trackNameLabel.text = name
  3792. this.progressTextLabel.text = '(Waiting to play, once found in playlist source.)'
  3793. } else {
  3794. this.clearInfoText()
  3795. }
  3796. }
  3797. clearInfoText() {
  3798. this.currentTrack = null
  3799. this.progressBarLabel.text = ''
  3800. this.progressTextLabel.text = ''
  3801. this.trackNameLabel.text = ''
  3802. this.downloadLabel.text = ''
  3803. this.timeData = {}
  3804. }
  3805. updateProgress() {
  3806. this.refreshProgressText()
  3807. this.fixLayout()
  3808. }
  3809. updateTrack() {
  3810. this.refreshTrackText()
  3811. this.fixLayout()
  3812. }
  3813. clearInfo() {
  3814. this.clearInfoText()
  3815. this.fixLayout()
  3816. }
  3817. drawTo(writable) {
  3818. if (this.isSelected) {
  3819. this.progressBarLabel.textAttributes = [ansi.A_INVERT]
  3820. } else {
  3821. this.progressBarLabel.textAttributes = []
  3822. }
  3823. if (this.isSelected) {
  3824. writable.write(ansi.invert())
  3825. writable.write(ansi.moveCursor(this.absTop, this.absLeft))
  3826. writable.write(' '.repeat(this.w))
  3827. }
  3828. }
  3829. get curSecTotal() { return this.getDep('curSecTotal') }
  3830. set curSecTotal(v) { this.setDep('curSecTotal', v) }
  3831. get lenSecTotal() { return this.getDep('lenSecTotal') }
  3832. set lenSecTotal(v) { this.setDep('lenSecTotal', v) }
  3833. get volume() { return this.getDep('volume') }
  3834. set volume(v) { this.setDep('volume', v) }
  3835. get isLooping() { return this.getDep('isLooping') }
  3836. set isLooping(v) { this.setDep('isLooping', v) }
  3837. get isPaused() { return this.getDep('isPaused') }
  3838. set isPaused(v) { this.setDep('isPaused', v) }
  3839. get currentTrack() { return this.getDep('currentTrack') }
  3840. set currentTrack(v) { this.setDep('currentTrack', v) }
  3841. }
  3842. class OpenPlaylistDialog extends Dialog {
  3843. constructor() {
  3844. super()
  3845. this.label = new Label('Enter a playlist source:')
  3846. this.pane.addChild(this.label)
  3847. this.form = new Form()
  3848. this.pane.addChild(this.form)
  3849. this.input = new TextInput()
  3850. this.form.addInput(this.input)
  3851. this.button = new Button('Open')
  3852. this.form.addInput(this.button)
  3853. this.buttonNewTab = new Button('..in New Tab')
  3854. this.form.addInput(this.buttonNewTab)
  3855. this.button.on('pressed', () => {
  3856. if (this.input.value) {
  3857. this.emit('source selected', this.input.value)
  3858. }
  3859. })
  3860. this.buttonNewTab.on('pressed', () => {
  3861. if (this.input.value) {
  3862. this.emit('source selected (new tab)', this.input.value)
  3863. }
  3864. })
  3865. }
  3866. opened() {
  3867. this.input.setValue('')
  3868. this.form.curIndex = 0
  3869. this.form.updateSelectedElement()
  3870. }
  3871. fixLayout() {
  3872. super.fixLayout()
  3873. this.pane.w = Math.min(60, this.contentW)
  3874. this.pane.h = 6
  3875. this.pane.centerInParent()
  3876. this.label.centerInParent()
  3877. this.label.y = 0
  3878. this.form.w = this.pane.contentW
  3879. this.form.h = 2
  3880. this.form.y = 1
  3881. this.input.w = this.form.contentW
  3882. this.button.centerInParent()
  3883. this.button.y = 1
  3884. this.buttonNewTab.centerInParent()
  3885. this.buttonNewTab.y = 2
  3886. }
  3887. selected() {
  3888. this.root.select(this.form)
  3889. }
  3890. }
  3891. class AlertDialog extends Dialog {
  3892. constructor() {
  3893. super()
  3894. this.label = new Label()
  3895. this.pane.addChild(this.label)
  3896. this.button = new Button('Close')
  3897. this.button.on('pressed', () => {
  3898. if (this.canClose) {
  3899. this.emit('cancelled')
  3900. }
  3901. })
  3902. this.pane.addChild(this.button)
  3903. }
  3904. selected() {
  3905. this.root.select(this.button)
  3906. }
  3907. showMessage(message, canClose = true) {
  3908. this.canClose = canClose
  3909. this.label.text = message
  3910. this.button.text = canClose ? 'Close' : '(Hold on...)'
  3911. this.open()
  3912. }
  3913. fixLayout() {
  3914. super.fixLayout()
  3915. this.pane.w = Math.min(this.label.w + 4, this.contentW)
  3916. this.pane.h = 4
  3917. this.pane.centerInParent()
  3918. this.label.centerInParent()
  3919. this.label.y = 0
  3920. this.button.fixLayout()
  3921. this.button.centerInParent()
  3922. this.button.y = 1
  3923. }
  3924. keyPressed() {
  3925. // Don't handle the escape key.
  3926. }
  3927. }
  3928. class Tabber extends FocusElement {
  3929. constructor() {
  3930. super()
  3931. this.tabberElements = []
  3932. this.currentElementIndex = 0
  3933. this.listElement = new TabberList(this)
  3934. this.addChild(this.listElement)
  3935. this.listElement.on('select', item => this.selectTab(item))
  3936. this.listElement.on('next tab', () => this.nextTab())
  3937. this.listElement.on('previous tab', () => this.previousTab())
  3938. }
  3939. fixLayout() {
  3940. const el = this.currentElement
  3941. if (el) {
  3942. // Only make space for the tab list if there's more than one tab visible.
  3943. // (The tab list isn't shown if there's only one.)
  3944. if (this.tabberElements.length > 1) {
  3945. el.w = this.contentW
  3946. el.h = this.contentH - 1
  3947. el.x = 0
  3948. el.y = 1
  3949. } else {
  3950. el.fillParent()
  3951. el.x = 0
  3952. el.y = 0
  3953. }
  3954. el.fixLayout()
  3955. }
  3956. if (this.tabberElements.length > 1) {
  3957. this.listElement.visible = true
  3958. this.listElement.w = this.contentW
  3959. this.listElement.h = 1
  3960. this.listElement.fixLayout()
  3961. } else {
  3962. this.listElement.visible = false
  3963. }
  3964. }
  3965. addTab(element, index = this.currentElementIndex) {
  3966. element.visible = false
  3967. this.tabberElements.splice(index + 1, 0, element)
  3968. this.addChild(element, index + 1)
  3969. this.listElement.buildItems()
  3970. }
  3971. nextTab() {
  3972. this.currentElementIndex++
  3973. if (this.currentElementIndex >= this.tabberElements.length) {
  3974. this.currentElementIndex = 0
  3975. }
  3976. this.updateVisibleElement()
  3977. }
  3978. previousTab() {
  3979. this.currentElementIndex--
  3980. if (this.currentElementIndex < 0) {
  3981. this.currentElementIndex = this.tabberElements.length - 1
  3982. }
  3983. this.updateVisibleElement()
  3984. }
  3985. selectTab(element) {
  3986. if (!this.tabberElements.includes(element)) {
  3987. throw new Error('That tab does not exist! (Perhaps it was removed, somehow, or was never added?)')
  3988. }
  3989. this.currentElementIndex = this.tabberElements.indexOf(element)
  3990. this.updateVisibleElement()
  3991. }
  3992. closeTab(element) {
  3993. if (!this.tabberElements.includes(element)) {
  3994. return
  3995. }
  3996. const index = this.tabberElements.indexOf(element)
  3997. this.tabberElements.splice(index, 1)
  3998. if (index <= this.currentElementIndex) {
  3999. this.currentElementIndex--
  4000. }
  4001. // Deliberately update the visible element before removing the child. If we
  4002. // remove the child first, the isSelected in updateVisibleElement will be
  4003. // false, so the new currentElement won't actually be root.select()'ed.
  4004. this.updateVisibleElement()
  4005. this.removeChild(element)
  4006. this.listElement.buildItems()
  4007. }
  4008. updateVisibleElement() {
  4009. const len = this.tabberElements.length - 1
  4010. this.currentElementIndex = Math.min(len, Math.max(0, this.currentElementIndex))
  4011. this.tabberElements.forEach((el, i) => {
  4012. el.visible = (i === this.currentElementIndex)
  4013. })
  4014. if (this.isSelected) {
  4015. if (this.currentElement) {
  4016. this.root.select(this.currentElement)
  4017. } else {
  4018. this.root.select(this)
  4019. }
  4020. }
  4021. this.fixLayout()
  4022. }
  4023. selected() {
  4024. if (this.currentElement) {
  4025. this.root.select(this.currentElement)
  4026. }
  4027. }
  4028. get selectable() {
  4029. return this.currentElement && this.currentElement.selectable
  4030. }
  4031. get currentElement() {
  4032. return this.tabberElements[this.currentElementIndex] || null
  4033. }
  4034. }
  4035. class TabberList extends ListScrollForm {
  4036. constructor(tabber) {
  4037. super('horizontal')
  4038. this.tabber = tabber
  4039. this.captureTab = false
  4040. }
  4041. buildItems() {
  4042. while (this.inputs.length) {
  4043. this.removeInput(this.inputs[0])
  4044. }
  4045. for (const item of this.tabber.tabberElements) {
  4046. const element = new TabberListItem(item, this.tabber)
  4047. this.addInput(element)
  4048. element.fixLayout()
  4049. element.on('select', () => this.emit('select', item))
  4050. }
  4051. this.scrollToEnd()
  4052. this.fixLayout()
  4053. }
  4054. fixLayout() {
  4055. this.w = this.parent.contentW
  4056. this.h = 1
  4057. this.x = 0
  4058. this.y = 0
  4059. this.scrollElementIntoEndOfView(this.inputs[this.curIndex])
  4060. super.fixLayout()
  4061. }
  4062. drawTo() {
  4063. let changed = false
  4064. for (const input of this.inputs) {
  4065. input.fixLayout()
  4066. if (input._oldW !== input.w) {
  4067. input._oldW = input.w
  4068. changed = true
  4069. }
  4070. }
  4071. if (changed) {
  4072. this.fixLayout()
  4073. }
  4074. }
  4075. clicked(button) {
  4076. if (button === 'scroll-up') {
  4077. this.emit('previous tab')
  4078. return false
  4079. } else if (button === 'scroll-down') {
  4080. this.emit('next tab')
  4081. return false
  4082. }
  4083. }
  4084. // TODO: Be less hacky about these! Right now the tabber list is totally not
  4085. // interactive.
  4086. get curIndex() { return this.tabber.currentElementIndex }
  4087. set curIndex(newVal) {}
  4088. }
  4089. class TabberListItem extends FocusElement {
  4090. constructor(tab, tabber) {
  4091. super()
  4092. this.tab = tab
  4093. this.tabber = tabber
  4094. }
  4095. fixLayout() {
  4096. this.w = ansi.measureColumns(this.text) + 3
  4097. this.h = 1
  4098. }
  4099. drawTo(writable) {
  4100. if (this.tabber.currentElement === this.tab) {
  4101. writable.write(ansi.setAttributes([ansi.A_BRIGHT]))
  4102. writable.write(ansi.moveCursor(this.absTop, this.absLeft))
  4103. writable.write('<' + this.text + '>')
  4104. writable.write(ansi.resetAttributes())
  4105. } else {
  4106. writable.write(ansi.moveCursor(this.absTop, this.absLeft + 1))
  4107. writable.write(this.text)
  4108. }
  4109. }
  4110. clicked(button) {
  4111. if (button === 'left') {
  4112. this.emit('select')
  4113. return false
  4114. }
  4115. }
  4116. get text() {
  4117. return this.tab.tabberLabel || 'a(n) ' + this.tab.constructor.name
  4118. }
  4119. }
  4120. class ContextMenu extends FocusElement {
  4121. constructor(showContextMenu) {
  4122. super()
  4123. this.pane = new Pane()
  4124. this.addChild(this.pane)
  4125. this.form = new ListScrollForm()
  4126. this.pane.addChild(this.form)
  4127. this.keyboardSelector = new KeyboardSelector(this.form)
  4128. this.visible = false
  4129. this.showContextMenu = showContextMenu
  4130. this.showSubmenu = this.showSubmenu.bind(this)
  4131. this.submenu = null
  4132. }
  4133. show({x = 0, y = 0, pages = null, items: itemsArg = null, focusKey = null, pageNum = 0}) {
  4134. this.reload = () => {
  4135. const els = [this.root.selectedElement, ...this.root.selectedElement.directAncestors]
  4136. const focusKey = Object.keys(keyElementMap).find(key => els.includes(keyElementMap[key]))
  4137. this.close(false)
  4138. this.show({x, y, items: itemsArg, focusKey})
  4139. }
  4140. this.nextPage = () => {
  4141. if (pages.length > 1) {
  4142. pageNum++
  4143. if (pageNum === pages.length) {
  4144. pageNum = 0
  4145. }
  4146. this.close(false)
  4147. this.show({x, y, pages, pageNum})
  4148. }
  4149. }
  4150. this.previousPage = () => {
  4151. if (pages.length > 1) {
  4152. pageNum--
  4153. if (pageNum === -1) {
  4154. pageNum = pages.length - 1
  4155. }
  4156. this.close(false)
  4157. this.show({x, y, pages, pageNum})
  4158. }
  4159. }
  4160. if (!pages && !itemsArg || pages && itemsArg) {
  4161. return
  4162. }
  4163. if (pages) {
  4164. if (pages.length === 0) {
  4165. return
  4166. }
  4167. itemsArg = pages[pageNum]
  4168. }
  4169. let items = (typeof itemsArg === 'function') ? itemsArg() : itemsArg
  4170. items = items.filter(Boolean)
  4171. if (!items.length) {
  4172. return
  4173. }
  4174. // Call refreshValue() on any items before they're shown, for items that
  4175. // provide it. (This is handy when reusing the same input across a menu that
  4176. // might be shown under different contexts.)
  4177. for (const item of items) {
  4178. const el = item.element
  4179. if (!el) continue
  4180. if (!el.refreshValue) continue
  4181. el.refreshValue()
  4182. }
  4183. if (!this.root.selectedElement.directAncestors.includes(this)) {
  4184. this.selectedBefore = this.root.selectedElement
  4185. }
  4186. this.clearItems()
  4187. this.x = x
  4188. this.y = y
  4189. this.visible = true
  4190. // This code is so that we don't show two dividers beside each other, or
  4191. // end a menu with a divider!
  4192. let wantDivider = false
  4193. const addDividerIfWanted = () => {
  4194. if (wantDivider) {
  4195. if (!firstItem) {
  4196. const element = new HorizontalRule()
  4197. this.form.addInput(element)
  4198. }
  4199. wantDivider = false
  4200. }
  4201. }
  4202. let firstItem = true
  4203. const keyElementMap = {}
  4204. for (const item of items.filter(Boolean)) {
  4205. let focusEl
  4206. if (item.element) {
  4207. addDividerIfWanted()
  4208. focusEl = item.element
  4209. this.form.addInput(item.element)
  4210. item.element.showContextMenu = this.showSubmenu
  4211. if (item.isDefault) {
  4212. this.root.select(item.element)
  4213. }
  4214. firstItem = false
  4215. } else if (item.divider) {
  4216. wantDivider = true
  4217. } else {
  4218. addDividerIfWanted()
  4219. let label = item.label
  4220. if (item.isPageSwitcher && pages.length > 1) {
  4221. label = `\x1b[2m(${pageNum + 1}/${pages.length}) « \x1b[22m${label}\x1b[2m »\x1b[22m`
  4222. }
  4223. const button = new Button(label)
  4224. button.keyboardIdentifier = item.keyboardIdentifier || label
  4225. if (item.action) {
  4226. button.on('pressed', async () => {
  4227. this.restoreSelection()
  4228. if (await item.action() === 'reload') {
  4229. this.reload()
  4230. } else {
  4231. this.close()
  4232. }
  4233. })
  4234. }
  4235. if (item.isPageSwitcher) {
  4236. button.on('pressed', async () => {
  4237. this.nextPage()
  4238. })
  4239. }
  4240. button.item = item
  4241. focusEl = button
  4242. this.form.addInput(button)
  4243. if (item.isDefault) {
  4244. this.root.select(button)
  4245. }
  4246. firstItem = false
  4247. }
  4248. if (item.key) {
  4249. keyElementMap[item.key] = focusEl
  4250. }
  4251. }
  4252. this.fixLayout()
  4253. if (focusKey && keyElementMap[focusKey]) {
  4254. this.root.select(keyElementMap[focusKey])
  4255. } else if (!items.some(item => item.isDefault)) {
  4256. this.form.firstInput()
  4257. }
  4258. this.keyboardSelector.reset()
  4259. }
  4260. showSubmenu(opts) {
  4261. this.showContextMenu(Object.assign({}, opts, {
  4262. // We need to get a reference to the submenu before it is shown, or else
  4263. // the parent menu will be closed (from being unselected and not knowing
  4264. // that a submenu was just opened).
  4265. beforeShowing: menu => {
  4266. this.submenu = menu
  4267. }
  4268. }))
  4269. this.submenu.on('close', () => {
  4270. this.submenu = null
  4271. })
  4272. }
  4273. keyPressed(keyBuf) {
  4274. if (telc.isEscape(keyBuf) || telc.isBackspace(keyBuf)) {
  4275. this.restoreSelection()
  4276. this.close()
  4277. return false
  4278. } else if (this.keyboardSelector.keyPressed(keyBuf)) {
  4279. return false
  4280. } else if (input.isScrollToStart(keyBuf)) {
  4281. this.form.firstInput()
  4282. this.form.scrollToBeginning()
  4283. } else if (input.isScrollToEnd(keyBuf)) {
  4284. this.form.lastInput()
  4285. } else if (input.isLeft(keyBuf) || input.isRight(keyBuf)) {
  4286. if (this.form.inputs[this.form.curIndex].item.isPageSwitcher) {
  4287. if (input.isLeft(keyBuf)) {
  4288. this.previousPage()
  4289. } else {
  4290. this.nextPage()
  4291. }
  4292. return false
  4293. }
  4294. } else {
  4295. return super.keyPressed(keyBuf)
  4296. }
  4297. }
  4298. unselected() {
  4299. // Don't close if we just opened a submenu!
  4300. const newEl = this.root.selectedElement
  4301. if (this.submenu && newEl.directAncestors.includes(this.submenu)) {
  4302. return
  4303. }
  4304. if (this.visible) {
  4305. this.close()
  4306. }
  4307. }
  4308. close(remove = true) {
  4309. this.clearItems()
  4310. this.visible = false
  4311. if (remove && this.parent) {
  4312. this.parent.removeChild(this)
  4313. this.emit('closed')
  4314. }
  4315. }
  4316. restoreSelection() {
  4317. if (this.selectedBefore.root.select) {
  4318. this.selectedBefore.root.select(this.selectedBefore)
  4319. }
  4320. }
  4321. clearItems() {
  4322. // Abhorrent hack - just erases children from memory. Leaves children
  4323. // thinking they've still got a parent, though. (Necessary to avoid crazy
  4324. // select() loops that probably explode the world... speaking of things
  4325. // to forget, that one time when I was figuring out menus in the queue.
  4326. // This makes them work.)
  4327. this.form.children = this.form.children.filter(
  4328. child => !this.form.inputs.includes(child))
  4329. this.form.inputs = []
  4330. }
  4331. fixLayout() {
  4332. // Do an initial pass to determine the width of this menu (or in particular
  4333. // the form), which is the greatest width of all the inputs.
  4334. let width = 10
  4335. // Some elements resize to fill their parent (the menu)'s width. Since we
  4336. // want to know what their *minimum* width is, we'll immediately change the
  4337. // parent width that they see.
  4338. this.form.w = width
  4339. for (const input of this.form.inputs) {
  4340. input.fixLayout()
  4341. width = Math.max(width, input.w)
  4342. }
  4343. let height = Math.min(14, this.form.inputs.length)
  4344. width += 2 // Space for the pane border
  4345. height += 2 // Space for the pane border
  4346. if (this.form.scrollBarShown) width++
  4347. this.w = width
  4348. this.h = height
  4349. this.fitToParent()
  4350. this.pane.fillParent()
  4351. this.form.fillParent()
  4352. this.form.fixLayout()
  4353. // After everything else, do a second pass to apply the decided width
  4354. // to every element, so that they expand to all be the same width.
  4355. // In order to change the width of a button (which is what these elements
  4356. // are), we need to append space characters.
  4357. for (const input of this.form.inputs) {
  4358. input.fixLayout()
  4359. if (input.text) {
  4360. const inputWidth = ansi.measureColumns(input.text)
  4361. if (inputWidth < this.form.contentW) {
  4362. input.text += ' '.repeat(this.form.contentW - inputWidth)
  4363. }
  4364. }
  4365. }
  4366. }
  4367. selected() {
  4368. this.root.select(this.form)
  4369. }
  4370. }
  4371. class HorizontalRule extends FocusElement {
  4372. // It's just a horizontal rule. Y'know..
  4373. // --------------------------------------------------------------------------
  4374. // You get the idea. :)
  4375. get selectable() {
  4376. // Just return false. A HorizontalRule is technically a FocusElement,
  4377. // but that's just so that it can be used in place of other inputs
  4378. // (e.g. in a ContextMenu).
  4379. return false
  4380. }
  4381. fixLayout() {
  4382. this.w = this.parent.contentW
  4383. this.h = 1
  4384. }
  4385. drawTo(writable) {
  4386. // For the character we draw with, we use an ordinary dash instead of
  4387. // an actual box-drawing horizontal line. That's so that the rule is
  4388. // distinguishable from the edge of a Pane.
  4389. writable.write(ansi.moveCursor(this.absTop, this.absLeft))
  4390. writable.write('-'.repeat(this.w))
  4391. }
  4392. }
  4393. class KeyboardSelector {
  4394. // Class used to select things when you type out their name. Specify strings
  4395. // used to access each element of a form in the keyboardIdentifier property.
  4396. // (Elements without a keyboardIdentifier, or which are !selectable, will be
  4397. // skipped.)
  4398. constructor(form) {
  4399. this.value = ''
  4400. this.form = form
  4401. }
  4402. reset() {
  4403. this.value = ''
  4404. }
  4405. keyPressed(keyBuf) {
  4406. // Don't do anything if the input isn't a single keyboard character.
  4407. if (keyBuf.length !== 1 || keyBuf[0] <= 31 || keyBuf[0] >= 127) {
  4408. return
  4409. }
  4410. // First see if a result is found when we append the typed character to our
  4411. // recorded input.
  4412. const char = keyBuf.toString()
  4413. this.value += char
  4414. if (!KeyboardSelector.find(this.value, this.form)) {
  4415. // If we don't find a result, replace our recorded input with the single
  4416. // character entered, then do a search. Start from the input after the
  4417. // current-selected one, so that we don't just end up re-selecting the
  4418. // element that was selected before, if there's another option that would
  4419. // match this key ahead. (This is so that you can easily type a string or
  4420. // character over and over to navigate through options that all start
  4421. // with the same string/character.)
  4422. this.value = char
  4423. return KeyboardSelector.find(this.value, this.form, this.form.curIndex + 1)
  4424. }
  4425. return true
  4426. }
  4427. static find(text, form, fromIndex = form.curIndex) {
  4428. // Most of this code is just stolen from AppElement's code for handling
  4429. // input from JumpElement!
  4430. const lower = text.toLowerCase()
  4431. const getName = inp => inp.keyboardIdentifier ? inp.keyboardIdentifier.toLowerCase().trim() : ''
  4432. const testStartsWith = inp => getName(inp).startsWith(lower)
  4433. const searchPastCurrentIndex = test => {
  4434. const start = fromIndex
  4435. const match = form.inputs.slice(start).findIndex(test)
  4436. if (match === -1) {
  4437. return -1
  4438. } else {
  4439. return start + match
  4440. }
  4441. }
  4442. const allIndexes = [
  4443. searchPastCurrentIndex(testStartsWith),
  4444. form.inputs.findIndex(testStartsWith),
  4445. ]
  4446. const matchedIndex = allIndexes.find(value => value >= 0)
  4447. if (typeof matchedIndex !== 'undefined') {
  4448. form.selectInput(form.inputs[matchedIndex])
  4449. return true
  4450. } else {
  4451. return false
  4452. }
  4453. }
  4454. }
  4455. class Menubar extends ListScrollForm {
  4456. constructor(showContextMenu) {
  4457. super('horizontal')
  4458. this.showContextMenu = showContextMenu
  4459. this.contextMenu = null
  4460. this.color = 4 // blue
  4461. this.attribute = 2 // dim
  4462. this.keyboardSelector = new KeyboardSelector(this)
  4463. }
  4464. select() {
  4465. // When the menubar is selected from the menubar's context menu, the UI
  4466. // looks like it's "popping" a state, so don't reset the selected index to
  4467. // the start - something we only do when we "newly" select the menubar.
  4468. if (this.contextMenu && this.contextMenu.isSelected) {
  4469. this.root.select(this)
  4470. } else {
  4471. this.selectedBefore = this.root.selectedElement
  4472. this.firstInput()
  4473. }
  4474. this.keyboardSelector.reset()
  4475. }
  4476. keyPressed(keyBuf) {
  4477. super.keyPressed(keyBuf)
  4478. // Don't pause the music from the menubar!
  4479. if (telc.isSpace(keyBuf)) {
  4480. return false
  4481. }
  4482. if (this.keyboardSelector.keyPressed(keyBuf)) {
  4483. return false
  4484. } else if (input.isNextThemeColor(keyBuf)) {
  4485. // For fun :)
  4486. this.color = (this.color === 8 ? 1 : this.color + 1)
  4487. this.emit('color', this.color)
  4488. return false
  4489. } else if (input.isPreviousThemeColor(keyBuf)) {
  4490. this.color = (this.color === 1 ? 8 : this.color - 1)
  4491. this.emit('color', this.color)
  4492. return false
  4493. } else if (telc.isCaselessLetter(keyBuf, 'a')) {
  4494. this.attribute = (this.attribute % 3) + 1
  4495. return false
  4496. }
  4497. }
  4498. restoreSelection() {
  4499. if (this.selectedBefore) {
  4500. this.root.select(this.selectedBefore)
  4501. this.selectedBefore = null
  4502. }
  4503. }
  4504. buildItems(array) {
  4505. for (const {text, menuItems, menuFn} of array) {
  4506. const button = new Button(` ${text} `)
  4507. const container = new FocusElement()
  4508. container.addChild(button)
  4509. button.x = 1
  4510. container.w = button.w + 2
  4511. container.h = 1
  4512. container.selected = () => this.root.select(button)
  4513. container.keyboardIdentifier = text
  4514. button.on('pressed', () => {
  4515. this.contextMenu = this.showContextMenu({
  4516. x: container.absLeft, y: container.absY + 1,
  4517. items: menuFn || menuItems
  4518. })
  4519. this.contextMenu.on('closed', () => {
  4520. this.contextMenu = null
  4521. })
  4522. })
  4523. this.addInput(container)
  4524. }
  4525. }
  4526. fixLayout() {
  4527. this.x = 0
  4528. this.y = 0
  4529. this.w = this.parent.contentW
  4530. this.h = 1
  4531. super.fixLayout()
  4532. }
  4533. drawTo(writable) {
  4534. writable.write(ansi.moveCursor(this.absTop, this.absLeft))
  4535. writable.write(ansi.setAttributes([this.attribute, 30 + this.color, ansi.A_INVERT, ansi.C_WHITE + 10]))
  4536. writable.write(' '.repeat(this.w))
  4537. writable.write(ansi.resetAttributes())
  4538. }
  4539. get color() { return this.getDep('color') }
  4540. set color(v) { this.setDep('color', v) }
  4541. get attribute() { return this.getDep('attribute') }
  4542. set attribute(v) { this.setDep('attribute', v) }
  4543. }
  4544. class PartyBanner extends DisplayElement {
  4545. constructor(direction) {
  4546. super()
  4547. this.direction = direction
  4548. }
  4549. drawTo(writable) {
  4550. writable.write(ansi.moveCursor(this.absTop, this.absLeft))
  4551. // TODO: Figure out how to connect this to the draw dependency system.
  4552. // Currently the party banner doesn't schedule any renders itself (meaning
  4553. // if you have nothing playing or otherwise rendering, it'll just stay
  4554. // still).
  4555. const timerNum = Date.now() / 2000 * this.direction
  4556. let lastAttribute = ''
  4557. const updateAttribute = offsetNum => {
  4558. const attr = (Math.cos(offsetNum - timerNum) < 0 ? '\x1b[0;1m' : '\x1b[0;2m')
  4559. if (attr === lastAttribute) {
  4560. return ''
  4561. } else {
  4562. lastAttribute = attr
  4563. return attr
  4564. }
  4565. }
  4566. let str = new Array(this.w).fill('0').map((_, i) => {
  4567. const offsetNum = i / this.w * 2 * Math.PI
  4568. return (
  4569. updateAttribute(offsetNum) +
  4570. (Math.sin(offsetNum + timerNum) < 0 ? '-' : '*')
  4571. )
  4572. }).join('')
  4573. writable.write(str)
  4574. writable.write(ansi.resetAttributes())
  4575. }
  4576. }
  4577. /*
  4578. class NotesTextEditor extends TuiTextEditor {
  4579. constructor() {
  4580. super()
  4581. this.openedItem = null
  4582. }
  4583. keyPressed(keyBuf) {
  4584. if (input.isDeselectTextEditor(keyBuf)) {
  4585. this.emit('deselect')
  4586. return false
  4587. } else if (input.isSaveTextEditor(keyBuf)) {
  4588. this.saveManually()
  4589. return false
  4590. } else {
  4591. return super.keyPressed(keyBuf)
  4592. }
  4593. }
  4594. async openItem(item, {doubleCheckItem}) {
  4595. if (this.hasBeenEdited) {
  4596. // Save in the background.
  4597. this.save()
  4598. }
  4599. const textFile = getCorrespondingFileForItem(item, '.txt')
  4600. if (!textFile) {
  4601. this.openedItem = null
  4602. return false
  4603. }
  4604. if (textFile === this.openedItem) {
  4605. // This file is already open - don't do anything.
  4606. return null
  4607. }
  4608. let filePath
  4609. try {
  4610. filePath = url.fileURLToPath(new URL(textFile.url))
  4611. } catch (error) {
  4612. this.openedItem = null
  4613. return false
  4614. }
  4615. let buffer
  4616. try {
  4617. buffer = await readFile(filePath)
  4618. } catch (error) {
  4619. this.openedItem = null
  4620. return false
  4621. }
  4622. if (!doubleCheckItem(item)) {
  4623. return null
  4624. }
  4625. this.openedItem = textFile
  4626. this.openedPath = filePath
  4627. this.clearSourceAndLoadText(buffer.toString())
  4628. return true
  4629. }
  4630. async saveManually() {
  4631. if (!this.openedItem || !this.openedPath) {
  4632. return
  4633. }
  4634. const item = this.openedItem
  4635. if (await this.save()) {
  4636. if (item === this.openedItem) {
  4637. this.showStatusMessage('Saved manually.')
  4638. }
  4639. }
  4640. }
  4641. async save() {
  4642. if (!this.openedItem || !this.openedPath) {
  4643. return
  4644. }
  4645. const text = this.getSourceText()
  4646. try {
  4647. await writeFile(this.openedPath, text)
  4648. this.clearEditStatus()
  4649. return true
  4650. } catch (error) {
  4651. this.showStatusMessage(`Failed to save (${path.basename(this.openedPath)}: ${error.code}).`)
  4652. return false
  4653. }
  4654. }
  4655. }
  4656. */
  4657. class Log extends ListScrollForm {
  4658. constructor() {
  4659. super('vertical')
  4660. }
  4661. newLogMessage(messageInfo) {
  4662. if (this.inputs.length === 10) {
  4663. this.removeInput(this.inputs[0])
  4664. }
  4665. if (messageInfo.mayCombine) {
  4666. // If a message is specified to "combine", it'll replace an immediately
  4667. // previous message of the same code and sender.
  4668. const previous = this.inputs[this.inputs.length - 1]
  4669. if (
  4670. previous &&
  4671. previous.info.code === messageInfo.code &&
  4672. previous.info.sender === messageInfo.sender
  4673. ) {
  4674. // If the code and sender match, just remove the previous message.
  4675. // It'll be replaced by the one we're about to add!
  4676. this.removeInput(previous)
  4677. }
  4678. }
  4679. const logMessage = new LogMessage(messageInfo)
  4680. this.addInput(logMessage)
  4681. this.fixLayout()
  4682. this.scrollToEnd()
  4683. this.emit('log message', logMessage)
  4684. return logMessage
  4685. }
  4686. }
  4687. class LogMessage extends FocusElement {
  4688. constructor(info) {
  4689. super()
  4690. this.info = info
  4691. const {
  4692. text,
  4693. isVerbose = false
  4694. } = info
  4695. this.label = new LogMessageLabel(text, isVerbose)
  4696. this.addChild(this.label)
  4697. }
  4698. fixLayout() {
  4699. this.w = this.parent.contentW
  4700. this.label.w = this.contentW
  4701. this.h = this.label.h
  4702. }
  4703. clicked(button) {
  4704. if (button === 'left') {
  4705. this.root.select(this)
  4706. return false
  4707. }
  4708. }
  4709. }
  4710. class LogMessageLabel extends WrapLabel {
  4711. constructor(text, isVerbose = false) {
  4712. super(text)
  4713. this.isVerbose = isVerbose
  4714. }
  4715. writeTextTo(writable) {
  4716. const w = this.w
  4717. const lines = this.getWrappedLines()
  4718. for (let i = 0; i < lines.length; i++) {
  4719. const text = this.processFormatting(lines[i])
  4720. writable.write(ansi.moveCursor(this.absTop + i, this.absLeft))
  4721. writable.write(text)
  4722. const width = ansi.measureColumns(text)
  4723. if (width < w && this.textAttributes.length) {
  4724. writable.write(ansi.setAttributes([ansi.A_RESET, ...this.textAttributes]))
  4725. writable.write(' '.repeat(w - width))
  4726. }
  4727. }
  4728. }
  4729. set textAttributes(val) {}
  4730. get textAttributes() {
  4731. return [
  4732. this.parent.isSelected ? 40 : null,
  4733. this.isVerbose ? 2 : null
  4734. ].filter(x => x !== null)
  4735. }
  4736. }