ui.js 148 KB

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