123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376 |
- // The UI in MTUI! Interfaces with the backend to form the complete mtui app.
- import {spawn} from 'node:child_process'
- import {readFile, writeFile} from 'node:fs/promises'
- import path from 'node:path'
- import url from 'node:url'
- import {orderBy} from 'natural-orderby'
- import open from 'open'
- import {Button, Form, ListScrollForm, TextInput} from 'tui-lib/ui/controls'
- import {Dialog} from 'tui-lib/ui/dialogs'
- import {Label, Pane, WrapLabel} from 'tui-lib/ui/presentation'
- import {DisplayElement, FocusElement} from 'tui-lib/ui/primitives'
- import * as ansi from 'tui-lib/util/ansi'
- import telc from 'tui-lib/util/telchars'
- import unic from 'tui-lib/util/unichars'
- import {getAllCrawlersForArg} from './crawlers.js'
- import processSmartPlaylist from './smart-playlist.js'
- import UndoManager from './undo-manager.js'
- import {
- commandExists,
- getSecFromTimestamp,
- getTimeStringsFromSec,
- promisifyProcess,
- shuffleArray,
- } from './general-util.js'
- import {
- cloneGrouplike,
- collapseGrouplike,
- countTotalTracks,
- flattenGrouplike,
- getCorrespondingFileForItem,
- getCorrespondingPlayableForFile,
- getItemPath,
- getNameWithoutTrackNumber,
- isGroup,
- isOpenable,
- isPlayable,
- isTrack,
- parentSymbol,
- reverseOrderOfGroups,
- searchForItem,
- shuffleOrderOfGroups,
- } from './playlist-utils.js'
- /* text editor features disabled because theyre very much incomplete and havent
- * gotten much use from me or anyonea afaik!
- const TuiTextEditor = require('tui-text-editor')
- */
- const input = {}
- const keyBindings = [
- ['isUp', telc.isUp],
- ['isDown', telc.isDown],
- ['isLeft', telc.isLeft],
- ['isRight', telc.isRight],
- ['isSelect', telc.isSelect],
- ['isBackspace', telc.isBackspace],
- ['isMenu', 'm'],
- ['isMenu', 'f'],
- ['isScrollToStart', 'g', {caseless: false}],
- ['isScrollToEnd', 'G', {caseless: false}],
- ['isScrollToStart', telc.isHome],
- ['isScrollToEnd', telc.isEnd],
- ['isTogglePause', telc.isSpace],
- ['isToggleLoop', 'l'],
- ['isStop', telc.isEscape],
- ['isVolumeUp', 'v', {caseless: false}],
- ['isVolumeDown', 'V', {caseless: false}],
- ['isSkipBack', telc.isControlUp],
- ['isSkipAhead', telc.isControlDown],
- ['isSkipBack', 'p'],
- ['isSkipAhead', 'n'],
- ['isFocusTabber', '['],
- ['isFocusQueue', ']'],
- ['isFocusPlaybackInfo', '|'],
- ['isNextTab', 't', {caseless: false}],
- ['isPreviousTab', 'T', {caseless: false}],
- ['isDownload', 'd'],
- ['isRemove', 'x'],
- ['isQueueAfterSelectedTrack', 'q'],
- ['isOpenThroughSystem', 'o'],
- ['isShuffleQueue', 's'],
- ['isClearQueue', 'c'],
- ['isFocusMenubar', ';'],
- // ['isFocusLabels', 'L', {caseless: false}], // todo: better key? to let isToggleLoop be caseless again
- ['isSelectUp', telc.isShiftUp],
- ['isSelectDown', telc.isShiftDown],
- ['isNextThemeColor', 'c', {caseless: false}],
- ['isPreviousThemeColor', 'C', {caseless: false}],
- ['isPreviousPlayer', telc.isMetaUp],
- ['isPreviousPlayer', [0x1b, 'p']],
- ['isNextPlayer', telc.isMetaDown],
- ['isNextPlayer', [0x1b, 'n']],
- ['isNewPlayer', [0x1b, 'c']],
- ['isRemovePlayer', [0x1b, 'x']],
- ['isActOnPlayer', [0x1b, 'a']],
- ['isActOnPlayer', [0x1b, '!']],
- ['isFocusTextEditor', [0x05]], // ^E
- ['isSaveTextEditor', [0x13]], // ^S
- ['isDeselectTextEditor', [0x18]], // ^X
- ['isDeselectTextEditor', telc.isEscape],
- // Number pad
- ['isUp', '8'],
- ['isDown', '2'],
- ['isLeft', '4'],
- ['isRight', '6'],
- ['isSpace', '5'],
- ['isTogglePause', '5'],
- ['isBackspace', '.'],
- ['isMenu', '+'],
- ['isMenu', '0'],
- ['isSkipBack', '1'],
- ['isSkipAhead', '3'],
- // Disabled because this is the jump key! Oops.
- // ['isVolumeDown', '/'],
- // ['isVolumeUp', '*'],
- ['isFocusTabber', '7'],
- ['isFocusQueue', '9'],
- ['isFocusMenubar', '*'],
- // HJKL
- ['isDown', 'j'],
- ['isUp', 'k'],
- // Don't use these for now... currently L is used for toggling loop.
- // May want to look into changing that (so we can re-enable these).
- // ['isLeft', 'h'],
- // ['isRight', 'l'],
- ]
- const addKey = (prop, keyOrFunc, {caseless = true} = {}) => {
- const oldFunc = input[prop] || (() => false)
- let newFunc
- if (typeof keyOrFunc === 'function') {
- newFunc = keyOrFunc
- } else if (typeof keyOrFunc === 'string') {
- const key = keyOrFunc
- if (caseless) {
- newFunc = input => input.toString().toLowerCase() === key.toLowerCase()
- } else {
- newFunc = input => input.toString() === key
- }
- } else if (Array.isArray(keyOrFunc)) {
- const buf = Buffer.from(keyOrFunc.map(k => typeof k === 'string' ? k.charCodeAt(0) : k))
- newFunc = keyBuf => keyBuf.equals(buf)
- }
- input[prop] = keyBuf => newFunc(keyBuf) || oldFunc(keyBuf)
- }
- for (const entry of keyBindings) {
- addKey(...entry)
- }
- // Some things just need to be overridden in order for the rest of tui-lib to
- // recognize our new keys.
- telc.isUp = input.isUp
- telc.isDown = input.isDown
- telc.isLeft = input.isLeft
- telc.isRight = input.isRight
- telc.isSelect = input.isSelect
- telc.isBackspace = input.isBackspace
- export default class AppElement extends FocusElement {
- constructor(backend, config = {}) {
- super()
- this.backend = backend
- this.telnetServer = null
- this.isPartyHost = false
- this.enableAutoDJ = false
- this.config = Object.assign({
- canControlPlayback: true,
- canControlQueue: true,
- canControlQueuePlayers: true,
- canProcessMetadata: true,
- canSuspend: true,
- themeColor: 4, // blue
- seekToStartThreshold: 3,
- showTabberPane: true,
- stopPlayingUponQuit: true
- }, config)
- // TODO: Move edit mode stuff to the backend!
- this.undoManager = new UndoManager()
- this.markGrouplike = {name: 'Selected Items', items: []}
- this.cachedMarkStatuses = new Map()
- this.editMode = false
- this.timestampDictionary = new WeakMap()
- // We add this is a child later (so that it's on top of every element).
- this.menuLayer = new DisplayElement()
- this.menuLayer.clickThrough = true
- this.showContextMenu = this.showContextMenu.bind(this)
- this.menubar = new Menubar(this.showContextMenu)
- this.addChild(this.menubar)
- this.setThemeColor(this.config.themeColor)
- this.menubar.on('color', color => this.setThemeColor(color))
- this.tabberPane = new Pane()
- this.addChild(this.tabberPane)
- this.queuePane = new Pane()
- this.addChild(this.queuePane)
- /*
- this.textInfoPane = new Pane()
- this.addChild(this.textInfoPane)
- this.textEditor = new NotesTextEditor()
- this.textInfoPane.addChild(this.textEditor)
- this.textInfoPane.visible = false
- this.textEditor.on('deselect', () => {
- this.root.select(this.tabber)
- this.fixLayout()
- })
- */
- if (!this.config.showTabberPane) {
- this.tabberPane.visible = false
- }
- this.tabber = new Tabber()
- this.tabberPane.addChild(this.tabber)
- this.metadataStatusLabel = new Label()
- this.metadataStatusLabel.visible = false
- this.tabberPane.addChild(this.metadataStatusLabel)
- this.newGrouplikeListing()
- this.queueListingElement = new QueueListingElement(this)
- this.setupCommonGrouplikeListingEvents(this.queueListingElement)
- this.queuePane.addChild(this.queueListingElement)
- this.queueLengthLabel = new Label('')
- this.queuePane.addChild(this.queueLengthLabel)
- this.queueTimeLabel = new Label('')
- this.queuePane.addChild(this.queueTimeLabel)
- this.queueListingElement.on('select', _item => this.updateQueueLengthLabel())
- this.queueListingElement.on('open', item => this.openSpecialOrThroughSystem(item))
- this.queueListingElement.on('queue', item => this.play(item))
- this.queueListingElement.on('remove', item => this.unqueue(item))
- this.queueListingElement.on('shuffle', () => this.shuffleQueue())
- this.queueListingElement.on('clear', () => this.clearQueue())
- this.queueListingElement.on('select main listing',
- () => this.selected())
- this.playbackPane = new Pane()
- this.addChild(this.playbackPane)
- this.playbackForm = new ListScrollForm()
- this.playbackPane.addChild(this.playbackForm)
- this.playbackInfoElements = []
- this.partyTop = new DisplayElement()
- this.partyBottom = new DisplayElement()
- this.addChild(this.partyTop)
- this.addChild(this.partyBottom)
- this.partyTop.visible = false
- this.partyBottom.visible = false
- this.partyTopBanner = new PartyBanner(1)
- this.partyBottomBanner = new PartyBanner(-1)
- this.partyTop.addChild(this.partyTopBanner)
- this.partyBottom.addChild(this.partyBottomBanner)
- this.partyLabel = new Label('')
- this.partyTop.addChild(this.partyLabel)
- // Dialogs
- this.openPlaylistDialog = new OpenPlaylistDialog()
- this.setupDialog(this.openPlaylistDialog)
- this.openPlaylistDialog.on('source selected', source => this.loadPlaylistOrSource(source))
- this.openPlaylistDialog.on('source selected (new tab)', source => this.loadPlaylistOrSource(source, true))
- this.alertDialog = new AlertDialog()
- this.setupDialog(this.alertDialog)
- // Should be placed on top of everything else!
- this.addChild(this.menuLayer)
- this.whereControl = new InlineListPickerElement('Where?', [
- {value: 'after-selected', label: 'After selected track'},
- {value: 'next', label: 'After current track'},
- {value: 'end', label: 'At end of queue'},
- {value: 'distribute-evenly', label: 'Distributed across queue evenly'},
- {value: 'distribute-randomly', label: 'Distributed across queue randomly'},
- {value: 'before-selected', label: 'Before selected track'}
- ], this.showContextMenu)
- this.orderControl = new InlineListPickerElement('Order?', [
- {value: 'shuffle', label: 'Shuffle all'},
- {value: 'shuffle-groups', label: 'Shuffle order of groups'},
- {value: 'reverse', label: 'Reverse all'},
- {value: 'reverse-groups', label: 'Reverse order of groups'},
- {value: 'alphabetic', label: 'Alphabetically'},
- {value: 'alphabetic-groups', label: 'Alphabetize order of groups'},
- {value: 'normal', label: 'In order'}
- ], this.showContextMenu)
- this.menubar.buildItems([
- {text: 'mtui', menuItems: [
- {label: 'mtui (perpetual development)'},
- {divider: true},
- {label: 'Quit', action: () => this.shutdown()},
- this.config.canSuspend && {label: 'Suspend', action: () => this.suspend()}
- ]},
- {text: 'Playback', menuFn: () => {
- const { playingTrack } = this.SQP
- const { items } = this.SQP.queueGrouplike
- const curIndex = items.indexOf(playingTrack)
- const next = (curIndex >= 0) && items[curIndex + 1]
- const previous = (curIndex >= 0) && items[curIndex - 1]
- return [
- {label: playingTrack ? `("${playingTrack.name}")` : '(No track playing.)'},
- {divider: true},
- {element: this.volumeSlider},
- {divider: true},
- playingTrack && {element: this.playingControl},
- playingTrack && {element: this.loopingControl},
- playingTrack && {element: this.pauseNextControl},
- {element: this.autoDJControl},
- {divider: true},
- previous && {label: `Previous (${previous.name})`, action: () => this.SQP.playPrevious(playingTrack)},
- next && {label: `Next (${next.name})`, action: () => this.SQP.playNext(playingTrack)},
- !next && this.SQP.queueEndMode === 'loop' &&
- {label: `Next (loop queue)`, action: () => this.SQP.playNext(playingTrack)},
- next && {label: '- Play later', action: () => this.playLater(next)}
- ]
- }},
- {text: 'Queue', menuFn: () => {
- const { items } = this.SQP.queueGrouplike
- const curIndex = items.indexOf(this.playingTrack)
- return [
- {label: `(Queue - ${curIndex >= 0 ? `${curIndex + 1}/` : ''}${items.length} items.)`},
- {divider: true},
- {element: this.loopModeControl},
- {divider: true},
- items.length && {label: 'Shuffle', action: () => this.shuffleQueue()},
- items.length && {label: 'Clear', action: () => this.clearQueue()}
- ]
- }},
- {text: 'Multi', menuFn: () => {
- const { queuePlayers } = this.backend
- return [
- {key: 'heading', label: `(Multi-players - ${queuePlayers.length})`},
- {divider: true},
- ...queuePlayers.map((queuePlayer, index) => {
- const PIE = new PlaybackInfoElement(queuePlayer, this)
- PIE.displayMode = 'collapsed'
- PIE.updateTrack()
- return {key: 'qp' + index, element: PIE}
- }),
- {divider: true},
- {key: 'add-new-player', label: `Add new player`, action: () => this.addQueuePlayer().then(() => 'reload')}
- ]
- }}
- ])
- this.playingControl = new ToggleControl('Pause?', {
- setValue: val => this.SQP.setPause(val),
- getValue: () => this.SQP.player.isPaused,
- getEnabled: () => this.config.canControlPlayback
- })
- this.loopingControl = new ToggleControl('Loop current track?', {
- setValue: val => this.SQP.setLoop(val),
- getValue: () => this.SQP.player.isLooping,
- getEnabled: () => this.config.canControlPlayback
- })
- this.loopModeControl = new InlineListPickerElement('Loop queue?', [
- {value: 'end', label: 'Don\'t loop'},
- {value: 'loop', label: 'Loop (same order)'},
- {value: 'shuffle', label: 'Loop (shuffle)'}
- ], {
- setValue: val => {
- if (this.SQP) {
- this.SQP.queueEndMode = val
- }
- },
- getValue: () => this.SQP && this.SQP.queueEndMode,
- showContextMenu: this.showContextMenu
- })
- this.pauseNextControl = new ToggleControl('Pause when this track ends?', {
- setValue: val => this.SQP.setPauseNextTrack(val),
- getValue: () => this.SQP.pauseNextTrack,
- getEnabled: () => this.config.canControlPlayback
- })
- this.loopQueueControl = new ToggleControl('Loop queue?', {
- setValue: val => this.SQP.setLoopQueueAtEnd(val),
- getValue: () => this.SQP.loopQueueAtEnd,
- getEnabled: () => this.config.canControlPlayback
- })
- this.volumeSlider = new SliderElement('Volume', {
- setValue: val => this.SQP.setVolume(val),
- getValue: () => this.SQP.player.volume,
- getEnabled: () => this.config.canControlPlayback
- })
- this.autoDJControl = new ToggleControl('Enable Auto-DJ?', {
- setValue: val => (this.enableAutoDJ = val),
- getValue: () => this.enableAutoDJ,
- getEnabled: () => this.config.canControlPlayback
- })
- this.bindListeners()
- this.initialAttachListeners()
- // Also handy to be bound to the app.
- this.showContextMenu = this.showContextMenu.bind(this)
- this.queuePlayersToActOn = []
- this.selectQueuePlayer(this.backend.queuePlayers[0])
- }
- bindListeners() {
- for (const key of [
- 'handlePlaying',
- 'handleReceivedTimeData',
- 'handleProcessMetadataProgress',
- 'handleQueueUpdated',
- 'handleAddedQueuePlayer',
- 'handleRemovedQueuePlayer',
- 'handleSetLoopQueueAtEnd'
- ]) {
- this[key] = this[key].bind(this)
- }
- }
- initialAttachListeners() {
- this.attachBackendListeners()
- for (const queuePlayer of this.backend.queuePlayers) {
- this.attachQueuePlayerListenersAndUI(queuePlayer)
- }
- }
- removeListeners() {
- this.removeBackendListeners()
- for (const queuePlayer of this.backend.queuePlayers) {
- // Don't update the UI - removeListeners is only called just before the
- // AppElement is done being used.
- this.removeQueuePlayerListenersAndUI(queuePlayer, false)
- }
- }
- attachQueuePlayerListenersAndUI(queuePlayer) {
- const PIE = new PlaybackInfoElement(queuePlayer, this)
- this.playbackInfoElements.push(PIE)
- this.playbackForm.addInput(PIE)
- this.fixLayout()
- PIE.on('seek back', () => PIE.queuePlayer.seekBack(5))
- PIE.on('seek ahead', () => PIE.queuePlayer.seekAhead(5))
- PIE.on('toggle pause', () => PIE.queuePlayer.togglePause())
- queuePlayer.on('received time data', this.handleReceivedTimeData)
- queuePlayer.on('playing', this.handlePlaying)
- queuePlayer.on('queue updated', this.handleQueueUpdated)
- }
- removeQueuePlayerListenersAndUI(queuePlayer, updateUI = true) {
- if (updateUI) {
- const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
- if (PIE) {
- const PIEs = this.playbackInfoElements
- const oldIndex = PIEs.indexOf(PIE)
- if (this.playbackForm.curIndex > oldIndex) {
- this.playbackForm.curIndex--
- }
- PIEs.splice(oldIndex, 1)
- this.playbackForm.removeInput(PIE)
- if (this.SQP === queuePlayer) {
- const { queuePlayer } = PIEs[Math.min(oldIndex, PIEs.length - 1)]
- this.selectQueuePlayer(queuePlayer)
- }
- this.fixLayout()
- }
- }
- const index = this.queuePlayersToActOn.indexOf(queuePlayer)
- if (index >= 0) {
- this.queuePlayersToActOn.splice(index, 1)
- }
- queuePlayer.removeListener('receivedTimeData', this.handleReceivedTimeData)
- queuePlayer.removeListener('playing', this.handlePlaying)
- queuePlayer.removeListener('queue updated', this.handleQueueUpdated)
- queuePlayer.stopPlaying()
- }
- attachBackendListeners() {
- this.backend.on('processMetadata progress', this.handleProcessMetadataProgress)
- this.backend.on('added queue player', this.handleAddedQueuePlayer)
- this.backend.on('removed queue player', this.handleRemovedQueuePlayer)
- this.backend.on('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd)
- }
- removeBackendListeners() {
- this.backend.removeListener('processMetadata progress', this.handleProcessMetadataProgress)
- this.backend.removeListener('added queue player', this.handleAddedQueuePlayer)
- this.backend.removeListener('removed queue player', this.handleRemovedQueuePlayer)
- this.backend.removeListener('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd)
- }
- handleAddedQueuePlayer(queuePlayer) {
- this.attachQueuePlayerListenersAndUI(queuePlayer)
- }
- handleRemovedQueuePlayer(queuePlayer) {
- this.removeQueuePlayerListenersAndUI(queuePlayer)
- if (this.menubar.contextMenu) {
- setTimeout(() => this.menubar.contextMenu.reload(), 0)
- }
- }
- handleSetLoopQueueAtEnd() {
- this.updateQueueLengthLabel()
- }
- async handlePlaying(track, oldTrack, startTime, queuePlayer) {
- const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
- if (PIE) {
- PIE.updateTrack()
- }
- if (queuePlayer === this.SQP) {
- this.updateQueueLengthLabel()
- this.queueListingElement.collapseTimestamps(oldTrack)
- if (track && this.queueListingElement.currentItem === oldTrack) {
- this.queueListingElement.selectAndShow(track)
- }
- }
- // Unfortunately, there isn't really any reliable way to make these work if
- // the containing queue isn't of the selected queue player.
- const timestampData = track && this.getTimestampData(track)
- if (timestampData && queuePlayer === this.SQP) {
- if (this.queueListingElement.currentItem === track) {
- this.queueListingElement.selectTimestampAtSec(track, startTime)
- }
- }
- if (track && this.enableAutoDJ) {
- queuePlayer.setVolumeMultiplier(0.5);
- const message = 'now playing: ' + getNameWithoutTrackNumber(track);
- if (await commandExists('espeak')) {
- await promisifyProcess(spawn('espeak', [message]));
- } else if (await commandExists('say')) {
- await promisifyProcess(spawn('say', [message]));
- }
- queuePlayer.fadeIn();
- }
- }
- handleReceivedTimeData(timeData, oldTimeData, queuePlayer) {
- const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
- if (PIE) {
- PIE.updateProgress()
- }
- if (queuePlayer === this.SQP) {
- this.updateQueueLengthLabel()
- this.updateQueueSelection(timeData, oldTimeData)
- }
- }
- handleProcessMetadataProgress(remaining) {
- this.metadataStatusLabel.text = `Processing metadata - ${remaining} to go.`
- this.updateQueueLengthLabel()
- }
- handleQueueUpdated() {
- this.queueListingElement.buildItems()
- }
- selectQueuePlayer(queuePlayer) {
- // You can use this.SQP as a shorthand to get this.
- this.selectedQueuePlayer = queuePlayer
- this.queueListingElement.loadGrouplike(queuePlayer.queueGrouplike)
- this.playbackForm.curIndex = this.playbackForm.inputs
- .findIndex(el => el.queuePlayer === queuePlayer)
- this.playbackForm.scrollSelectedElementIntoView()
- }
- selectNextQueuePlayer() {
- const { queuePlayers } = this.backend
- let index = queuePlayers.indexOf(this.SQP) + 1
- if (index >= queuePlayers.length) {
- index = 0
- }
- this.selectQueuePlayer(queuePlayers[index])
- }
- selectPreviousQueuePlayer() {
- const { queuePlayers } = this.backend
- let index = queuePlayers.indexOf(this.SQP) - 1
- if (index <= -1) {
- index = queuePlayers.length - 1
- }
- this.selectQueuePlayer(queuePlayers[index])
- }
- async addQueuePlayer() {
- if (!this.config.canControlQueuePlayers) {
- return false
- }
- const queuePlayer = await this.backend.addQueuePlayer()
- this.selectQueuePlayer(queuePlayer)
- }
- removeQueuePlayer(queuePlayer) {
- if (!this.config.canControlQueuePlayers) {
- return false
- }
- this.backend.removeQueuePlayer(queuePlayer)
- }
- toggleActOnQueuePlayer(queuePlayer) {
- const index = this.queuePlayersToActOn.indexOf(queuePlayer)
- if (index >= 0) {
- this.queuePlayersToActOn.splice(index, 1)
- } else {
- this.queuePlayersToActOn.push(queuePlayer)
- }
- for (const PIE of this.playbackInfoElements) {
- PIE.fixLayout()
- }
- }
- getPlaybackInfoElementForQueuePlayer(queuePlayer) {
- return this.playbackInfoElements
- .find(el => el.queuePlayer === queuePlayer)
- }
- selected() {
- if (this.tabberPane.visible) {
- this.root.select(this.tabber)
- } else {
- if (this.queueListingElement.selectable) {
- this.root.select(this.queueListingElement)
- } else {
- this.menubar.select()
- }
- }
- }
- newGrouplikeListing() {
- const grouplikeListing = new GrouplikeListingElement(this)
- this.tabber.addTab(grouplikeListing)
- this.tabber.selectTab(grouplikeListing)
- grouplikeListing.on('browse', item => this.browse(grouplikeListing, item))
- grouplikeListing.on('download', item => this.SQP.download(item))
- grouplikeListing.on('open', item => this.openSpecialOrThroughSystem(item))
- grouplikeListing.on('queue', (item, opts) => this.handleQueueOptions(item, opts))
- const updateListingsFor = item => {
- for (const grouplikeListing of this.tabber.tabberElements) {
- if (grouplikeListing.grouplike === item) {
- this.browse(grouplikeListing, item, false)
- }
- }
- }
- grouplikeListing.on('remove', item => {
- if (this.editMode) {
- const parent = item[parentSymbol]
- const index = parent.items.indexOf(item)
- this.undoManager.pushAction({
- activate: () => {
- parent.items.splice(index, 1)
- delete item[parentSymbol]
- updateListingsFor(item)
- updateListingsFor(parent)
- },
- undo: () => {
- parent.items.splice(index, 0, item)
- item[parentSymbol] = parent
- updateListingsFor(item)
- updateListingsFor(parent)
- }
- })
- }
- })
- grouplikeListing.on('mark', item => {
- if (this.editMode) {
- if (!this.markGrouplike.items.includes(item)) {
- this.undoManager.pushAction({
- activate: () => {
- this.markGrouplike.items.push(item)
- },
- undo: () => {
- this.markGrouplike.items.pop()
- }
- })
- } else {
- const index = this.markGrouplike.items.indexOf(item)
- this.undoManager.pushAction({
- activate: () => {
- this.markGrouplike.items.splice(index, 1)
- },
- undo: () => {
- this.markGrouplike.items.splice(index, 0, item)
- }
- })
- }
- }
- })
- grouplikeListing.on('paste', (item, {where = 'below'} = {}) => {
- if (this.editMode && this.markGrouplike.items.length) {
- let parent, index
- if (where === 'above') {
- parent = item[parentSymbol]
- index = parent.items.indexOf(item)
- } else if (where === 'below') {
- parent = item[parentSymbol]
- index = parent.items.indexOf(item) + 1
- }
- this.undoManager.pushAction({
- activate: () => {
- parent.items.splice(index, 0, ...cloneGrouplike(this.markGrouplike).items.map(
- item => Object.assign({}, item, {[parentSymbol]: parent})
- ))
- updateListingsFor(parent)
- },
- undo: () => {
- parent.items.splice(index, this.markGrouplike.items.length)
- updateListingsFor(parent)
- }
- })
- }
- })
- this.setupCommonGrouplikeListingEvents(grouplikeListing)
- return grouplikeListing
- }
- setupCommonGrouplikeListingEvents(grouplikeListing) {
- // Sets up event listeners that are common to ordinary grouplike listings
- // (made by newGrouplikeListing) as well as the queue grouplike listing.
- grouplikeListing.on('timestamp', (item, time) => this.playOrSeek(item, time))
- grouplikeListing.pathElement.on('select', (item, child) => this.revealInLibrary(item, child))
- grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing))
- /*
- grouplikeListing.on('select', item => this.editNotesFile(item, false))
- grouplikeListing.on('edit-notes', item => {
- this.revealInLibrary(item)
- this.editNotesFile(item, true)
- })
- */
- }
- showContextMenu(opts) {
- const menu = new ContextMenu(this.showContextMenu)
- this.menuLayer.addChild(menu)
- if (opts.beforeShowing) {
- opts.beforeShowing(menu)
- }
- menu.show(opts)
- return menu
- }
- browse(grouplikeListing, grouplike, ...args) {
- this.loadTimestampDataInGrouplike(grouplike)
- grouplikeListing.loadGrouplike(grouplike, ...args)
- }
- revealInLibrary(item, child) {
- if (!this.tabberPane.visible) {
- return
- }
- const tabberListing = this.tabber.currentElement
- this.root.select(tabberListing)
- const parent = item[parentSymbol]
- if (isGroup(item)) {
- tabberListing.loadGrouplike(item)
- if (child) {
- tabberListing.selectAndShow(child)
- }
- } else if (parent) {
- if (tabberListing.grouplike !== parent) {
- tabberListing.loadGrouplike(parent)
- }
- tabberListing.selectAndShow(item)
- }
- }
- revealInQueue(item) {
- const queueListing = this.queueListingElement
- if (queueListing.selectAndShow(item)) {
- this.root.select(queueListing)
- }
- }
- play(item) {
- if (!this.config.canControlQueue) {
- return
- }
- this.SQP.play(item)
- }
- playOrSeek(item, time) {
- if (!this.config.canControlQueue || !this.config.canControlPlayback) {
- return
- }
- this.SQP.playOrSeek(item, time)
- }
- unqueue(item) {
- if (!this.config.canControlQueue) {
- return
- }
- let focusItem = this.queueListingElement.currentItem
- focusItem = this.SQP.unqueue(item, focusItem)
- this.queueListingElement.buildItems()
- this.updateQueueLengthLabel()
- if (focusItem) {
- this.queueListingElement.selectAndShow(focusItem)
- }
- }
- playSooner(item) {
- if (!this.config.canControlQueue) {
- return
- }
- this.SQP.playSooner(item)
- // It may not have queued as soon as the user wants; in that case, they'll
- // want to queue it sooner again. Automatically reselect the track so that
- // this they don't have to navigate back to it by hand.
- this.queueListingElement.selectAndShow(item)
- }
- playLater(item) {
- if (!this.config.canControlQueue) {
- return
- }
- this.SQP.playLater(item)
- // Just for consistency with playSooner (you can press ^-L to quickly get
- // back to the current track).
- this.queueListingElement.selectAndShow(item)
- }
- clearQueuePast(item) {
- if (!this.config.canControlQueue) {
- return
- }
- this.SQP.clearQueuePast(item)
- this.queueListingElement.selectAndShow(item)
- }
- clearQueueUpTo(item) {
- if (!this.config.canControlQueue) {
- return
- }
- this.SQP.clearQueueUpTo(item)
- this.queueListingElement.selectAndShow(item)
- }
- replaceMark(items) {
- this.markGrouplike.items = items.slice(0) // Don't share the array! :)
- this.emitMarkChanged()
- }
- unmarkAll() {
- this.markGrouplike.items = []
- this.emitMarkChanged()
- }
- markItem(item) {
- if (isGroup(item)) {
- for (const child of item.items) {
- this.markItem(child)
- }
- } else {
- const { items } = this.markGrouplike
- if (!items.includes(item)) {
- items.push(item)
- this.emitMarkChanged()
- }
- }
- }
- unmarkItem(item) {
- if (isGroup(item)) {
- for (const child of item.items) {
- this.unmarkItem(child)
- }
- } else {
- const { items } = this.markGrouplike
- if (items.includes(item)) {
- items.splice(items.indexOf(item), 1)
- this.emitMarkChanged()
- }
- }
- }
- getMarkStatus(item) {
- if (!this.cachedMarkStatuses.get(item)) {
- const { items } = this.markGrouplike
- let status
- if (isGroup(item)) {
- const tracks = flattenGrouplike(item).items
- if (tracks.every(track => items.includes(track))) {
- status = 'marked'
- } else if (tracks.some(track => items.includes(track))) {
- status = 'partial'
- } else {
- status = 'unmarked'
- }
- } else {
- if (items.includes(item)) {
- status = 'marked'
- } else {
- status = 'unmarked'
- }
- }
- this.cachedMarkStatuses.set(item, status)
- }
- return this.cachedMarkStatuses.get(item)
- }
- emitMarkChanged() {
- this.emit('mark changed')
- this.cachedMarkStatuses = new Map()
- this.scheduleDrawWithoutPropertyChange()
- }
- pauseAll() {
- if (!this.config.canControlPlayback) {
- return
- }
- for (const queuePlayer of this.backend.queuePlayers) {
- queuePlayer.setPause(true)
- }
- }
- resumeAll() {
- if (!this.config.canControlPlayback) {
- return
- }
- for (const queuePlayer of this.backend.queuePlayers) {
- queuePlayer.setPause(false)
- }
- }
- async createNotesFile(item) {
- if (!item[parentSymbol]) {
- return
- }
- if (!item.url) {
- return
- }
- if (getCorrespondingFileForItem(item, '.txt')) {
- return
- }
- let itemPath
- try {
- itemPath = url.fileURLToPath(item.url)
- } catch (error) {
- return
- }
- const dirname = path.dirname(itemPath)
- const extname = path.extname(itemPath)
- const basename = path.basename(itemPath, extname)
- const name = basename + '.txt'
- const filePath = path.join(dirname, name)
- const fileURL = url.pathToFileURL(filePath).toString()
- const file = {name, url: fileURL}
- await writeFile(filePath, '\n')
- const { items } = item[parentSymbol]
- items.splice(items.indexOf(item), 0, file)
- }
- /*
- async editNotesFile(item, focus) {
- if (!item) {
- return
- }
- // Creates it, if it doesn't exist.
- // We only do this when we're manually selecting the file (and expect to
- // focus it). Otherwise we'd create a notes file for every track hovered
- // over!
- if (focus) {
- await this.createNotesFile(item)
- }
- const doubleCheckItem = () => {
- const listing = this.root.selectedElement.directAncestors.find(el => el instanceof GrouplikeListingElement)
- return listing && listing.currentItem === item
- }
- if (!doubleCheckItem()) {
- return
- }
- const status = await this.textEditor.openItem(item, {doubleCheckItem})
- let fixLayout
- if (status === true) {
- this.textInfoPane.visible = true
- fixLayout = true
- } else if (status === false) {
- this.textInfoPane.visible = false
- fixLayout = true
- }
- if (focus && (status === true || status === null) && doubleCheckItem()) {
- this.root.select(this.textEditor)
- fixLayout = true
- }
- if (fixLayout) {
- this.fixLayout()
- }
- }
- */
- expandTimestamps(item, listing) {
- listing.expandTimestamps(item)
- }
- collapseTimestamps(item, listing) {
- listing.collapseTimestamps(item)
- }
- toggleTimestamps(item, listing) {
- listing.toggleTimestamps(item)
- }
- timestampsExpanded(item, listing) {
- return listing.timestampsExpanded(item)
- }
- hasTimestampsFile(item) {
- return !!this.getTimestampsFile(item)
- }
- getTimestampsFile(item) {
- // Only tracks have timestamp files!
- if (!isTrack(item)) {
- return false
- }
- return getCorrespondingFileForItem(item, '.timestamps.txt')
- }
- async loadTimestampDataInGrouplike(grouplike) {
- // Only load data for a grouplike once.
- if (this.timestampDictionary.has(grouplike)) {
- return
- }
- this.timestampDictionary.set(grouplike, true)
- // There's no parallelization here, but like, whateeeever.
- for (const item of grouplike.items) {
- if (!isTrack(item)) {
- continue
- }
- if (this.timestampDictionary.has(item)) {
- continue
- }
- if (!this.hasTimestampsFile(item)) {
- this.timestampDictionary.set(item, false)
- continue
- }
- this.timestampDictionary.set(item, null)
- const data = await this.readTimestampData(item)
- this.timestampDictionary.set(item, data)
- }
- }
- getTimestampData(item) {
- return this.timestampDictionary.get(item) || null
- }
- getTimestampAtSec(item, sec) {
- const timestampData = this.getTimestampData(item)
- if (!timestampData) {
- return null
- }
- // Just like, start from the end, man.
- // Why doesn't JavaScript have a findIndexFromEnd function???
- for (let i = timestampData.length - 1; i >= 0; i--) {
- const ts = timestampData[i];
- if (
- ts.timestamp <= sec &&
- ts.timestampEnd >= sec
- ) {
- return ts
- }
- }
- return null
- }
- async readTimestampData(item) {
- const file = this.getTimestampsFile(item)
- if (!file) {
- return null
- }
- let filePath
- try {
- filePath = url.fileURLToPath(new URL(file.url))
- } catch (error) {
- return null
- }
- let contents
- try {
- contents = (await readFile(filePath)).toString()
- } catch (error) {
- return null
- }
- if (contents.startsWith('[')) {
- try {
- return JSON.parse(contents)
- } catch (error) {
- return null
- }
- }
- const lines = contents.split('\n')
- .filter(line => !line.startsWith('#'))
- .filter(line => line)
- const metadata = this.backend.getMetadataFor(item)
- const duration = (metadata ? metadata.duration : Infinity)
- const data = lines
- .map(line => line.match(/^\s*([0-9:.]+)\s*(\S.*)\s*$/))
- .filter(match => match)
- .map(match => ({timestamp: getSecFromTimestamp(match[1]), comment: match[2]}))
- .filter(({ timestamp: sec }) => !isNaN(sec))
- .map((cur, i, arr) =>
- (i + 1 === arr.length
- ? {...cur, timestampEnd: duration}
- : {...cur, timestampEnd: arr[i + 1].timestamp}))
- return data
- }
- openSpecialOrThroughSystem(item) {
- if (item.url.endsWith('.json')) {
- return this.loadPlaylistOrSource(item.url, true)
- /*
- } else if (item.url.endsWith('.txt')) {
- if (this.textInfoPane.visible) {
- this.root.select(this.textEditor)
- }
- */
- } else {
- return this.openThroughSystem(item)
- }
- }
- openThroughSystem(item) {
- if (!isOpenable(item)) {
- return
- }
- open(item.url)
- }
- set actOnAllPlayers(val) {
- if (val) {
- this.queuePlayersToActOn = this.backend.queuePlayers.slice()
- } else {
- this.queuePlayersToActOn = []
- }
- }
- get actOnAllPlayers() {
- return this.queuePlayersToActOn.length === this.backend.queuePlayers.length
- }
- willActOnQueuePlayer(queuePlayer) {
- if (this.queuePlayersToActOn.length) {
- if (this.queuePlayersToActOn.includes(queuePlayer)) {
- return 'marked'
- }
- } else if (queuePlayer === this.SQP) {
- return '=SQP'
- }
- }
- skipBackOrSeekToStart() {
- // Perform the same action - skipping to the previous track or seeking to
- // the start of the current track - for all target queue players. If any is
- // past an arbitrary time position (default 3 seconds), seek to start; if
- // all are before this position, skip to previous.
- let maxCurSec = 0
- this.forEachQueuePlayerToActOn(qp => {
- if (qp.timeData) {
- let effectiveCurSec = qp.timeData.curSecTotal
- const ts = (qp.timeData &&
- this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
- if (ts) {
- effectiveCurSec -= ts.timestamp
- }
- maxCurSec = Math.max(maxCurSec, effectiveCurSec)
- }
- })
- if (Math.floor(maxCurSec) < this.config.seekToStartThreshold) {
- this.skipBack()
- } else {
- this.seekToStart()
- }
- }
- seekToStart() {
- this.actOnQueuePlayers(qp => qp.seekToStart())
- this.actOnQueuePlayers(qp => {
- if (!qp.playingTrack) {
- return
- }
- const ts = (qp.timeData &&
- this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
- if (ts) {
- qp.seekTo(ts.timestamp)
- return
- }
- qp.seekToStart()
- })
- }
- skipBack() {
- this.actOnQueuePlayers(qp => {
- if (!qp.playingTrack) {
- return
- }
- const ts = (qp.timeData &&
- this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
- if (ts) {
- const timestampData = this.getTimestampData(qp.playingTrack)
- const playingTimestampIndex = timestampData.indexOf(ts)
- const previous = timestampData[playingTimestampIndex - 1]
- if (previous) {
- qp.seekTo(previous.timestamp)
- return
- }
- }
- qp.playPrevious(qp.playingTrack, true)
- })
- }
- skipAhead() {
- this.actOnQueuePlayers(qp => {
- if (!qp.playingTrack) {
- return
- }
- const ts = (qp.timeData &&
- this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
- if (ts) {
- const timestampData = this.getTimestampData(qp.playingTrack)
- const playingTimestampIndex = timestampData.indexOf(ts)
- const next = timestampData[playingTimestampIndex + 1]
- if (next) {
- qp.seekTo(next.timestamp)
- return
- }
- }
- qp.playNext(qp.playingTrack, true)
- })
- }
- actOnQueuePlayers(fn) {
- this.forEachQueuePlayerToActOn(queuePlayer => {
- fn(queuePlayer)
- const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
- if (PIE) {
- PIE.updateProgress()
- }
- })
- }
- forEachQueuePlayerToActOn(fn) {
- const actOn = this.queuePlayersToActOn.length ? this.queuePlayersToActOn : [this.SQP]
- actOn.forEach(fn)
- }
- showMenuForItemElement(el, listing) {
- // const { editMode } = this
- const { canControlQueue, canProcessMetadata } = this.config
- // const anyMarked = editMode && this.markGrouplike.items.length > 0
- const generatePageForItem = item => {
- const emitControls = play => () => {
- this.handleQueueOptions(item, {
- where: this.whereControl.curValue,
- order: this.orderControl.curValue,
- play: play
- })
- }
- // const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt')
- const timestampsItem = this.hasTimestampsFile(item) && (this.timestampsExpanded(item, listing)
- ? {label: 'Collapse saved timestamps', action: () => this.collapseTimestamps(item, listing)}
- : {label: 'Expand saved timestamps', action: () => this.expandTimestamps(item, listing)}
- )
- const isQueued = this.SQP.queueGrouplike.items.includes(item)
- if (listing.grouplike.isTheQueue && isTrack(item)) {
- return [
- item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal in library', action: () => this.revealInLibrary(item)},
- timestampsItem,
- {divider: true},
- canControlQueue && {label: 'Play later', action: () => this.playLater(item)},
- canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)},
- {divider: true},
- canControlQueue && {label: 'Clear past this track', action: () => this.clearQueuePast(item)},
- canControlQueue && {label: 'Clear up to this track', action: () => this.clearQueueUpTo(item)},
- {divider: true},
- {label: 'Autoscroll', action: () => listing.toggleAutoscroll()},
- {divider: true},
- canControlQueue && {label: 'Remove from queue', action: () => this.unqueue(item)}
- ]
- } else {
- const numTracks = countTotalTracks(item)
- const { string: durationString } = this.backend.getDuration(item)
- return [
- // A label that just shows some brief information about the item.
- {label:
- `(${item.name ? `"${item.name}"` : 'Unnamed'} - ` +
- (isGroup(item) ? ` ${numTracks} track${numTracks === 1 ? '' : 's'}, ` : '') +
- durationString +
- ')',
- keyboardIdentifier: item.name,
- isPageSwitcher: true
- },
- // The actual controls!
- {divider: true},
- // TODO: Don't emit these on the element (and hence receive them from
- // the listing) - instead, handle their behavior directly. We'll want
- // to move the "mark"/"paste" (etc) code into separate functions,
- // instead of just defining their behavior inside the listing event
- // handlers.
- // editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')},
- // anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})},
- // anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})},
- // 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)
- // {divider: true},
- canControlQueue && isPlayable(item) && {element: this.whereControl},
- canControlQueue && isGroup(item) && {element: this.orderControl},
- canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)},
- canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)},
- {divider: true},
- canProcessMetadata && isGroup(item) && {label: 'Process metadata (new entries)', action: () => setTimeout(() => this.processMetadata(item, false))},
- canProcessMetadata && isGroup(item) && {label: 'Process metadata (reprocess)', action: () => setTimeout(() => this.processMetadata(item, true))},
- canProcessMetadata && isTrack(item) && {label: 'Process metadata', action: () => setTimeout(() => this.processMetadata(item, true))},
- isOpenable(item) && item.url.endsWith('.json') && {label: 'Open (JSON Playlist)', action: () => this.openSpecialOrThroughSystem(item)},
- isOpenable(item) && {label: 'Open (System)', action: () => this.openThroughSystem(item)},
- // !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)},
- // hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)},
- canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)},
- isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)},
- {divider: true},
- timestampsItem,
- ...(item === this.markGrouplike
- ? [{label: 'Deselect all', action: () => this.unmarkAll()}]
- : [
- this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)},
- this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)}
- ])
- ]
- }
- }
- const pages = [
- this.markGrouplike.items.length && generatePageForItem(this.markGrouplike),
- el.item && generatePageForItem(el.item)
- ].filter(Boolean)
- // TODO: Implement this! :P
- // const isMarked = false
- this.showContextMenu({
- x: el.absLeft,
- y: el.absTop + 1,
- pages
- })
- }
- async loadPlaylistOrSource(sourceOrPlaylist, newTab = false) {
- if (this.openPlaylistDialog.visible) {
- this.openPlaylistDialog.close()
- }
- this.alertDialog.showMessage('Opening playlist...', false)
- let grouplike
- if (typeof sourceOrPlaylist === 'object' && isGroup(sourceOrPlaylist) || sourceOrPlaylist.source) {
- grouplike = sourceOrPlaylist
- } else {
- try {
- grouplike = await this.openPlaylist(sourceOrPlaylist)
- } catch (error) {
- if (error === 'unknown argument') {
- this.alertDialog.showMessage('Could not figure out how to load a playlist from: ' + sourceOrPlaylist)
- } else if (typeof error === 'string') {
- this.alertDialog.showMessage(error)
- } else {
- throw error
- }
- return
- }
- }
- this.alertDialog.close()
- grouplike = await processSmartPlaylist(grouplike)
- if (!this.tabber.currentElement || newTab && this.tabber.currentElement.grouplike) {
- const grouplikeListing = this.newGrouplikeListing()
- grouplikeListing.loadGrouplike(grouplike)
- } else {
- this.tabber.currentElement.loadGrouplike(grouplike)
- }
- }
- openPlaylist(arg) {
- const crawlers = getAllCrawlersForArg(arg)
- if (crawlers.length === 0) {
- throw 'unknown argument'
- }
- const crawler = crawlers[0]
- return crawler(arg)
- }
- setupDialog(dialog) {
- dialog.visible = false
- this.addChild(dialog)
- dialog.on('cancelled', () => {
- dialog.close()
- })
- }
- async shutdown() {
- if (this.config.stopPlayingUponQuit) {
- await this.backend.stopPlayingAll()
- }
- /*
- await this.textEditor.save()
- */
- this.emit('quitRequested')
- }
- suspend() {
- if (this.config.canSuspend) {
- this.emit('suspendRequested')
- }
- }
- fixLayout() {
- if (this.parent) {
- this.fillParent()
- }
- this.menubar.fixLayout()
- let topY = this.contentH
- if (this.partyBottom.visible) {
- this.partyBottom.w = this.contentW
- this.partyBottom.h = 1
- this.partyBottom.x = 0
- this.partyBottom.y = topY - this.partyBottom.h
- topY = this.partyBottom.top
- this.partyBottomBanner.w = this.partyBottom.w
- }
- this.playbackPane.w = this.contentW
- this.playbackPane.h = 5
- this.playbackPane.x = 0
- this.playbackPane.y = topY - this.playbackPane.h
- topY = this.playbackPane.top
- for (const PIE of this.playbackInfoElements) {
- if (this.playbackInfoElements.length === 1) {
- PIE.displayMode = 'expanded'
- } else {
- PIE.displayMode = 'collapsed'
- }
- }
- this.playbackForm.fillParent()
- this.playbackForm.fixLayout()
- let bottomY = 1
- if (this.partyTop.visible) {
- this.partyTop.w = this.contentW
- this.partyTop.h = 1
- this.partyTop.x = 0
- this.partyTop.y = 1
- bottomY = this.partyTop.bottom
- this.partyTopBanner.w = this.partyTop.w
- this.partyTopBanner.y = this.partyTop.contentH - 1
- this.alignPartyLabel()
- }
- const leftWidth = Math.max(Math.floor(0.7 * this.contentW), this.contentW - 80)
- /*
- if (this.textInfoPane.visible) {
- this.textInfoPane.w = leftWidth
- if (this.textEditor.isSelected) {
- this.textInfoPane.h = 8
- } else {
- this.textEditor.w = this.textInfoPane.contentW
- this.textEditor.rebuildUiLines()
- this.textInfoPane.h = Math.min(8, this.textEditor.getOptimalHeight() + 2)
- }
- this.textEditor.fillParent()
- this.textEditor.fixLayout()
- }
- */
- if (this.tabberPane.visible) {
- this.tabberPane.w = leftWidth
- this.tabberPane.y = bottomY
- this.tabberPane.h = topY - this.tabberPane.y
- /*
- if (this.textInfoPane.visible) {
- this.tabberPane.h -= this.textInfoPane.h
- this.textInfoPane.y = this.tabberPane.bottom
- }
- */
- this.queuePane.x = this.tabberPane.right
- this.queuePane.w = this.contentW - this.tabberPane.right
- } else {
- this.queuePane.x = 0
- this.queuePane.w = this.contentW
- /*
- if (this.textInfoPane.visible) {
- this.textInfoPane.y = bottomY
- }
- */
- }
- this.queuePane.y = bottomY
- this.queuePane.h = topY - this.queuePane.y
- topY = this.queuePane.y
- this.tabber.fillParent()
- if (this.metadataStatusLabel.visible) {
- this.tabber.h--
- this.metadataStatusLabel.y = this.tabberPane.contentH - 1
- }
- this.tabber.fixLayout()
- this.queueListingElement.fillParent()
- this.queueListingElement.h -= 2
- this.updateQueueLengthLabel()
- this.menuLayer.fillParent()
- }
- alignPartyLabel() {
- this.partyLabel.centerInParent()
- this.partyLabel.y = 0
- }
- attachAsServerHost(telnetServer) {
- this.isPartyHost = true
- this.attachAsServer(telnetServer)
- }
- attachAsServerClient(telnetServer) {
- this.isPartyHost = false
- this.attachAsServer(telnetServer)
- }
- attachAsServer(telnetServer) {
- this.telnetServer = telnetServer
- this.updatePartyLabel()
- this.telnetServer.on('joined', () => this.updatePartyLabel())
- this.telnetServer.on('left', () => this.updatePartyLabel())
- this.partyTop.visible = true
- this.partyBottom.visible = true
- this.fixLayout()
- }
- updatePartyLabel() {
- const clients = this.telnetServer.sockets.length
- const clientsMsg = clients === 1 ? '1-ish connection' : `${clients}-ish connections`
- let msg = `${process.env.USER} playing for ${clientsMsg}`
- this.partyLabel.text = ` ${msg} `
- this.alignPartyLabel()
- }
- keyPressed(keyBuf) {
- if (keyBuf[0] === 0x03) { // Ctrl-C
- this.shutdown()
- return
- } else if (keyBuf[0] === 0x1a) { // Ctrl-Z
- this.suspend()
- return
- }
- if ((telc.isEscape(keyBuf) || telc.isBackspace(keyBuf)) && this.menubar.isSelected) {
- this.menubar.restoreSelection()
- return
- }
- if (this.config.canControlPlayback) {
- if ((telc.isLeft(keyBuf) || telc.isRight(keyBuf)) && this.menubar.isSelected) {
- return // le sigh
- } else if (input.isRight(keyBuf)) {
- this.actOnQueuePlayers(qp => qp.seekAhead(10))
- } else if (input.isLeft(keyBuf)) {
- this.actOnQueuePlayers(qp => qp.seekBack(10))
- } else if (input.isTogglePause(keyBuf)) {
- this.actOnQueuePlayers(qp => qp.togglePause())
- } else if (input.isToggleLoop(keyBuf)) {
- this.actOnQueuePlayers(qp => qp.toggleLoop())
- } else if (input.isVolumeUp(keyBuf)) {
- this.actOnQueuePlayers(qp => qp.volUp())
- } else if (input.isVolumeDown(keyBuf)) {
- this.actOnQueuePlayers(qp => qp.volDown())
- } else if (input.isStop(keyBuf)) {
- this.actOnQueuePlayers(qp => qp.stopPlaying())
- } else if (input.isSkipBack(keyBuf)) {
- this.skipBackOrSeekToStart()
- } else if (input.isSkipAhead(keyBuf)) {
- this.skipAhead()
- }
- }
- if (input.isFocusTabber(keyBuf) && this.tabberPane.visible && this.tabber.selectable) {
- this.root.select(this.tabber)
- } else if (input.isFocusQueue(keyBuf) && this.queueListingElement.selectable) {
- this.root.select(this.queueListingElement)
- } else if (input.isFocusPlaybackInfo(keyBuf) && this.backend.queuePlayers.length > 1) {
- this.root.select(this.playbackForm)
- } else if (input.isFocusMenubar(keyBuf)) {
- if (this.menubar.isSelected) {
- this.menubar.restoreSelection()
- } else {
- // If we've got a menu open, close it, restoring selection to the
- // element selected before the menu was opened, so the menubar will
- // see that as the previously selected element (instead of the context
- // menu - which will be closed irregardless and gone when the menubar
- // tries to restore the selection).
- if (this.menuLayer.children[0]) {
- this.menuLayer.children[0].close()
- }
- this.menubar.select()
- }
- } else if (this.editMode && keyBuf.equals(Buffer.from([14]))) { // ctrl-N
- this.newEmptyTab()
- } else if (keyBuf.equals(Buffer.from([15]))) { // ctrl-O
- this.openPlaylistDialog.open()
- } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from([20]))) { // ctrl-T
- this.cloneCurrentTab()
- } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from([23]))) { // ctrl-W
- if (this.tabber.tabberElements.length > 1) {
- this.closeCurrentTab()
- }
- } else if (telc.isCharacter(keyBuf, 'u')) {
- this.undoManager.undoLastAction()
- } else if (telc.isCharacter(keyBuf, 'U')) {
- this.undoManager.redoLastUndoneAction()
- } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from(['t'.charCodeAt(0)]))) {
- this.tabber.nextTab()
- } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from(['T'.charCodeAt(0)]))) {
- this.tabber.previousTab()
- } else if (input.isPreviousPlayer(keyBuf)) {
- this.selectPreviousQueuePlayer()
- } else if (input.isNextPlayer(keyBuf)) {
- this.selectNextQueuePlayer()
- } else if (input.isNewPlayer(keyBuf)) {
- this.addQueuePlayer()
- } else if (input.isRemovePlayer(keyBuf)) {
- this.removeQueuePlayer(this.SQP)
- } else if (input.isActOnPlayer(keyBuf)) {
- this.toggleActOnQueuePlayer(this.SQP)
- } else {
- super.keyPressed(keyBuf)
- }
- }
- newEmptyTab() {
- const listing = this.newGrouplikeListing()
- listing.loadGrouplike({
- name: 'New Playlist',
- items: []
- })
- }
- cloneCurrentTab() {
- const grouplike = this.tabber.currentElement.grouplike
- const listing = this.newGrouplikeListing()
- listing.loadGrouplike(grouplike)
- }
- closeCurrentTab() {
- const listing = this.tabber.currentElement
- let index
- this.undoManager.pushAction({
- activate: () => {
- index = this.tabber.currentElementIndex
- this.tabber.closeTab(this.tabber.currentElement)
- },
- undo: () => {
- this.tabber.addTab(listing, index)
- this.tabber.selectTab(listing)
- }
- })
- }
- shuffleQueue() {
- this.SQP.shuffleQueue()
- }
- clearQueue() {
- this.SQP.clearQueue()
- this.queueListingElement.selectNone()
- this.updateQueueLengthLabel()
- if (this.queueListingElement.isSelected && !this.queueListingElement.selectable) {
- this.root.select(this.tabber)
- }
- }
- // TODO: I'd like to name/incorporate this function better.. for now it's
- // just directly moved from the old event listener on grouplikeListings for
- // 'queue'.
- handleQueueOptions(item, {where = 'end', order = 'normal', play = false, skip = false} = {}) {
- if (!this.config.canControlQueue) {
- return
- }
- const passedItem = item
- let { playingTrack } = this.SQP
- if (skip && playingTrack === item) {
- this.SQP.playNext(playingTrack)
- }
- const oldName = item.name
- if (isGroup(item)) {
- switch (order) {
- case 'shuffle':
- item = {
- name: `${oldName} (shuffled)`,
- items: shuffleArray(flattenGrouplike(item).items)
- }
- break
- case 'shuffle-groups':
- item = shuffleOrderOfGroups(item)
- item.name = `${oldName} (group order shuffled)`
- break
- case 'reverse':
- item = {
- name: `${oldName} (reversed)`,
- items: flattenGrouplike(item).items.reverse()
- }
- break
- case 'reverse-groups':
- item = reverseOrderOfGroups(item)
- item.name = `${oldName} (group order reversed)`
- break
- case 'alphabetic':
- item = {
- name: `${oldName} (alphabetic)`,
- items: orderBy(
- flattenGrouplike(item).items,
- t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '')
- )
- }
- break
- case 'alphabetic-groups':
- item = {
- name: `${oldName} (group order alphabetic)`,
- items: orderBy(
- collapseGrouplike(item).items,
- t => t.name.replace(/[^a-zA-Z0-9]/g, '')
- )
- }
- break
- }
- } else {
- // Make it into a grouplike that just contains itself.
- item = {name: oldName, items: [item]}
- }
- if (where === 'next' || where === 'after-selected' || where === 'before-selected' || where === 'end') {
- const selected = this.queueListingElement.currentItem
- let afterItem = null
- if (where === 'next') {
- afterItem = playingTrack
- } else if (where === 'after-selected') {
- afterItem = selected
- } else if (where === 'before-selected') {
- const { items } = this.SQP.queueGrouplike
- const index = items.indexOf(selected)
- if (index === 0) {
- afterItem = 'FRONT'
- } else if (index > 0) {
- afterItem = items[index - 1]
- }
- }
- this.SQP.queue(item, afterItem, {
- movePlayingTrack: order === 'normal' || order === 'alphabetic'
- })
- if (isTrack(passedItem)) {
- this.queueListingElement.selectAndShow(passedItem)
- } else {
- this.queueListingElement.selectAndShow(selected)
- }
- } else if (where.startsWith('distribute-')) {
- this.SQP.distributeQueue(item, {
- how: where.slice('distribute-'.length)
- })
- }
- this.updateQueueLengthLabel()
- if (play) {
- this.play(item)
- }
- }
- async processMetadata(item, reprocess = false) {
- if (!this.config.canProcessMetadata) {
- return
- }
- if (this.clearMetadataStatusTimeout) {
- clearTimeout(this.clearMetadataStatusTimeout)
- }
- this.metadataStatusLabel.text = 'Processing metadata...'
- this.metadataStatusLabel.visible = true
- this.fixLayout()
- const counter = await this.backend.processMetadata(item, reprocess)
- const tracksMsg = (counter === 1) ? '1 track' : `${counter} tracks`
- this.metadataStatusLabel.text = `Done processing metadata of ${tracksMsg}!`
- this.clearMetadataStatusTimeout = setTimeout(() => {
- this.clearMetadataStatusTimeout = null
- this.metadataStatusLabel.text = ''
- this.metadataStatusLabel.visible = false
- this.fixLayout()
- }, 3000)
- }
- updateQueueLengthLabel() {
- if (!this.SQP) {
- this.queueTimeLabel.text = ''
- return
- }
- const { playingTrack, timeData, queueEndMode } = this.SQP
- const { items } = this.SQP.queueGrouplike
- const {
- currentInput: currentInput,
- currentItem: selectedTrack
- } = this.queueListingElement
- const isTimestamp = (currentInput instanceof TimestampGrouplikeItemElement)
- let trackRemainSec = 0
- let trackPassedSec = 0
- if (timeData) {
- const { curSecTotal = 0, lenSecTotal = 0 } = timeData
- trackRemainSec = lenSecTotal - curSecTotal
- trackPassedSec = curSecTotal
- }
- const playingIndex = items.indexOf(playingTrack)
- const selectedIndex = items.indexOf(selectedTrack)
- const timestampData = playingTrack && this.getTimestampData(playingTrack)
- // This will be set to a list of tracks, which will later be used to
- // calculate a particular duration (as described below) to be shown in
- // the time label.
- let durationRange
- // This will be added to the calculated duration before it is displayed.
- // It's used to account for the time of the current track, if that is
- // relevant to the particular duration being calculated.
- let durationAdd
- // This will be stuck behind the final duration when it is displayed. It's
- // used to indicate the "direction" of the calculated duration to the user.
- let durationSymbol
- // Depending on which track is selected relative to which track is playing
- // (and on whether any track is playing at all), display...
- if (!playingTrack) {
- // Full length of the queue.
- durationRange = items
- durationAdd = 0
- durationSymbol = ''
- } else if (
- selectedIndex === playingIndex &&
- (!isTimestamp || currentInput.isCurrentTimestamp)
- ) {
- // Remaining length of the queue.
- if (timeData) {
- durationRange = items.slice(playingIndex + 1)
- durationAdd = trackRemainSec
- } else {
- durationRange = items.slice(playingIndex)
- durationAdd = 0
- }
- durationSymbol = ''
- } else if (
- selectedIndex < playingIndex ||
- (isTimestamp && currentInput.data.timestamp <= trackPassedSec)
- ) {
- // Time since the selected track ended.
- durationRange = items.slice(selectedIndex + 1, playingIndex)
- durationAdd = trackPassedSec // defaults to 0: no need to check timeData
- durationSymbol = '-'
- if (isTimestamp) {
- if (selectedIndex < playingIndex) {
- durationRange.unshift(items[selectedIndex])
- }
- durationAdd -= currentInput.data.timestampEnd
- }
- } else if (
- selectedIndex > playingIndex ||
- (isTimestamp && currentInput.data.timestamp > trackPassedSec)
- ) {
- // Time until the selected track begins.
- if (timeData) {
- if (selectedIndex === playingIndex) {
- durationRange = []
- durationAdd = -trackPassedSec
- } else {
- durationRange = items.slice(playingIndex + 1, selectedIndex)
- durationAdd = trackRemainSec
- }
- } else {
- durationRange = items.slice(playingIndex, selectedIndex)
- durationAdd = 0
- }
- if (isTimestamp) {
- durationAdd += currentInput.data.timestamp
- }
- durationSymbol = '+'
- }
- // Use the duration* variables to calculate and display the specified
- // duration.
- const { seconds: durationCalculated, approxSymbol } = this.backend.getDuration({items: durationRange})
- const durationTotal = durationCalculated + durationAdd
- const { duration: durationString } = getTimeStringsFromSec(0, durationTotal)
- this.queueTimeLabel.text = `(${durationSymbol + durationString + approxSymbol})`
- if (playingTrack) {
- let trackPart
- let trackPartShort
- let trackPartReallyShort
- {
- const distance = Math.abs(selectedIndex - playingIndex)
- let insertString
- let insertStringShort
- if (selectedIndex < playingIndex) {
- insertString = ` (-${distance})`
- insertStringShort = `-${distance}`
- } else if (selectedIndex > playingIndex) {
- insertString = ` (+${distance})`
- insertStringShort = `+${distance}`
- } else {
- insertString = ''
- insertStringShort = ''
- }
- trackPart = `${playingIndex + 1 + insertString} / ${items.length}`
- trackPartShort = (insertString
- ? `${playingIndex + 1 + insertStringShort}/${items.length}`
- : `${playingIndex + 1}/${items.length}`)
- trackPartReallyShort = (insertString
- ? insertStringShort
- : `#${playingIndex + 1}`)
- }
- let timestampPart
- if (isTimestamp && selectedIndex === playingIndex) {
- const selectedTimestampIndex = timestampData.indexOf(currentInput.data)
- const found = timestampData.findIndex(ts => ts.timestamp > trackPassedSec)
- const playingTimestampIndex = (found >= 0 ? found - 1 : 0)
- const distance = Math.abs(selectedTimestampIndex - playingTimestampIndex)
- let insertString
- if (selectedTimestampIndex < playingTimestampIndex) {
- insertString = ` (-${distance})`
- } else if (selectedTimestampIndex > playingTimestampIndex) {
- insertString = ` (+${distance})`
- } else {
- insertString = ''
- }
- timestampPart = `${playingTimestampIndex + 1 + insertString} / ${timestampData.length}`
- }
- let queueLoopPart
- let queueLoopPartShort
- if (selectedIndex === playingIndex) {
- switch (queueEndMode) {
- case 'loop':
- queueLoopPart = 'Repeat'
- queueLoopPartShort = 'R'
- break
- case 'shuffle':
- queueLoopPart = 'Shuffle'
- queueLoopPartShort = 'S'
- break
- case 'end':
- default:
- break
- }
- }
- let partsTogether
- const all = () => `(${this.SQP.playSymbol} ${partsTogether})`
- const tooWide = () => all().length > this.queuePane.contentW
- // goto irl
- determineParts: {
- if (timestampPart) {
- if (queueLoopPart) {
- partsTogether = `${trackPart} : ${timestampPart} »${queueLoopPartShort}`
- } else {
- partsTogether = `(${this.SQP.playSymbol} ${trackPart} : ${timestampPart})`
- }
- break determineParts
- }
- if (queueLoopPart) includeQueueLoop: {
- partsTogether = `${trackPart} » ${queueLoopPart}`
- if (tooWide()) {
- partsTogether = `${trackPart} »${queueLoopPartShort}`
- if (tooWide()) {
- break includeQueueLoop
- }
- }
- break determineParts
- }
- partsTogether = trackPart
- if (tooWide()) {
- partsTogether = trackPartShort
- if (tooWide()) {
- partsTogether = trackPartReallyShort
- }
- }
- }
- this.queueLengthLabel.text = all()
- } else {
- this.queueLengthLabel.text = `(${items.length})`
- }
- // Layout stuff to position the length and time labels correctly.
- this.queueLengthLabel.centerInParent()
- this.queueTimeLabel.centerInParent()
- this.queueLengthLabel.y = this.queuePane.contentH - 2
- this.queueTimeLabel.y = this.queuePane.contentH - 1
- }
- updateQueueSelection(timeData, oldTimeData) {
- if (!timeData) {
- return
- }
- const { playingTrack } = this.SQP
- const { form } = this.queueListingElement
- const { currentInput } = form
- if (!currentInput || currentInput.item !== playingTrack) {
- return
- }
- const timestamps = this.getTimestampData(playingTrack)
- if (!timestamps) {
- return
- }
- const tsOld = oldTimeData &&
- this.getTimestampAtSec(playingTrack, oldTimeData.curSecTotal)
- const tsNew =
- this.getTimestampAtSec(playingTrack, timeData.curSecTotal)
- if (
- tsNew !== tsOld &&
- currentInput instanceof TimestampGrouplikeItemElement &&
- currentInput.data === tsOld
- ) {
- const index = form.inputs.findIndex(el => (
- el.item === playingTrack &&
- el instanceof TimestampGrouplikeItemElement &&
- el.data === tsNew
- ))
- if (index === -1) {
- return
- }
- form.curIndex = index
- if (form.isSelected) {
- form.updateSelectedElement()
- }
- form.scrollSelectedElementIntoView()
- }
- }
- setThemeColor(color) {
- this.themeColor = color
- this.menubar.color = color
- }
- get SQP() {
- // Just a convenient shorthand.
- return this.selectedQueuePlayer
- }
- get selectedQueuePlayer() { return this.getDep('selectedQueuePlayer') }
- set selectedQueuePlayer(v) { this.setDep('selectedQueuePlayer', v) }
- }
- class GrouplikeListingElement extends Form {
- // TODO: This is a Form, which means that it captures the tab key. The result
- // of this is that you cannot use Tab to navigate the top-level application.
- // Accordingly, I've made AppElement a FocusElement and not a Form and re-
- // factored calls of addInput to addChild. However, I'm not sure that this is
- // the "correct" or most intuitive behavior. Should the tab key be usable to
- // navigate the entire interface? I don't know. I've gone with the current
- // behavior (GrouplikeListingElement as a Form) because it feels right at the
- // moment, but we'll see, I suppose.
- //
- // In order to let tab navigate through all UI elements (or rather, the top-
- // level application as well as GrouplikeListingElements, which are a sort of
- // nested Form), the AppElement would have to be changed to be a Form again
- // (replacing addChild with addInput where appropriate). Furthermore, while
- // the GrouplikeListingElement should stay as a Form subclass, it should be
- // modified so that it does not capture tab if there is no next element to
- // select, and vice versa for shift-tab and the previous element. This should
- // probably be implemented in tui-lib as a flag on Form (captureTabOnEnds,
- // or something).
- //
- // (PS AppElement apparently used a "this.form" property, instead of directly
- // inheriting from Form, apparently. That's more or less adjacent to the
- // point. It's removed now. You'll have to add it back, if wanted.)
- //
- // August 15th, 2018
- constructor(app) {
- super()
- this.grouplike = null
- this.app = app
- this.form = this.getNewForm()
- this.addInput(this.form)
- this.form.on('select', input => {
- if (input && this.pathElement) {
- this.pathElement.showItem(input.item)
- this.autoscroll()
- this.emit('select', input.item)
- }
- })
- this.jumpElement = new ListingJumpElement()
- this.addChild(this.jumpElement)
- this.jumpElement.visible = false
- this.oldFocusedIndex = null // To restore to, if a jump is canceled.
- this.previousJumpValue = '' // To default to, if the user doesn't enter anything.
- this.jumpElement.on('cancel', () => this.hideJumpElement(true))
- this.jumpElement.on('change', value => this.handleJumpValue(value, false))
- this.jumpElement.on('confirm', value => this.handleJumpValue(value, true))
- this.pathElement = new PathElement()
- this.addInput(this.pathElement)
- this.commentLabel = new WrapLabel()
- this.addChild(this.commentLabel)
- this.grouplikeData = new WeakMap()
- this.autoscrollOffset = null
- this.expandedTimestamps = []
- }
- getNewForm() {
- return new GrouplikeListingForm(this.app)
- }
- fixLayout() {
- this.commentLabel.w = this.contentW
- this.form.w = this.contentW
- this.form.h = this.contentH
- this.form.y = this.commentLabel.bottom
- this.form.h -= this.commentLabel.h
- this.form.h -= 1 // For the path element
- if (this.jumpElement.visible) this.form.h -= 1
- this.form.fixLayout() // Respond to being resized
- this.autoscroll()
- this.form.scrollSelectedElementIntoView()
- this.pathElement.y = this.contentH - 1
- this.pathElement.w = this.contentW
- this.jumpElement.y = this.pathElement.y - 1
- this.jumpElement.w = this.contentW
- }
- selected() {
- this.curIndex = 0
- this.root.select(this.form)
- this.emit('select', this.currentItem)
- }
- clicked(button) {
- if (button === 'left') {
- this.selected()
- return false
- }
- }
- get selectable() {
- return this.form.selectable
- }
- keyPressed(keyBuf) {
- // Just about everything here depends on the grouplike existing, so let's
- // not continue if it doesn't!
- if (!this.grouplike) {
- return
- }
- if (telc.isBackspace(keyBuf)) {
- this.loadParentGrouplike()
- } else if (telc.isCharacter(keyBuf, '/') || keyBuf[0] === 6) { // '/', ctrl-F
- this.showJumpElement()
- } else if (input.isScrollToStart(keyBuf)) {
- this.form.selectAndShow(this.grouplike.items[0])
- this.form.scrollToBeginning()
- } else if (input.isScrollToEnd(keyBuf)) {
- this.form.selectAndShow(this.grouplike.items[this.grouplike.items.length - 1])
- } else if (keyBuf[0] === 12) { // ctrl-L
- if (this.grouplike.isTheQueue) {
- this.form.selectAndShow(this.app.SQP.playingTrack)
- /*
- } else {
- this.toggleExpandLabels()
- */
- }
- } else if (keyBuf[0] === 1) { // ctrl-A
- this.toggleMarkAll()
- } else {
- return super.keyPressed(keyBuf)
- }
- }
- loadGrouplike(grouplike, resetIndex = true) {
- this.saveGrouplikeData()
- this.grouplike = grouplike
- this.buildItems(resetIndex)
- this.restoreGrouplikeData()
- if (this.root.select) this.hideJumpElement()
- }
- saveGrouplikeData() {
- if (isGroup(this.grouplike)) {
- this.grouplikeData.set(this.grouplike, {
- scrollItems: this.form.scrollItems,
- currentItem: this.currentItem,
- expandedTimestamps: this.expandedTimestamps
- })
- }
- }
- restoreGrouplikeData() {
- if (this.grouplikeData.has(this.grouplike)) {
- const data = this.grouplikeData.get(this.grouplike)
- this.form.scrollItems = data.scrollItems
- this.form.selectAndShow(data.currentItem)
- this.form.fixLayout()
- this.expandedTimestamps = data.expandedTimestamps
- this.buildTimestampItems()
- }
- }
- selectNone() {
- // nb: this is unrelated to the actual track selection system!
- // just clears the form selection
- this.pathElement.showItem(null)
- this.form.curIndex = 0
- this.form.scrollItems = 0
- }
- toggleMarkAll() {
- const { items } = this.grouplike
- const actions = []
- const tracks = flattenGrouplike(this.grouplike).items
- if (items.every(item => this.app.getMarkStatus(item) !== 'unmarked')) {
- if (this.app.markGrouplike.items.length > tracks.length) {
- actions.push({label: 'Remove from selection', action: () => this.app.unmarkItem(this.grouplike)})
- }
- actions.push({label: 'Clear selection', action: () => this.app.unmarkAll()})
- } else {
- actions.push({label: 'Add to selection', action: () => this.app.markItem(this.grouplike)})
- if (this.app.markGrouplike.items.some(item => !tracks.includes(item))) {
- actions.push({label: 'Replace selection', action: () => {
- this.app.unmarkAll()
- this.app.markItem(this.grouplike)
- }})
- }
- }
- if (actions.length === 1) {
- actions[0].action()
- } else {
- const el = this.form.inputs[this.form.curIndex]
- this.app.showContextMenu({
- x: el.absLeft,
- y: el.absTop + 1,
- items: actions
- })
- }
- }
- /*
- toggleExpandLabels() {
- this.expandLabels = !this.expandLabels
- for (const input of this.form.inputs) {
- if (!(input instanceof InteractiveGrouplikeItemElement)) {
- continue
- }
- if (!input.labelsSelected) {
- input.expandLabels = this.expandLabels
- input.computeText()
- }
- }
- }
- */
- toggleAutoscroll() {
- if (this.autoscrollOffset === null) {
- this.autoscrollOffset = this.form.curIndex - this.form.scrollItems
- this.form.wheelMode = 'selection'
- } else {
- this.autoscrollOffset = null
- this.form.wheelMode = 'scroll'
- }
- }
- autoscroll() {
- if (this.autoscrollOffset !== null) {
- const distanceFromTop = this.form.curIndex - this.form.scrollItems
- const delta = this.autoscrollOffset - distanceFromTop
- this.form.scrollItems -= delta
- this.form.fixLayout()
- }
- }
- expandTimestamps(item) {
- if (this.grouplike && this.grouplike.items.includes(item)) {
- const ET = this.expandedTimestamps
- if (!ET.includes(item)) {
- this.expandedTimestamps.push(item)
- this.buildTimestampItems()
- if (this.currentItem === item) {
- if (this.isSelected) {
- this.form.selectInput(this.form.inputs[this.form.curIndex + 1])
- } else {
- this.form.curIndex += 1
- }
- }
- }
- }
- }
- collapseTimestamps(item) {
- const ET = this.expandedTimestamps // :alien:
- if (ET.includes(item)) {
- const restore = (this.currentItem === item)
- ET.splice(ET.indexOf(item), 1)
- this.buildTimestampItems()
- if (restore) {
- const { form } = this
- const index = form.inputs.findIndex(inp => inp.item === item)
- form.curIndex = index
- if (form.isSelected) {
- form.updateSelectedElement()
- }
- form.scrollSelectedElementIntoView()
- }
- }
- }
- toggleTimestamps(item) {
- if (this.timestampsExpanded(item)) {
- this.collapseTimestamps(item)
- } else {
- this.expandTimestamps(item)
- }
- }
- timestampsExpanded(item) {
- this.updateTimestamps()
- return this.expandedTimestamps.includes(item)
- }
- selectTimestampAtSec(item, sec) {
- this.expandTimestamps(item)
- const { form } = this
- let index = form.inputs.findIndex(el => (
- el.item === item &&
- el instanceof TimestampGrouplikeItemElement &&
- el.data.timestamp >= sec
- ))
- if (index === -1) {
- index = form.inputs.findIndex(el => el.item === item)
- if (index === -1) {
- return
- }
- }
- form.curIndex = index
- if (form.isSelected) {
- form.updateSelectedElement()
- }
- form.scrollSelectedElementIntoView()
- }
- updateTimestamps() {
- const ET = this.expandedTimestamps
- if (ET) {
- this.expandedTimestamps = ET.filter(item => this.grouplike.items.includes(item))
- }
- }
- restoreSelectedInput(restoreInput) {
- const { form } = this
- const { inputs, currentInput } = form
- if (currentInput === restoreInput) {
- return
- }
- let inputToSelect
- if (inputs.includes(restoreInput)) {
- inputToSelect = restoreInput
- } else if (restoreInput instanceof InteractiveGrouplikeItemElement) {
- inputToSelect = inputs.find(input =>
- input.item === restoreInput.item &&
- input instanceof InteractiveGrouplikeItemElement
- )
- } else if (restoreInput instanceof TimestampGrouplikeItemElement) {
- inputToSelect = inputs.find(input =>
- input.data === restoreInput.data &&
- input instanceof TimestampGrouplikeItemElement
- )
- }
- if (!inputToSelect) {
- return
- }
- form.curIndex = inputs.indexOf(inputToSelect)
- if (form.isSelected) {
- form.updateSelectedElement()
- }
- form.scrollSelectedElementIntoView()
- }
- buildTimestampItems(restoreInput = this.currentInput) {
- const form = this.form
- // Clear up any existing timestamp items, since we're about to generate new
- // ones!
- form.children = form.children.filter(child => !(child instanceof TimestampGrouplikeItemElement))
- form.inputs = form.inputs.filter(child => !(child instanceof TimestampGrouplikeItemElement))
- this.updateTimestamps()
- if (!this.expandedTimestamps) {
- // Well that's going to have obvious consequences.
- return
- }
- for (const item of this.expandedTimestamps) {
- // Find the main item element. The items we're about to generate will be
- // inserted after it.
- const mainElementIndex = form.inputs.findIndex(el => (
- el instanceof InteractiveGrouplikeItemElement &&
- el.item === item
- ))
- const timestampData = this.app.getTimestampData(item)
- // Oh no.
- // TODO: This should probably error report lol.
- if (!timestampData) {
- continue
- }
- // Generate some items! Just go over the data list and generate one for
- // each timestamp.
- const tsElements = timestampData.map(ts => {
- const el = new TimestampGrouplikeItemElement(item, ts, timestampData, this.app)
- el.on('pressed', () => this.emit('timestamp', item, ts.timestamp))
- if (this.grouplike.isTheQueue) {
- el.hideMetadata = true
- }
- return el
- })
- // Stick 'em in. Form doesn't implement an "insert input" function because
- // why would life be easy, so we'll mangle the inputs array ourselves.
- form.inputs.splice(mainElementIndex + 1, 0, ...tsElements)
- let previousIndex = mainElementIndex
- for (const el of tsElements) {
- // We do addChild rather than a simple splice because addChild does more
- // stuff than just sticking it in the array (e.g. setting the child's
- // .parent property). What if addInput gets updated to do more stuff in
- // a similar fashion? Well, then we're scr*wed! :)
- form.addChild(el, previousIndex + 1)
- previousIndex++
- }
- }
- this.restoreSelectedInput(restoreInput)
- this.scheduleDrawWithoutPropertyChange()
- this.fixAllLayout()
- }
- buildItems(resetIndex = false) {
- if (!this.grouplike) {
- throw new Error('Attempted to call buildItems before a grouplike was loaded')
- }
- this.commentLabel.text = this.grouplike.comment || ''
- const restoreInput = this.form.currentInput
- const wasSelected = this.isSelected
- const form = this.form
- // Just outright scrap the old items - don't deal with any selection stuff
- // (as a result of removeInput) yet.
- form.children = form.children.filter(child => !form.inputs.includes(child));
- form.inputs = []
- const parent = this.grouplike[parentSymbol]
- if (parent) {
- const upButton = new BasicGrouplikeItemElement(`Up (to ${parent.name || 'unnamed group'})`)
- upButton.on('pressed', () => this.loadParentGrouplike())
- form.addInput(upButton)
- }
- if (this.grouplike.items.length) {
- // Add an element for controlling this whole group. Particularly handy
- // for operating on the top-level group, which itself is not contained
- // within any groups (so you can't browse a parent and access its menu
- // from there).
- if (!this.grouplike.isTheQueue) {
- const ownElement = new BasicGrouplikeItemElement(`This group: ${this.grouplike.name || '(Unnamed group)'}`)
- ownElement.item = this.grouplike
- ownElement.app = this.app
- ownElement.isGroup = true
- ownElement.on('pressed', () => {
- ownElement.emit('menu', ownElement)
- })
- this.addEventListeners(ownElement)
- form.addInput(ownElement)
- }
- // Add the elements for all the actual items within this playlist.
- for (const item of this.grouplike.items) {
- if (!isPlayable(item) && getCorrespondingPlayableForFile(item)) {
- continue
- }
- const itemElement = new InteractiveGrouplikeItemElement(item, this.app)
- this.addEventListeners(itemElement)
- form.addInput(itemElement)
- if (this.grouplike.isTheQueue) {
- itemElement.hideMetadata = true
- itemElement.text = getNameWithoutTrackNumber(item)
- }
- }
- } else if (!this.grouplike.isTheQueue) {
- form.addInput(new BasicGrouplikeItemElement('(This group is empty)'))
- }
- if (wasSelected) {
- if (resetIndex) {
- form.scrollItems = 0
- form.selectInput(form.inputs[form.firstItemIndex])
- } else {
- this.root.select(form)
- }
- }
- this.buildTimestampItems(restoreInput)
- // Just to make the selected-track-info bar fill right away (if it wasn't
- // already filled by a previous this.curIndex set).
- /* eslint-disable-next-line no-self-assign */
- form.curIndex = form.curIndex
- this.fixAllLayout()
- }
- addEventListeners(itemElement) {
- for (const evtName of [
- 'browse',
- 'download',
- 'edit-notes',
- 'mark',
- 'menu',
- 'open',
- 'paste',
- 'queue',
- 'remove',
- 'unqueue'
- ]) {
- itemElement.on(evtName, (...data) => this.emit(evtName, itemElement.item, ...data))
- }
- itemElement.on('toggle-timestamps', () => this.toggleTimestamps(itemElement.item))
- /*
- itemElement.on('unselected labels', () => {
- if (!this.expandLabels) {
- itemElement.expandLabels = false
- itemElement.computeText()
- }
- })
- */
- }
- loadParentGrouplike() {
- if (!this.grouplike) {
- return
- }
- const parent = this.grouplike[parentSymbol]
- if (parent) {
- const form = this.form
- const oldGrouplike = this.grouplike
- this.loadGrouplike(parent)
- form.curIndex = form.firstItemIndex
- this.restoreGrouplikeData()
- const index = form.inputs.findIndex(inp => inp.item === oldGrouplike)
- if (typeof index === 'number') {
- form.curIndex = index
- }
- form.updateSelectedElement()
- form.scrollSelectedElementIntoView()
- }
- }
- selectAndShow(item) {
- return this.form.selectAndShow(item)
- }
- handleJumpValue(value, isConfirm) {
- // If the user doesn't enter anything, we won't perform a search -- unless
- // the user just pressed enter. If that's the case, we'll search for
- // whatever was previously entered into the form. This is to strike a
- // balance between keeping the jump form simple and unsurprising but also
- // powerful, i.e. to support easy "repeated" searches (see the below
- // cmoment about search match prioritization).
- if (!value.length && isConfirm && this.previousJumpValue) {
- value = this.previousJumpValue
- }
- const grouplike = {items: this.form.inputs.map(inp => inp.item)}
- // We prioritize searching past the index that the user opened the jump
- // element from (oldFocusedIndex). This is so that it's more practical
- // to do a "repeated" search, wherein the user searches for the same
- // value over and over, each time jumping to the next match, until they
- // have found the one they're looking for.
- const preferredStartIndex = this.oldFocusedIndex
- const item = searchForItem(grouplike, value, preferredStartIndex)
- if (item) {
- this.form.curIndex = this.form.inputs.findIndex(inp => inp.item === item)
- this.form.scrollSelectedElementIntoView()
- } else {
- // TODO: Feedback that the search failed.. right now we just close the
- // jump-to menu, which might not be right.
- }
- if (isConfirm) {
- this.previousJumpValue = value
- this.hideJumpElement()
- }
- }
- showJumpElement() {
- this.oldFocusedIndex = this.form.curIndex
- this.jumpElement.visible = true
- this.root.select(this.jumpElement)
- this.fixLayout()
- }
- hideJumpElement(isCancel) {
- if (this.jumpElement.visible) {
- if (isCancel) {
- this.form.curIndex = this.oldFocusedIndex
- this.form.scrollSelectedElementIntoView()
- }
- this.jumpElement.visible = false
- if (this.jumpElement.isSelected) {
- this.root.select(this)
- }
- this.fixLayout()
- }
- }
- unselected() {
- this.hideJumpElement(true)
- }
- get tabberLabel() {
- if (this.grouplike) {
- return this.grouplike.name || 'Unnamed group'
- } else {
- return 'No group open'
- }
- }
- get currentItem() {
- const element = this.currentInput
- return element && element.item
- }
- get currentInput() {
- return this.form.currentInput
- }
- }
- class GrouplikeListingForm extends ListScrollForm {
- constructor(app) {
- super('vertical')
- this.app = app
- this.dragInputs = []
- this.selectMode = null
- this.keyboardDragDirection = null
- this.captureTab = false
- }
- keyPressed(keyBuf) {
- if (this.inputs.length === 0) {
- return
- }
- if (input.isSelectUp(keyBuf)) {
- this.selectUp()
- } else if (input.isSelectDown(keyBuf)) {
- this.selectDown()
- } else {
- if (telc.isUp(keyBuf) || telc.isDown(keyBuf)) {
- this.keyboardDragDirection = null
- }
- return super.keyPressed(keyBuf)
- }
- }
- set curIndex(newIndex) {
- this.setDep('curIndex', newIndex)
- this.emit('select', this.inputs[this.curIndex])
- }
- get curIndex() {
- return this.getDep('curIndex')
- }
- get firstItemIndex() {
- return Math.max(0, this.inputs.findIndex(el => el instanceof InteractiveGrouplikeItemElement))
- }
- get currentInput() {
- return this.inputs[this.curIndex]
- }
- selectAndShow(item) {
- const index = this.inputs.findIndex(inp => inp.item === item)
- if (index >= 0) {
- this.curIndex = index
- if (this.isSelected) {
- this.updateSelectedElement()
- }
- this.scrollSelectedElementIntoView()
- return true
- }
- return false
- }
- clicked(button, allData) {
- const { line, ctrl } = allData
- if (button === 'left') {
- this.dragStartLine = line - this.absTop + this.scrollItems
- this.dragStartIndex = this.inputs.findIndex(inp => inp.absTop === line - 1)
- if (this.dragStartIndex >= 0) {
- const input = this.inputs[this.dragStartIndex]
- if (!(input instanceof InteractiveGrouplikeItemElement)) {
- this.dragStartIndex = -1
- return
- }
- const { item } = input
- if (this.app.getMarkStatus(item) === 'unmarked') {
- if (!ctrl) {
- this.app.unmarkAll()
- }
- this.selectMode = 'select'
- } else {
- this.selectMode = 'deselect'
- }
- if (ctrl) {
- this.dragInputs = [item]
- this.dragEnteredRange(item)
- } else {
- this.dragInputs = []
- }
- this.oldMarkedItems = this.app.markGrouplike.items.slice()
- }
- } else if (button === 'drag-left' && this.dragStartIndex >= 0) {
- const offset = (line - this.absTop + this.scrollItems) - this.dragStartLine
- const rangeA = this.dragStartIndex
- const rangeB = this.dragStartIndex + offset
- const inputs = ((rangeA < rangeB)
- ? this.inputs.slice(rangeA, rangeB + 1)
- : this.inputs.slice(rangeB, rangeA + 1))
- let enteredRange = inputs.filter(inp => !this.dragInputs.includes(inp))
- let leftRange = this.dragInputs.filter(inp => !inputs.includes(inp))
- for (const { item } of enteredRange) {
- this.dragEnteredRange(item)
- }
- for (const { item } of leftRange) {
- this.dragLeftRange(item)
- }
- if (this.inputs[rangeB]) {
- this.root.select(this.inputs[rangeB])
- }
- this.dragInputs = inputs
- } else if (button === 'release') {
- this.dragStartIndex = -1
- } else {
- return super.clicked(button, allData)
- }
- }
- dragEnteredRange(item) {
- if (this.selectMode === 'select') {
- this.app.markItem(item)
- } else if (this.selectMode === 'deselect') {
- this.app.unmarkItem(item)
- }
- }
- dragLeftRange(item) {
- if (this.selectMode === 'select') {
- if (!this.oldMarkedItems.includes(item)) {
- this.app.unmarkItem(item)
- }
- } else if (this.selectMode === 'deselect') {
- if (this.oldMarkedItems.includes(item)) {
- this.app.markItem(item)
- }
- }
- }
- selectUp() {
- this.handleKeyboardSelect(-1)
- }
- selectDown() {
- this.handleKeyboardSelect(+1)
- }
- handleKeyboardSelect(direction) {
- const move = () => {
- if (direction === +1) {
- this.nextInput()
- } else {
- this.previousInput()
- }
- this.scrollSelectedElementIntoView()
- }
- const getItem = () => {
- const input = this.inputs[this.curIndex]
- if (input instanceof InteractiveGrouplikeItemElement) {
- return input.item
- } else {
- return null
- }
- }
- if (!this.keyboardDragDirection) {
- const item = getItem()
- if (!item) {
- move()
- return
- }
- this.keyboardDragDirection = direction
- this.oldMarkedItems = (this.inputs
- .filter(input => input.item && this.app.getMarkStatus(input.item) !== 'unmarked')
- .map(input => input.item))
- if (this.app.getMarkStatus(item) === 'unmarked') {
- this.selectMode = 'select'
- } else {
- this.selectMode = 'deselect'
- }
- this.dragEnteredRange(item)
- }
- if (direction === this.keyboardDragDirection) {
- move()
- const item = getItem()
- if (!item) {
- return
- }
- this.dragEnteredRange(item)
- } else {
- const item = getItem()
- if (!item) {
- move()
- return
- }
- this.dragLeftRange(item)
- move()
- }
- }
- }
- class BasicGrouplikeItemElement extends Button {
- constructor(text) {
- super()
- this._text = this._rightText = ''
- this.text = text
- this.rightText = ''
- this.drawText = ''
- }
- fixLayout() {
- this.w = this.parent.contentW
- this.h = 1
- this.computeText()
- }
- set text(val) {
- if (this._text !== val) {
- this._text = val
- this.computeText()
- }
- }
- get text() {
- return this._text
- }
- set rightText(val) {
- if (this._rightText !== val) {
- this._rightText = val
- this.computeText()
- }
- }
- get rightText() {
- return this._rightText
- }
- getFormattedRightText() {
- return this.rightText
- }
- getRightTextColumns() {
- return ansi.measureColumns(this.rightText)
- }
- getMinLeftTextColumns() {
- return 12
- }
- getLeftPadding() {
- return 2
- }
- getSelfSelected() {
- return this.isSelected
- }
- computeText() {
- let w = this.w - this.x - this.getLeftPadding()
- // Also make space for the right text - if we choose to show it.
- const rightTextCols = this.getRightTextColumns()
- const showRightText = (w - rightTextCols > this.getMinLeftTextColumns())
- if (showRightText) {
- w -= rightTextCols
- }
- let text = ansi.trimToColumns(this.text, w)
- const width = ansi.measureColumns(this.text)
- if (width < w) {
- text += ' '.repeat(w - width)
- }
- if (showRightText) {
- text += this.getFormattedRightText()
- }
- text += ansi.resetAttributes()
- this.drawText = text
- }
- drawTo(writable) {
- const isCurrentInput = this.parent.inputs[this.parent.curIndex] === this
- // This line's commented out for now, so it'll show as selected (but
- // dimmed) even if you don't have the listing selected. To change that,
- // uncomment this and add it to the isCurrentInput line.
- // const isListingSelected = this.parent.parent.isSelected
- const isSelfSelected = this.getSelfSelected()
- if (isSelfSelected) {
- writable.write(ansi.invert())
- } else if (isCurrentInput) {
- // technically cheating - isPlayable is defined on InteractiveGrouplikeElement
- if (this.isPlayable === false) {
- writable.write(ansi.setAttributes([ansi.A_INVERT, ansi.C_BLACK, ansi.A_BRIGHT]))
- } else {
- writable.write(ansi.setAttributes([ansi.A_INVERT, ansi.A_DIM]))
- }
- }
- writable.write(ansi.moveCursor(this.absTop, this.absLeft))
- this.writeStatus(writable)
- writable.write(this.drawText)
- }
- writeStatus(writable) {
- // Add a couple spaces. This is less than the padding of the status text
- // of elements which represent real playlist items; that's to distinguish
- // "fake" rows from actual playlist items.
- writable.write(' ')
- this.drawX += 2
- }
- keyPressed(keyBuf) {
- // This function is overridden by InteractiveGrouplikeItemElement, but
- // it's still specified here that only enter counts as an action key.
- // By default for buttons, the space key also works, but since in this app
- // space is generally bound to mean "pause" instead of "select", we don't
- // check if space is pressed here.
- if (telc.isEnter(keyBuf) || input.isMenu(keyBuf)) {
- this.emit('pressed')
- }
- }
- clicked(button) {
- super.clicked(button)
- }
- }
- class InlineListPickerElement extends FocusElement {
- // And you thought my class names couldn't get any worse...
- // This is an element that looks something like the following:
- // Fruit? [Apple]
- // (Imagine that "[Apple]" just looks like "Apple" written in blue text.)
- // If you press the element (like a button), it'll pick the next item in its
- // list of options, like "Banana" or "Canteloupe" in this example. The arrow
- // keys also work to move through the list. You typically don't want to put
- // too many items in the list, since there's no visual way of telling what's
- // next or previous. (That's the point, it's inline.) This element is mainly
- // useful in forms or ContextMenus.
- constructor(labelText, options, optsOrShowContextMenu = null) {
- super()
- this.labelText = labelText
- this.options = options
- if (typeof optsOrShowContextMenu === 'function') {
- this.showContextMenu = optsOrShowContextMenu
- }
- if (typeof optsOrShowContextMenu === 'object') {
- const opts = optsOrShowContextMenu
- this.showContextMenu = opts.showContextMenu
- this.getValue = opts.getValue
- this.setValue = opts.setValue
- }
- this.keyboardIdentifier = this.labelText
- this.curIndex = 0
- this.refreshValue()
- }
- fixLayout() {
- // We want to fill the parent's width, but also fit ourselves, so we need
- // to determine the ideal width which would fit us but not leave extra
- // space.
- const longestOptionLength = this.options.reduce(
- (soFar, { label }) => Math.max(soFar, ansi.measureColumns(label)), 0)
- const idealWidth = (
- ansi.measureColumns(this.labelText) + longestOptionLength + 4)
- // Then we use whichever is greater - our ideal width or the width of the
- // parent - as our own width. The parent should respect our needs by
- // growing if necessary. :) (ContextMenu does this, which is where you'd
- // typically embed this element.)
- // I shall fill you, parent, even beyond your own bounds!!!
- this.w = Math.max(this.parent.contentW, idealWidth)
- // Height is always just 1.
- this.h = 1
- }
- drawTo(writable) {
- if (this.isSelected) {
- writable.write(ansi.invert())
- }
- const curOption = this.options[this.curIndex].label.toString()
- let drawX = 0
- writable.write(ansi.moveCursor(this.absTop, this.absLeft))
- writable.write(this.labelText + ' ')
- drawX += ansi.measureColumns(this.labelText) + 1
- writable.write(ansi.setAttributes([ansi.A_BRIGHT, ansi.C_BLUE]))
- writable.write(' ' + curOption + ' ')
- drawX += ansi.measureColumns(curOption) + 2
- writable.write(ansi.setForeground(ansi.C_RESET))
- writable.write(' '.repeat(Math.max(0, this.w - drawX)))
- writable.write(ansi.resetAttributes())
- }
- keyPressed(keyBuf) {
- if (telc.isSelect(keyBuf) || telc.isRight(keyBuf)) {
- this.nextOption()
- } else if (telc.isLeft(keyBuf)) {
- this.previousOption()
- } else if (input.isMenu(keyBuf) && this.showContextMenu) {
- this.showMenu()
- } else {
- return true
- }
- return false
- }
- clicked(button) {
- if (button === 'left') {
- if (this.isSelected) {
- this.nextOption()
- } else {
- this.root.select(this)
- }
- } else if (button === 'right') {
- this.showMenu()
- } else if (button === 'scroll-up') {
- this.previousOption()
- } else if (button === 'scroll-down') {
- this.nextOption()
- } else {
- return true
- }
- return false
- }
- showMenu() {
- this.showContextMenu({
- x: this.absLeft + ansi.measureColumns(this.labelText) + 1,
- y: this.absTop + 1,
- items: this.options.map(({ label }, index) => ({
- label,
- action: () => {
- this.curIndex = index
- },
- isDefault: index === this.curIndex
- }))
- })
- }
- refreshValue() {
- if (this.getValue) {
- const value = this.getValue()
- const index = this.options.findIndex(opt => opt.value === value)
- if (index >= 0) {
- this.curIndex = index
- }
- }
- }
- nextOption() {
- this.curIndex++
- if (this.curIndex === this.options.length) {
- this.curIndex = 0
- }
- if (this.setValue) {
- this.setValue(this.curValue)
- }
- }
- previousOption() {
- this.curIndex--
- if (this.curIndex < 0) {
- this.curIndex = this.options.length - 1
- }
- if (this.setValue) {
- this.setValue(this.curValue)
- }
- }
- get curValue() {
- return this.options[this.curIndex].value
- }
- get curIndex() { return this.getDep('curIndex') }
- set curIndex(v) { this.setDep('curIndex', v) }
- }
- // Quite hacky, but ATM I can't think of any way to neatly tie getDep/setDep
- // into the slider and toggle elements.
- const drawAfter = (fn, thisObj) => (...args) => {
- const ret = fn(...args)
- thisObj.scheduleDrawWithoutPropertyChange()
- return ret
- }
- class SliderElement extends FocusElement {
- // Same general principle and usage as InlineListPickerElement, but for
- // changing a numeric value.
- constructor(labelText, {setValue, getValue, maxValue = 100, percent = true, getEnabled = () => true}) {
- super()
- this.labelText = labelText
- this.setValue = drawAfter(setValue, this)
- this.getValue = getValue
- this.getEnabled = getEnabled
- this.maxValue = maxValue
- this.percent = percent
- this.keyboardIdentifier = this.labelText
- }
- fixLayout() {
- const idealWidth = ansi.measureColumns(
- this.labelText +
- ' ' + this.getValueString(this.maxValue) +
- ' ' + this.getNumString(this.maxValue) +
- ' '
- )
- this.w = Math.max(this.parent.contentW, idealWidth)
- this.h = 1
- }
- drawTo(writable) {
- const enabled = this.getEnabled()
- if (this.isSelected) {
- writable.write(ansi.invert())
- }
- let drawX = 0
- writable.write(ansi.moveCursor(this.absTop, this.absLeft))
- if (!enabled) {
- writable.write(ansi.setAttributes([ansi.A_DIM, ansi.C_WHITE]))
- }
- writable.write(this.labelText + ' ')
- drawX += ansi.measureColumns(this.labelText) + 1
- if (enabled) {
- writable.write(ansi.setAttributes([ansi.A_BRIGHT, ansi.C_BLUE]))
- }
- writable.write(' ')
- drawX += 1
- const valueString = this.getValueString(this.getValue())
- writable.write(valueString)
- drawX += valueString.length
- const numString = this.getNumString(this.getValue())
- writable.write(' ' + numString + ' ')
- drawX += numString.length + 2
- if (enabled) {
- writable.write(ansi.setForeground(ansi.C_RESET))
- }
- writable.write(' '.repeat(Math.max(0, this.w - drawX)))
- writable.write(ansi.resetAttributes())
- }
- getValueString(value) {
- const maxLength = 10
- let length = Math.round(value / this.maxValue * maxLength)
- if (value < this.maxValue && length === maxLength) {
- length--
- }
- if (value > 0 && length === 0) {
- length++
- }
- return (
- '[' +
- '-'.repeat(length) +
- ' '.repeat(maxLength - length) +
- ']'
- )
- }
- getNumString(value) {
- const maxValueString = Math.round(this.maxValue).toString()
- const valueString = Math.round(value).toString()
- const paddedString = valueString.padStart(maxValueString.length)
- return paddedString + (this.percent ? '%' : '')
- }
- keyPressed(keyBuf) {
- const enabled = this.getEnabled()
- if (enabled && telc.isRight(keyBuf)) {
- this.increment()
- } else if (enabled && telc.isLeft(keyBuf)) {
- this.decrement()
- } else {
- return true
- }
- return false
- }
- clicked(button) {
- if (!this.getEnabled()) {
- return
- }
- if (button === 'left') {
- if (this.isSelected) {
- if (this.getValue() === this.maxValue) {
- this.setValue(0)
- } else {
- this.increment()
- }
- } else {
- this.root.select(this)
- }
- } else if (button === 'scroll-up') {
- this.increment()
- } else if (button === 'scroll-down') {
- this.decrement()
- }
- }
- increment() {
- this.setValue(this.getValue() + this.step)
- }
- decrement() {
- this.setValue(this.getValue() - this.step)
- }
- get step() {
- return this.maxValue / 10
- }
- }
- class ToggleControl extends FocusElement {
- constructor(labelText, {setValue, getValue, getEnabled = () => true}) {
- super()
- this.labelText = labelText
- this.setValue = drawAfter(setValue, this)
- this.getValue = getValue
- this.getEnabled = getEnabled
- this.keyboardIdentifier = this.labelText
- }
- keyPressed(keyBuf) {
- if (input.isSelect(keyBuf) && this.getEnabled()) {
- this.toggle()
- }
- }
- clicked(button) {
- if (!this.getEnabled()) {
- return
- }
- if (button === 'left') {
- if (this.isSelected) {
- this.toggle()
- } else {
- this.root.select(this)
- }
- } else if (button === 'scroll-up' || button === 'scroll-down') {
- this.toggle()
- } else {
- return true
- }
- return false
- }
- // Note: ToggleControl doesn't specify refreshValue because it doesn't have an
- // internal state for the current value. It sets and draws based on the value
- // getter provided externally.
- toggle() {
- this.setValue(!this.getValue())
- }
- fixLayout() {
- // Same general principle as ToggleControl - fill the parent, but always
- // fit ourselves!
- this.w = Math.max(this.parent.contentW, this.labelText.length + 5)
- this.h = 1
- }
- drawTo(writable) {
- if (this.isSelected) {
- writable.write(ansi.invert())
- }
- if (!this.getEnabled()) {
- writable.write(ansi.setAttributes([ansi.C_WHITE, ansi.A_DIM]))
- }
- writable.write(ansi.moveCursor(this.absTop, this.absLeft))
- writable.write(this.getValue() ? '[X] ' : '[.] ')
- writable.write(this.labelText)
- writable.write(' '.repeat(this.w - (this.labelText.length + 4)))
- writable.write(ansi.resetAttributes())
- }
- }
- class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
- constructor(item, app) {
- super(item.name)
- this.item = item
- this.app = app
- this.hideMetadata = false
- /*
- this.expandLabels = false
- this.labelsSelected = false
- this.selectedLabelIndex = 0
- */
- }
- drawTo(writable) {
- this.rightText = ''
- if (!this.hideMetadata) {
- const metadata = this.app.backend.getMetadataFor(this.item)
- if (metadata) {
- const durationString = getTimeStringsFromSec(0, metadata.duration).duration
- this.rightText = ` (${durationString}) `
- }
- }
- super.drawTo(writable)
- }
- selected() {
- this.computeText()
- }
- /*
- unselected() {
- this.unselectLabels()
- }
- getLabelTexts() {
- const separator = this.isSelected ? '' : ''
- let labels = []
- // let labels = ['Voice', 'Woof']
- if (this.expandLabels && this.labelsSelected) {
- labels = ['+', ...labels]
- }
- return labels.map((label, i) => {
- return [
- label,
- separator + (this.expandLabels
- ? (this.labelsSelected && i === this.selectedLabelIndex
- ? `<${label}>`
- : ` ${label} `)
- : label[0])
- ]
- })
- }
- getLabelColor(label) {
- if (label === '+') {
- return ansi.C_BLACK
- } else {
- return 30 + (label.charCodeAt(0) % 7)
- }
- }
- getFormattedRightText() {
- const labelTexts = this.getLabelTexts()
- if (labelTexts.length) {
- const lastColor = this.getLabelColor(labelTexts[labelTexts.length - 1][0])
- return (this.isSelected ? ' ' : '') +
- ansi.resetAttributes() +
- (this.isSelected ? '' : ' ') +
- ansi.setAttributes(this.isSelected ? [ansi.A_BRIGHT, 7] : []) +
- labelTexts.map(([ label, labelText ], i, arr) => {
- let text = ''
- if (this.isSelected) {
- text += ansi.setBackground(this.getLabelColor(label))
- } else {
- text += ansi.setForeground(this.getLabelColor(label))
- }
- text += labelText[0]
- // text += ansi.resetAttributes()
- text += ansi.setForeground(ansi.C_RESET)
- text += ansi.setBackground(this.getLabelColor(label))
- text += labelText.slice(1)
- return text
- }).join('') +
- ansi.setAttributes([ansi.A_RESET, this.isSelected ? 0 : lastColor]) +
- '▎' +
- ansi.resetAttributes() +
- super.getFormattedRightText()
- } else {
- return super.getFormattedRightText()
- }
- }
- getRightTextColumns() {
- const labelTexts = this.getLabelTexts()
- return labelTexts
- .reduce((acc, [l, lt]) => acc + lt.length, 0) +
- (labelTexts.length ? 2 : 0) +
- super.getRightTextColumns()
- }
- getMinLeftTextColumns() {
- return this.expandLabels ? 0 : super.getMinLeftTextColumns()
- }
- */
- getLeftPadding() {
- return 3
- }
- /*
- getSelfSelected() {
- return !this.labelsSelected && super.getSelfSelected()
- }
- */
- keyPressed(keyBuf) {
- /*
- if (this.labelsSelected) {
- if (input.isRight(keyBuf)) {
- this.selectNextLabel()
- } else if (input.isLeft(keyBuf)) {
- this.selectPreviousLabel()
- } else if (telc.isEscape(keyBuf) || input.isFocusLabels(keyBuf)) {
- this.unselectLabels()
- return false
- }
- } else */ if (input.isDownload(keyBuf)) {
- this.emit('download')
- } else if (input.isQueueAfterSelectedTrack(keyBuf)) {
- this.emit('queue', {where: 'next-selected'})
- } else if (input.isOpenThroughSystem(keyBuf)) {
- this.emit('open')
- } else if (telc.isEnter(keyBuf)) {
- if (isGroup(this.item)) {
- this.emit('browse')
- } else if (this.app.hasTimestampsFile(this.item)) {
- this.emit('toggle-timestamps')
- } else if (isTrack(this.item)) {
- this.emit('queue', {where: 'next', play: true})
- } else if (!this.isPlayable) {
- this.emit('open')
- }
- } else if (input.isRemove(keyBuf)) {
- this.emit('remove')
- } else if (input.isMenu(keyBuf)) {
- this.emit('menu', this)
- /*
- } else if (input.isFocusTextEditor(keyBuf)) {
- this.emit('edit-notes')
- } else if (input.isFocusLabels(keyBuf)) {
- this.labelsSelected = true
- this.expandLabels = true
- this.selectedLabelIndex = 0
- */
- }
- }
- /*
- unselectLabels() {
- this.labelsSelected = false
- this.emit('unselected labels')
- this.computeText()
- }
- selectNextLabel() {
- this.selectedLabelIndex++
- if (this.selectedLabelIndex >= this.getLabelTexts().length) {
- this.selectedLabelIndex = 0
- }
- this.computeText()
- }
- selectPreviousLabel() {
- this.selectedLabelIndex--
- if (this.selectedLabelIndex < 0) {
- this.selectedLabelIndex = this.getLabelTexts().length - 1
- }
- this.computeText()
- }
- */
- clicked(button, {ctrl}) {
- if (button === 'left') {
- if (this.isSelected) {
- if (ctrl) {
- return
- }
- if (this.isGroup) {
- this.emit('browse')
- } else if (this.isTrack) {
- this.emit('queue', {where: 'next', play: true})
- } else if (!this.isPlayable) {
- this.emit('open')
- }
- return false
- } else {
- this.parent.selectInput(this)
- }
- } else if (button === 'right') {
- this.parent.selectInput(this)
- this.emit('menu', this)
- return false
- }
- }
- writeStatus(writable) {
- const markStatus = this.app.getMarkStatus(this.item)
- const color = this.app.themeColor + 30
- if (this.isGroup) {
- // The ANSI attributes here will apply to the rest of the line, too.
- // (We don't reset the active attributes until after drawing the rest of
- // the line.)
- if (markStatus === 'marked' || markStatus === 'partial') {
- writable.write(ansi.setAttributes([color + 10]))
- } else {
- writable.write(ansi.setAttributes([color, ansi.A_BRIGHT]))
- }
- } else if (this.isTrack) {
- if (markStatus === 'marked') {
- writable.write(ansi.setAttributes([ansi.C_WHITE + 10, ansi.C_BLACK, ansi.A_BRIGHT]))
- }
- } else if (!this.isPlayable) {
- if (markStatus === 'marked') {
- writable.write(ansi.setAttributes([ansi.C_WHITE + 10, ansi.C_BLACK, ansi.A_BRIGHT]))
- } else {
- writable.write(ansi.setAttributes([ansi.A_DIM]))
- }
- }
- this.drawX += 3
- const braille = '⠈⠐⠠⠄⠂⠁'
- const brailleChar = braille[Math.floor(Date.now() / 250) % 6]
- const record = this.app.backend.getRecordFor(this.item)
- if (markStatus === 'marked') {
- writable.write('+')
- } else if (markStatus === 'partial') {
- writable.write('*')
- } else {
- writable.write(' ')
- }
- if (this.isGroup) {
- writable.write('G')
- } else if (!this.isPlayable) {
- writable.write('F')
- } else if (record.downloading) {
- writable.write(brailleChar)
- } else if (this.app.SQP.playingTrack === this.item) {
- writable.write('\u25B6')
- } else if (this.app.hasTimestampsFile(this.item)) {
- writable.write(':')
- } else {
- writable.write(' ')
- }
- writable.write(' ')
- }
- get isGroup() {
- return isGroup(this.item)
- }
- get isTrack() {
- return isTrack(this.item)
- }
- get isPlayable() {
- return isPlayable(this.item)
- }
- }
- class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement {
- constructor(item, timestampData, tsDataArray, app) {
- super('')
- this.app = app
- this.data = timestampData
- this.tsData = tsDataArray
- this.item = item
- this.hideMetadata = false
- }
- drawTo(writable) {
- const { data, tsData } = this
- const metadata = this.app.backend.getMetadataFor(this.item)
- const last = tsData[tsData.length - 1]
- const duration = ((metadata && metadata.duration)
- || last.timestampEnd !== Infinity && last.timestampEnd
- || last.timestamp)
- const strings = getTimeStringsFromSec(data.timestamp, duration)
- this.text = (
- /*
- (trackDuration
- ? `(${strings.timeDone} - ${strings.percentDone})`
- : `(${strings.timeDone})`) +
- */
- `(${strings.timeDone})` +
- (data.comment
- ? ` ${data.comment}`
- : '')
- )
- if (!this.hideMetadata) {
- const durationString = (data.timestampEnd === Infinity
- ? 'to end'
- : getTimeStringsFromSec(0, data.timestampEnd - data.timestamp).duration)
- // Try to line up so there's one column of negative padding - the duration
- // of the timestamp(s) should start one column before the duration of the
- // actual track. This makes for a nice nested look!
- const rightPadding = ' '.repeat(duration > 3600 ? 4 : 2)
- this.rightText = ` (${durationString})` + rightPadding
- }
- super.drawTo(writable)
- }
- writeStatus(writable) {
- let parts = []
- const color = ansi.setAttributes([ansi.A_BRIGHT, 30 + this.app.themeColor])
- const reset = ansi.setAttributes([ansi.C_RESET])
- if (this.isCurrentTimestamp) {
- parts = [
- color,
- ' ',
- // reset,
- '\u25B6 ',
- // color,
- ' '
- ]
- } else {
- parts = [
- color,
- ' ',
- reset,
- ':',
- color,
- ' '
- ]
- }
- for (const part of parts) {
- writable.write(part)
- }
- this.drawX += 4
- }
- get isCurrentTimestamp() {
- const { SQP } = this.app
- return (
- SQP.playingTrack === this.item &&
- SQP.timeData &&
- SQP.timeData.curSecTotal >= this.data.timestamp &&
- SQP.timeData.curSecTotal < this.data.timestampEnd
- )
- }
- getLeftPadding() {
- return 4
- }
- }
- class ListingJumpElement extends Form {
- constructor() {
- super()
- this.label = new Label('Jump to: ')
- this.addChild(this.label)
- this.input = new TextInput()
- this.addInput(this.input)
- this.input.on('confirm', value => this.emit('confirm', value))
- this.input.on('change', value => this.emit('change', value))
- this.input.on('cancel', () => this.emit('cancel'))
- }
- selected() {
- this.input.value = ''
- this.input.keepCursorInRange()
- this.root.select(this.input)
- }
- fixLayout() {
- this.input.x = this.label.right
- this.input.w = this.contentW - this.input.x
- }
- keyPressed(keyBuf) {
- const val = super.keyPressed(keyBuf)
- if (typeof val !== 'undefined') {
- return val
- }
- // Don't bubble escape.
- if (telc.isEscape(keyBuf)) {
- return false
- }
- }
- }
- class PathElement extends ListScrollForm {
- constructor() {
- // TODO: Once we've got the horizontal scrollbar draw working, perhaps
- // enable this? Well probably not. This is more a TODO to just, well,
- // implement that horizontal scrollbar drawing anyway.
- super('horizontal', false)
- this.captureTab = false
- }
- showItem(item) {
- while (this.inputs.length) {
- this.removeInput(this.inputs[0])
- }
- if (!item) {
- return
- }
- const itemPath = getItemPath(item)
- const parentPath = itemPath.slice(0, -1)
- for (let i = 0; i < parentPath.length; i++) {
- const pathItem = parentPath[i]
- const nextItem = itemPath[i + 1]
- const isFirst = (i === 0)
- const element = new PathItemElement(pathItem, isFirst)
- element.on('select', () => this.emit('select', pathItem, nextItem))
- element.fixLayout()
- this.addInput(element)
- }
- this.curIndex = this.inputs.length - 1
- this.scrollToEnd()
- this.fixLayout()
- }
- }
- class PathItemElement extends FocusElement {
- constructor(item, isFirst) {
- super()
- this.item = item
- this.isFirst = isFirst
- this.arrowLabel = new Label(isFirst ? 'In: ' : ' > ')
- this.addChild(this.arrowLabel)
- this.button = new Button(item.name || '(Unnamed)')
- this.addChild(this.button)
- this.button.on('pressed', () => {
- this.emit('select')
- })
- }
- selected() {
- this.root.select(this.button)
- }
- clicked(button) {
- if (button === 'left') {
- this.emit('select')
- }
- }
- fixLayout() {
- const text = this.item.name || '(Unnamed)'
- const maxWidth = this.parent ? this.parent.contentW : Infinity
- this.arrowLabel.fixLayout()
- const maxButtonWidth = maxWidth - this.arrowLabel.w
- if (text.length > maxButtonWidth) {
- this.button.text = unic.ELLIPSIS + text.slice(-(maxButtonWidth - 1))
- } else {
- this.button.text = text
- }
- this.button.fixLayout()
- this.w = this.button.w + this.arrowLabel.w
- this.button.x = this.arrowLabel.right
- this.h = 1
- }
- }
- class QueueListingElement extends GrouplikeListingElement {
- getNewForm() {
- return new QueueListingForm(this.app)
- }
- keyPressed(keyBuf) {
- if (input.isShuffleQueue(keyBuf)) {
- this.emit('shuffle')
- } else if (input.isClearQueue(keyBuf)) {
- this.emit('clear')
- } else {
- return super.keyPressed(keyBuf)
- }
- }
- }
- class QueueListingForm extends GrouplikeListingForm {
- updateSelectedElement() {
- if (this.inputs.length) {
- super.updateSelectedElement()
- } else {
- this.emit('select main listing')
- }
- }
- }
- class PlaybackInfoElement extends FocusElement {
- constructor(queuePlayer, app) {
- super()
- this.queuePlayer = queuePlayer
- this.app = app
- this.displayMode = 'expanded'
- this.timeData = {}
- this.queuePlayerIndex = 0
- this.queuePlayerSelected = false
- this.progressBarLabel = new Label('')
- this.addChild(this.progressBarLabel)
- this.progressTextLabel = new Label('')
- this.addChild(this.progressTextLabel)
- this.trackNameLabel = new Label('')
- this.addChild(this.trackNameLabel)
- this.downloadLabel = new Label('')
- this.addChild(this.downloadLabel)
- this.queuePlayerIndexLabel = new Label('')
- this.addChild(this.queuePlayerIndexLabel)
- this.remainingTracksLabel = new Label('')
- this.addChild(this.remainingTracksLabel)
- this.updateTrack()
- this.updateProgress()
- this.handleQueueUpdated = this.handleQueueUpdated.bind(this)
- this.attachQueuePlayerListeners()
- }
- attachQueuePlayerListeners() {
- this.queuePlayer.on('queue updated', this.handleQueueUpdated)
- }
- removeQueuePlayerListeners() {
- this.queuePlayer.removeListener('queue updated', this.handleQueueUpdated)
- }
- handleQueueUpdated() {
- this.updateProgress()
- this.updateTrack()
- }
- fixLayout() {
- this.refreshProgressText()
- if (this.displayMode === 'expanded') {
- this.fixLayoutExpanded()
- } else if (this.displayMode === 'collapsed') {
- this.fixLayoutCollapsed()
- }
- }
- fixLayoutExpanded() {
- if (this.parent) {
- this.fillParent()
- }
- this.queuePlayerIndexLabel.visible = false
- this.remainingTracksLabel.visible = false
- this.downloadLabel.visible = true
- this.trackNameLabel.y = 0
- this.progressBarLabel.y = 1
- this.progressTextLabel.y = this.progressBarLabel.y
- this.downloadLabel.y = 2
- if (this.currentTrack) {
- const dl = this.currentTrack.downloaderArg
- let dlText = dl.slice(Math.max(dl.length - this.w + 20, 0))
- if (dlText !== dl) {
- dlText = unic.ELLIPSIS + dlText
- }
- this.downloadLabel.text = `(From: ${dlText})`
- }
- for (const el of [
- this.progressTextLabel,
- this.trackNameLabel,
- this.downloadLabel
- ]) {
- el.x = Math.round((this.w - el.w) / 2)
- }
- }
- fixLayoutCollapsed() {
- if (this.parent) {
- this.w = Math.max(30, this.parent.contentW)
- }
- this.h = 1
- this.queuePlayerIndexLabel.visible = true
- this.remainingTracksLabel.visible = true
- this.downloadLabel.visible = false
- const why = this.app.willActOnQueuePlayer(this.queuePlayer)
- const index = this.app.backend.queuePlayers.indexOf(this.queuePlayer)
- const msg = (why ? '!' : ' ') + index
- this.queuePlayerIndexLabel.text = (this.app.SQP === this.queuePlayer
- ? `<${msg}>`
- : ` ${msg} `)
- if (why === 'marked') {
- this.queuePlayerIndexLabel.textAttributes = [ansi.A_BRIGHT]
- } else {
- this.queuePlayerIndexLabel.textAttributes = []
- }
- this.queuePlayerIndexLabel.x = 1
- this.queuePlayerIndexLabel.y = 0
- this.trackNameLabel.x = this.queuePlayerIndexLabel.right + 1
- this.trackNameLabel.y = 0
- this.progressBarLabel.y = 0
- this.progressBarLabel.x = 0
- this.remainingTracksLabel.x = this.contentW - this.remainingTracksLabel.w - 1
- this.remainingTracksLabel.y = 0
- this.progressTextLabel.x = this.remainingTracksLabel.x - this.progressTextLabel.w - 1
- this.progressTextLabel.y = 0
- this.refreshTrackText(this.progressTextLabel.x - 2 - this.trackNameLabel.x)
- this.refreshProgressText()
- }
- clicked(button) {
- if (button === 'scroll-up') {
- this.emit('seek back')
- } else if (button === 'scroll-down') {
- this.emit('seek ahead')
- } else if (button === 'left') {
- if (this.displayMode === 'expanded') {
- this.emit('toggle pause')
- } else if (this.isSelected) {
- this.showMenu()
- } else {
- this.root.select(this)
- }
- }
- }
- keyPressed(keyBuf) {
- if (input.isSelect(keyBuf)) {
- this.showMenu()
- return false
- }
- }
- showMenu() {
- const fn = this.showContextMenu || this.app.showContextMenu
- fn({
- x: this.absLeft,
- y: this.absTop + 1,
- items: [
- {
- label: 'Select',
- action: () => {
- this.app.selectQueuePlayer(this.queuePlayer)
- this.parent.fixLayout()
- }
- },
- {
- label: (this.app.willActOnQueuePlayer(this.queuePlayer) === 'marked'
- ? 'Remove from multiple-player selection'
- : 'Add to multiple-player selection'),
- action: () => {
- this.app.toggleActOnQueuePlayer(this.queuePlayer)
- this.parent.fixLayout()
- }
- },
- this.app.backend.queuePlayers.length > 1 && {
- label: 'Delete',
- action: () => {
- this.app.removeQueuePlayer(this.queuePlayer)
- }
- }
- ]
- })
- }
- refreshProgressText() {
- const { player, timeData } = this.queuePlayer
- this.remainingTracksLabel.text = (this.queuePlayer.playingTrack
- ? `(+${this.queuePlayer.remainingTracks})`
- : `(${this.queuePlayer.remainingTracks})`)
- if (!timeData) {
- return
- }
- const { timeDone, duration, lenSecTotal, curSecTotal } = timeData
- this.timeData = timeData
- this.curSecTotal = curSecTotal
- this.lenSecTotal = lenSecTotal
- this.volume = player.volume
- this.isLooping = player.isLooping
- this.isPaused = player.isPaused
- this.progressBarLabel.text = '-'.repeat(Math.floor(this.w / lenSecTotal * curSecTotal))
- this.progressTextLabel.text = timeDone + ' / ' + duration
- if (player.isLooping) {
- this.progressTextLabel.text += ' [Looping]'
- }
- if (player.volume !== 100) {
- this.progressTextLabel.text += ` [Volume: ${Math.round(player.volume)}%]`
- }
- }
- refreshTrackText(maxNameWidth = Infinity) {
- const { playingTrack } = this.queuePlayer
- if (playingTrack) {
- this.currentTrack = playingTrack
- const { name } = playingTrack
- if (ansi.measureColumns(name) > maxNameWidth) {
- this.trackNameLabel.text = ansi.trimToColumns(name, maxNameWidth) + unic.ELLIPSIS
- } else {
- this.trackNameLabel.text = playingTrack.name
- }
- this.progressBarLabel.text = ''
- this.progressTextLabel.text = '(Starting..)'
- this.timeData = {}
- } else {
- this.clearInfoText()
- }
- }
- clearInfoText() {
- this.currentTrack = null
- this.progressBarLabel.text = ''
- this.progressTextLabel.text = ''
- this.trackNameLabel.text = ''
- this.downloadLabel.text = ''
- this.timeData = {}
- }
- updateProgress() {
- this.refreshProgressText()
- this.fixLayout()
- }
- updateTrack() {
- this.refreshTrackText()
- this.fixLayout()
- }
- clearInfo() {
- this.clearInfoText()
- this.fixLayout()
- }
- drawTo(writable) {
- if (this.isSelected) {
- this.progressBarLabel.textAttributes = [ansi.A_INVERT]
- } else {
- this.progressBarLabel.textAttributes = []
- }
- if (this.isSelected) {
- writable.write(ansi.invert())
- writable.write(ansi.moveCursor(this.absTop, this.absLeft))
- writable.write(' '.repeat(this.w))
- }
- }
- get curSecTotal() { return this.getDep('curSecTotal') }
- set curSecTotal(v) { this.setDep('curSecTotal', v) }
- get lenSecTotal() { return this.getDep('lenSecTotal') }
- set lenSecTotal(v) { this.setDep('lenSecTotal', v) }
- get volume() { return this.getDep('volume') }
- set volume(v) { this.setDep('volume', v) }
- get isLooping() { return this.getDep('isLooping') }
- set isLooping(v) { this.setDep('isLooping', v) }
- get isPaused() { return this.getDep('isPaused') }
- set isPaused(v) { this.setDep('isPaused', v) }
- get currentTrack() { return this.getDep('currentTrack') }
- set currentTrack(v) { this.setDep('currentTrack', v) }
- }
- class OpenPlaylistDialog extends Dialog {
- constructor() {
- super()
- this.label = new Label('Enter a playlist source:')
- this.pane.addChild(this.label)
- this.form = new Form()
- this.pane.addChild(this.form)
- this.input = new TextInput()
- this.form.addInput(this.input)
- this.button = new Button('Open')
- this.form.addInput(this.button)
- this.buttonNewTab = new Button('..in New Tab')
- this.form.addInput(this.buttonNewTab)
- this.button.on('pressed', () => {
- if (this.input.value) {
- this.emit('source selected', this.input.value)
- }
- })
- this.buttonNewTab.on('pressed', () => {
- if (this.input.value) {
- this.emit('source selected (new tab)', this.input.value)
- }
- })
- }
- opened() {
- this.input.setValue('')
- this.form.curIndex = 0
- this.form.updateSelectedElement()
- }
- fixLayout() {
- super.fixLayout()
- this.pane.w = Math.min(60, this.contentW)
- this.pane.h = 6
- this.pane.centerInParent()
- this.label.centerInParent()
- this.label.y = 0
- this.form.w = this.pane.contentW
- this.form.h = 2
- this.form.y = 1
- this.input.w = this.form.contentW
- this.button.centerInParent()
- this.button.y = 1
- this.buttonNewTab.centerInParent()
- this.buttonNewTab.y = 2
- }
- selected() {
- this.root.select(this.form)
- }
- }
- class AlertDialog extends Dialog {
- constructor() {
- super()
- this.label = new Label()
- this.pane.addChild(this.label)
- this.button = new Button('Close')
- this.button.on('pressed', () => {
- if (this.canClose) {
- this.emit('cancelled')
- }
- })
- this.pane.addChild(this.button)
- }
- selected() {
- this.root.select(this.button)
- }
- showMessage(message, canClose = true) {
- this.canClose = canClose
- this.label.text = message
- this.button.text = canClose ? 'Close' : '(Hold on...)'
- this.open()
- }
- fixLayout() {
- super.fixLayout()
- this.pane.w = Math.min(this.label.w + 4, this.contentW)
- this.pane.h = 4
- this.pane.centerInParent()
- this.label.centerInParent()
- this.label.y = 0
- this.button.fixLayout()
- this.button.centerInParent()
- this.button.y = 1
- }
- keyPressed() {
- // Don't handle the escape key.
- }
- }
- class Tabber extends FocusElement {
- constructor() {
- super()
- this.tabberElements = []
- this.currentElementIndex = 0
- this.listElement = new TabberList(this)
- this.addChild(this.listElement)
- this.listElement.on('select', item => this.selectTab(item))
- this.listElement.on('next tab', () => this.nextTab())
- this.listElement.on('previous tab', () => this.previousTab())
- }
- fixLayout() {
- const el = this.currentElement
- if (el) {
- // Only make space for the tab list if there's more than one tab visible.
- // (The tab list isn't shown if there's only one.)
- if (this.tabberElements.length > 1) {
- el.w = this.contentW
- el.h = this.contentH - 1
- el.x = 0
- el.y = 1
- } else {
- el.fillParent()
- el.x = 0
- el.y = 0
- }
- el.fixLayout()
- }
- if (this.tabberElements.length > 1) {
- this.listElement.visible = true
- this.listElement.w = this.contentW
- this.listElement.h = 1
- this.listElement.fixLayout()
- } else {
- this.listElement.visible = false
- }
- }
- addTab(element, index = this.currentElementIndex) {
- element.visible = false
- this.tabberElements.splice(index + 1, 0, element)
- this.addChild(element, index + 1)
- this.listElement.buildItems()
- }
- nextTab() {
- this.currentElementIndex++
- if (this.currentElementIndex >= this.tabberElements.length) {
- this.currentElementIndex = 0
- }
- this.updateVisibleElement()
- }
- previousTab() {
- this.currentElementIndex--
- if (this.currentElementIndex < 0) {
- this.currentElementIndex = this.tabberElements.length - 1
- }
- this.updateVisibleElement()
- }
- selectTab(element) {
- if (!this.tabberElements.includes(element)) {
- throw new Error('That tab does not exist! (Perhaps it was removed, somehow, or was never added?)')
- }
- this.currentElementIndex = this.tabberElements.indexOf(element)
- this.updateVisibleElement()
- }
- closeTab(element) {
- if (!this.tabberElements.includes(element)) {
- return
- }
- const index = this.tabberElements.indexOf(element)
- this.tabberElements.splice(index, 1)
- if (index <= this.currentElementIndex) {
- this.currentElementIndex--
- }
- // Deliberately update the visible element before removing the child. If we
- // remove the child first, the isSelected in updateVisibleElement will be
- // false, so the new currentElement won't actually be root.select()'ed.
- this.updateVisibleElement()
- this.removeChild(element)
- this.listElement.buildItems()
- }
- updateVisibleElement() {
- const len = this.tabberElements.length - 1
- this.currentElementIndex = Math.min(len, Math.max(0, this.currentElementIndex))
- this.tabberElements.forEach((el, i) => {
- el.visible = (i === this.currentElementIndex)
- })
- if (this.isSelected) {
- if (this.currentElement) {
- this.root.select(this.currentElement)
- } else {
- this.root.select(this)
- }
- }
- this.fixLayout()
- }
- selected() {
- if (this.currentElement) {
- this.root.select(this.currentElement)
- }
- }
- get selectable() {
- return this.currentElement && this.currentElement.selectable
- }
- get currentElement() {
- return this.tabberElements[this.currentElementIndex] || null
- }
- }
- class TabberList extends ListScrollForm {
- constructor(tabber) {
- super('horizontal')
- this.tabber = tabber
- this.captureTab = false
- }
- buildItems() {
- while (this.inputs.length) {
- this.removeInput(this.inputs[0])
- }
- for (const item of this.tabber.tabberElements) {
- const element = new TabberListItem(item, this.tabber)
- this.addInput(element)
- element.fixLayout()
- element.on('select', () => this.emit('select', item))
- }
- this.scrollToEnd()
- this.fixLayout()
- }
- fixLayout() {
- this.w = this.parent.contentW
- this.h = 1
- this.x = 0
- this.y = 0
- this.scrollElementIntoEndOfView(this.inputs[this.curIndex])
- super.fixLayout()
- }
- drawTo() {
- let changed = false
- for (const input of this.inputs) {
- input.fixLayout()
- if (input._oldW !== input.w) {
- input._oldW = input.w
- changed = true
- }
- }
- if (changed) {
- this.fixLayout()
- }
- }
- clicked(button) {
- if (button === 'scroll-up') {
- this.emit('previous tab')
- return false
- } else if (button === 'scroll-down') {
- this.emit('next tab')
- return false
- }
- }
- // TODO: Be less hacky about these! Right now the tabber list is totally not
- // interactive.
- get curIndex() { return this.tabber.currentElementIndex }
- set curIndex(newVal) {}
- }
- class TabberListItem extends FocusElement {
- constructor(tab, tabber) {
- super()
- this.tab = tab
- this.tabber = tabber
- }
- fixLayout() {
- this.w = ansi.measureColumns(this.text) + 3
- this.h = 1
- }
- drawTo(writable) {
- if (this.tabber.currentElement === this.tab) {
- writable.write(ansi.setAttributes([ansi.A_BRIGHT]))
- writable.write(ansi.moveCursor(this.absTop, this.absLeft))
- writable.write('<' + this.text + '>')
- writable.write(ansi.resetAttributes())
- } else {
- writable.write(ansi.moveCursor(this.absTop, this.absLeft + 1))
- writable.write(this.text)
- }
- }
- clicked(button) {
- if (button === 'left') {
- this.emit('select')
- return false
- }
- }
- get text() {
- return this.tab.tabberLabel || 'a(n) ' + this.tab.constructor.name
- }
- }
- class ContextMenu extends FocusElement {
- constructor(showContextMenu) {
- super()
- this.pane = new Pane()
- this.addChild(this.pane)
- this.form = new ListScrollForm()
- this.pane.addChild(this.form)
- this.keyboardSelector = new KeyboardSelector(this.form)
- this.visible = false
- this.showContextMenu = showContextMenu
- this.showSubmenu = this.showSubmenu.bind(this)
- this.submenu = null
- }
- show({x = 0, y = 0, pages = null, items: itemsArg = null, focusKey = null, pageNum = 0}) {
- this.reload = () => {
- const els = [this.root.selectedElement, ...this.root.selectedElement.directAncestors]
- const focusKey = Object.keys(keyElementMap).find(key => els.includes(keyElementMap[key]))
- this.close(false)
- this.show({x, y, items: itemsArg, focusKey})
- }
- this.nextPage = () => {
- if (pages.length > 1) {
- pageNum++
- if (pageNum === pages.length) {
- pageNum = 0
- }
- this.close(false)
- this.show({x, y, pages, pageNum})
- }
- }
- this.previousPage = () => {
- if (pages.length > 1) {
- pageNum--
- if (pageNum === -1) {
- pageNum = pages.length - 1
- }
- this.close(false)
- this.show({x, y, pages, pageNum})
- }
- }
- if (!pages && !itemsArg || pages && itemsArg) {
- return
- }
- if (pages) {
- if (pages.length === 0) {
- return
- }
- itemsArg = pages[pageNum]
- }
- let items = (typeof itemsArg === 'function') ? itemsArg() : itemsArg
- items = items.filter(Boolean)
- if (!items.length) {
- return
- }
- // Call refreshValue() on any items before they're shown, for items that
- // provide it. (This is handy when reusing the same input across a menu that
- // might be shown under different contexts.)
- for (const item of items) {
- const el = item.element
- if (!el) continue
- if (!el.refreshValue) continue
- el.refreshValue()
- }
- if (!this.root.selectedElement.directAncestors.includes(this)) {
- this.selectedBefore = this.root.selectedElement
- }
- this.clearItems()
- this.x = x
- this.y = y
- this.visible = true
- // This code is so that we don't show two dividers beside each other, or
- // end a menu with a divider!
- let wantDivider = false
- const addDividerIfWanted = () => {
- if (wantDivider) {
- if (!firstItem) {
- const element = new HorizontalRule()
- this.form.addInput(element)
- }
- wantDivider = false
- }
- }
- let firstItem = true
- const keyElementMap = {}
- for (const item of items.filter(Boolean)) {
- let focusEl
- if (item.element) {
- addDividerIfWanted()
- focusEl = item.element
- this.form.addInput(item.element)
- item.element.showContextMenu = this.showSubmenu
- if (item.isDefault) {
- this.root.select(item.element)
- }
- firstItem = false
- } else if (item.divider) {
- wantDivider = true
- } else {
- addDividerIfWanted()
- let label = item.label
- if (item.isPageSwitcher && pages.length > 1) {
- label = `\x1b[2m(${pageNum + 1}/${pages.length}) « \x1b[22m${label}\x1b[2m »\x1b[22m`
- }
- const button = new Button(label)
- button.keyboardIdentifier = item.keyboardIdentifier || label
- if (item.action) {
- button.on('pressed', async () => {
- this.restoreSelection()
- if (await item.action() === 'reload') {
- this.reload()
- } else {
- this.close()
- }
- })
- }
- if (item.isPageSwitcher) {
- button.on('pressed', async () => {
- this.nextPage()
- })
- }
- button.item = item
- focusEl = button
- this.form.addInput(button)
- if (item.isDefault) {
- this.root.select(button)
- }
- firstItem = false
- }
- if (item.key) {
- keyElementMap[item.key] = focusEl
- }
- }
- this.fixLayout()
- if (focusKey && keyElementMap[focusKey]) {
- this.root.select(keyElementMap[focusKey])
- } else if (!items.some(item => item.isDefault)) {
- this.form.firstInput()
- }
- this.keyboardSelector.reset()
- }
- showSubmenu(opts) {
- this.showContextMenu(Object.assign({}, opts, {
- // We need to get a reference to the submenu before it is shown, or else
- // the parent menu will be closed (from being unselected and not knowing
- // that a submenu was just opened).
- beforeShowing: menu => {
- this.submenu = menu
- }
- }))
- this.submenu.on('close', () => {
- this.submenu = null
- })
- }
- keyPressed(keyBuf) {
- if (telc.isEscape(keyBuf) || telc.isBackspace(keyBuf)) {
- this.restoreSelection()
- this.close()
- return false
- } else if (this.keyboardSelector.keyPressed(keyBuf)) {
- return false
- } else if (input.isScrollToStart(keyBuf)) {
- this.form.firstInput()
- this.form.scrollToBeginning()
- } else if (input.isScrollToEnd(keyBuf)) {
- this.form.lastInput()
- } else if (input.isLeft(keyBuf) || input.isRight(keyBuf)) {
- if (this.form.inputs[this.form.curIndex].item.isPageSwitcher) {
- if (input.isLeft(keyBuf)) {
- this.previousPage()
- } else {
- this.nextPage()
- }
- return false
- }
- } else {
- return super.keyPressed(keyBuf)
- }
- }
- unselected() {
- // Don't close if we just opened a submenu!
- const newEl = this.root.selectedElement
- if (this.submenu && newEl.directAncestors.includes(this.submenu)) {
- return
- }
- if (this.visible) {
- this.close()
- }
- }
- close(remove = true) {
- this.clearItems()
- this.visible = false
- if (remove && this.parent) {
- this.parent.removeChild(this)
- this.emit('closed')
- }
- }
- restoreSelection() {
- if (this.selectedBefore.root.select) {
- this.selectedBefore.root.select(this.selectedBefore)
- }
- }
- clearItems() {
- // Abhorrent hack - just erases children from memory. Leaves children
- // thinking they've still got a parent, though. (Necessary to avoid crazy
- // select() loops that probably explode the world... speaking of things
- // to forget, that one time when I was figuring out menus in the queue.
- // This makes them work.)
- this.form.children = this.form.children.filter(
- child => !this.form.inputs.includes(child))
- this.form.inputs = []
- }
- fixLayout() {
- // Do an initial pass to determine the width of this menu (or in particular
- // the form), which is the greatest width of all the inputs.
- let width = 10
- // Some elements resize to fill their parent (the menu)'s width. Since we
- // want to know what their *minimum* width is, we'll immediately change the
- // parent width that they see.
- this.form.w = width
- for (const input of this.form.inputs) {
- input.fixLayout()
- width = Math.max(width, input.w)
- }
- let height = Math.min(14, this.form.inputs.length)
- width += 2 // Space for the pane border
- height += 2 // Space for the pane border
- if (this.form.scrollBarShown) width++
- this.w = width
- this.h = height
- this.fitToParent()
- this.pane.fillParent()
- this.form.fillParent()
- this.form.fixLayout()
- // After everything else, do a second pass to apply the decided width
- // to every element, so that they expand to all be the same width.
- // In order to change the width of a button (which is what these elements
- // are), we need to append space characters.
- for (const input of this.form.inputs) {
- input.fixLayout()
- if (input.text) {
- const inputWidth = ansi.measureColumns(input.text)
- if (inputWidth < this.form.contentW) {
- input.text += ' '.repeat(this.form.contentW - inputWidth)
- }
- }
- }
- }
- selected() {
- this.root.select(this.form)
- }
- }
- class HorizontalRule extends FocusElement {
- // It's just a horizontal rule. Y'know..
- // --------------------------------------------------------------------------
- // You get the idea. :)
- get selectable() {
- // Just return false. A HorizontalRule is technically a FocusElement,
- // but that's just so that it can be used in place of other inputs
- // (e.g. in a ContextMenu).
- return false
- }
- fixLayout() {
- this.w = this.parent.contentW
- this.h = 1
- }
- drawTo(writable) {
- // For the character we draw with, we use an ordinary dash instead of
- // an actual box-drawing horizontal line. That's so that the rule is
- // distinguishable from the edge of a Pane.
- writable.write(ansi.moveCursor(this.absTop, this.absLeft))
- writable.write('-'.repeat(this.w))
- }
- }
- class KeyboardSelector {
- // Class used to select things when you type out their name. Specify strings
- // used to access each element of a form in the keyboardIdentifier property.
- // (Elements without a keyboardIdentifier, or which are !selectable, will be
- // skipped.)
- constructor(form) {
- this.value = ''
- this.form = form
- }
- reset() {
- this.value = ''
- }
- keyPressed(keyBuf) {
- // Don't do anything if the input isn't a single keyboard character.
- if (keyBuf.length !== 1 || keyBuf[0] <= 31 || keyBuf[0] >= 127) {
- return
- }
- // First see if a result is found when we append the typed character to our
- // recorded input.
- const char = keyBuf.toString()
- this.value += char
- if (!KeyboardSelector.find(this.value, this.form)) {
- // If we don't find a result, replace our recorded input with the single
- // character entered, then do a search. Start from the input after the
- // current-selected one, so that we don't just end up re-selecting the
- // element that was selected before, if there's another option that would
- // match this key ahead. (This is so that you can easily type a string or
- // character over and over to navigate through options that all start
- // with the same string/character.)
- this.value = char
- return KeyboardSelector.find(this.value, this.form, this.form.curIndex + 1)
- }
- return true
- }
- static find(text, form, fromIndex = form.curIndex) {
- // Most of this code is just stolen from AppElement's code for handling
- // input from JumpElement!
- const lower = text.toLowerCase()
- const getName = inp => inp.keyboardIdentifier ? inp.keyboardIdentifier.toLowerCase().trim() : ''
- const testStartsWith = inp => getName(inp).startsWith(lower)
- const searchPastCurrentIndex = test => {
- const start = fromIndex
- const match = form.inputs.slice(start).findIndex(test)
- if (match === -1) {
- return -1
- } else {
- return start + match
- }
- }
- const allIndexes = [
- searchPastCurrentIndex(testStartsWith),
- form.inputs.findIndex(testStartsWith),
- ]
- const matchedIndex = allIndexes.find(value => value >= 0)
- if (typeof matchedIndex !== 'undefined') {
- form.selectInput(form.inputs[matchedIndex])
- return true
- } else {
- return false
- }
- }
- }
- class Menubar extends ListScrollForm {
- constructor(showContextMenu) {
- super('horizontal')
- this.showContextMenu = showContextMenu
- this.contextMenu = null
- this.color = 4 // blue
- this.attribute = 2 // dim
- this.keyboardSelector = new KeyboardSelector(this)
- }
- select() {
- // When the menubar is selected from the menubar's context menu, the UI
- // looks like it's "popping" a state, so don't reset the selected index to
- // the start - something we only do when we "newly" select the menubar.
- if (this.contextMenu && this.contextMenu.isSelected) {
- this.root.select(this)
- } else {
- this.selectedBefore = this.root.selectedElement
- this.firstInput()
- }
- this.keyboardSelector.reset()
- }
- keyPressed(keyBuf) {
- super.keyPressed(keyBuf)
- // Don't pause the music from the menubar!
- if (telc.isSpace(keyBuf)) {
- return false
- }
- if (this.keyboardSelector.keyPressed(keyBuf)) {
- return false
- } else if (input.isNextThemeColor(keyBuf)) {
- // For fun :)
- this.color = (this.color === 8 ? 1 : this.color + 1)
- this.emit('color', this.color)
- return false
- } else if (input.isPreviousThemeColor(keyBuf)) {
- this.color = (this.color === 1 ? 8 : this.color - 1)
- this.emit('color', this.color)
- return false
- } else if (telc.isCaselessLetter(keyBuf, 'a')) {
- this.attribute = (this.attribute % 3) + 1
- return false
- }
- }
- restoreSelection() {
- if (this.selectedBefore) {
- this.root.select(this.selectedBefore)
- this.selectedBefore = null
- }
- }
- buildItems(array) {
- for (const {text, menuItems, menuFn} of array) {
- const button = new Button(` ${text} `)
- const container = new FocusElement()
- container.addChild(button)
- button.x = 1
- container.w = button.w + 2
- container.h = 1
- container.selected = () => this.root.select(button)
- container.keyboardIdentifier = text
- button.on('pressed', () => {
- this.contextMenu = this.showContextMenu({
- x: container.absLeft, y: container.absY + 1,
- items: menuFn || menuItems
- })
- this.contextMenu.on('closed', () => {
- this.contextMenu = null
- })
- })
- this.addInput(container)
- }
- }
- fixLayout() {
- this.x = 0
- this.y = 0
- this.w = this.parent.contentW
- this.h = 1
- super.fixLayout()
- }
- drawTo(writable) {
- writable.write(ansi.moveCursor(this.absTop, this.absLeft))
- writable.write(ansi.setAttributes([this.attribute, 30 + this.color, ansi.A_INVERT, ansi.C_WHITE + 10]))
- writable.write(' '.repeat(this.w))
- writable.write(ansi.resetAttributes())
- }
- get color() { return this.getDep('color') }
- set color(v) { this.setDep('color', v) }
- get attribute() { return this.getDep('attribute') }
- set attribute(v) { this.setDep('attribute', v) }
- }
- class PartyBanner extends DisplayElement {
- constructor(direction) {
- super()
- this.direction = direction
- }
- drawTo(writable) {
- writable.write(ansi.moveCursor(this.absTop, this.absLeft))
- // TODO: Figure out how to connect this to the draw dependency system.
- // Currently the party banner doesn't schedule any renders itself (meaning
- // if you have nothing playing or otherwise rendering, it'll just stay
- // still).
- const timerNum = Date.now() / 2000 * this.direction
- let lastAttribute = ''
- const updateAttribute = offsetNum => {
- const attr = (Math.cos(offsetNum - timerNum) < 0 ? '\x1b[0;1m' : '\x1b[0;2m')
- if (attr === lastAttribute) {
- return ''
- } else {
- lastAttribute = attr
- return attr
- }
- }
- let str = new Array(this.w).fill('0').map((_, i) => {
- const offsetNum = i / this.w * 2 * Math.PI
- return (
- updateAttribute(offsetNum) +
- (Math.sin(offsetNum + timerNum) < 0 ? '-' : '*')
- )
- }).join('')
- writable.write(str)
- writable.write(ansi.resetAttributes())
- }
- }
- /*
- class NotesTextEditor extends TuiTextEditor {
- constructor() {
- super()
- this.openedItem = null
- }
- keyPressed(keyBuf) {
- if (input.isDeselectTextEditor(keyBuf)) {
- this.emit('deselect')
- return false
- } else if (input.isSaveTextEditor(keyBuf)) {
- this.saveManually()
- return false
- } else {
- return super.keyPressed(keyBuf)
- }
- }
- async openItem(item, {doubleCheckItem}) {
- if (this.hasBeenEdited) {
- // Save in the background.
- this.save()
- }
- const textFile = getCorrespondingFileForItem(item, '.txt')
- if (!textFile) {
- this.openedItem = null
- return false
- }
- if (textFile === this.openedItem) {
- // This file is already open - don't do anything.
- return null
- }
- let filePath
- try {
- filePath = url.fileURLToPath(new URL(textFile.url))
- } catch (error) {
- this.openedItem = null
- return false
- }
- let buffer
- try {
- buffer = await readFile(filePath)
- } catch (error) {
- this.openedItem = null
- return false
- }
- if (!doubleCheckItem(item)) {
- return null
- }
- this.openedItem = textFile
- this.openedPath = filePath
- this.clearSourceAndLoadText(buffer.toString())
- return true
- }
- async saveManually() {
- if (!this.openedItem || !this.openedPath) {
- return
- }
- const item = this.openedItem
- if (await this.save()) {
- if (item === this.openedItem) {
- this.showStatusMessage('Saved manually.')
- }
- }
- }
- async save() {
- if (!this.openedItem || !this.openedPath) {
- return
- }
- const text = this.getSourceText()
- try {
- await writeFile(this.openedPath, text)
- this.clearEditStatus()
- return true
- } catch (error) {
- this.showStatusMessage(`Failed to save (${path.basename(this.openedPath)}: ${error.code}).`)
- return false
- }
- }
- }
- */
|