[
  {
    "path": ".all-contributorsrc",
    "content": "{\n  \"files\": [\n    \"README.md\"\n  ],\n  \"imageSize\": 100,\n  \"commit\": false,\n  \"contributors\": [\n    {\n      \"login\": \"Rigellute\",\n      \"name\": \"Alexander Keliris\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/12150276?v=4\",\n      \"profile\": \"https://keliris.dev/\",\n      \"contributions\": [\n        \"code\",\n        \"doc\",\n        \"design\",\n        \"blog\",\n        \"ideas\",\n        \"infra\",\n        \"maintenance\",\n        \"platform\",\n        \"review\"\n      ]\n    },\n    {\n      \"login\": \"mikepombal\",\n      \"name\": \"Mickael Marques\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/6864231?v=4\",\n      \"profile\": \"https://github.com/mikepombal\",\n      \"contributions\": [\n        \"financial\"\n      ]\n    },\n    {\n      \"login\": \"HakierGrzonzo\",\n      \"name\": \"Grzegorz Koperwas\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/36668331?v=4\",\n      \"profile\": \"https://github.com/HakierGrzonzo\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"amgassert\",\n      \"name\": \"Austin Gassert\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/22896005?v=4\",\n      \"profile\": \"https://github.com/amgassert\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"calenrobinette\",\n      \"name\": \"Calen Robinette\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/30757528?v=4\",\n      \"profile\": \"https://robinette.dev\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"MCOfficer\",\n      \"name\": \"M*C*O\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/22377202?v=4\",\n      \"profile\": \"https://mcofficer.me\",\n      \"contributions\": [\n        \"infra\"\n      ]\n    },\n    {\n      \"login\": \"eminence\",\n      \"name\": \"Andrew Chin\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/402454?v=4\",\n      \"profile\": \"https://github.com/eminence\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Monkeyanator\",\n      \"name\": \"Sam Naser\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/4377348?v=4\",\n      \"profile\": \"https://www.samnaser.com/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"radogost\",\n      \"name\": \"Micha\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/15713820?v=4\",\n      \"profile\": \"https://github.com/radogost\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"neriglissar\",\n      \"name\": \"neriglissar\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/53038761?v=4\",\n      \"profile\": \"https://github.com/neriglissar\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"TimonPost\",\n      \"name\": \"Timon\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/19969910?v=4\",\n      \"profile\": \"https://github.com/TimonPost\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"echoSayonara\",\n      \"name\": \"echoSayonara\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/54503126?v=4\",\n      \"profile\": \"https://github.com/echoSayonara\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"D-Nice\",\n      \"name\": \"D-Nice\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/2888248?v=4\",\n      \"profile\": \"https://github.com/D-Nice\",\n      \"contributions\": [\n        \"doc\",\n        \"infra\"\n      ]\n    },\n    {\n      \"login\": \"gpawlik\",\n      \"name\": \"Grzegorz Pawlik\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/6296883?v=4\",\n      \"profile\": \"http://gpawlik.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"LennyPenny\",\n      \"name\": \"Lennart Bernhardt\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/4027243?v=4\",\n      \"profile\": \"http://lenny.ninja\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"BlackYoup\",\n      \"name\": \"Arnaud Lefebvre\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/6098160?v=4\",\n      \"profile\": \"https://github.com/BlackYoup\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"tem1029\",\n      \"name\": \"tem1029\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/57712713?v=4\",\n      \"profile\": \"https://github.com/tem1029\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Peterkmoss\",\n      \"name\": \"Peter K. Moss\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/12544579?v=4\",\n      \"profile\": \"http://peter.moss.dk\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"RadicalZephyr\",\n      \"name\": \"Geoff Shannon\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/113102?v=4\",\n      \"profile\": \"http://www.zephyrizing.net/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"zacklukem\",\n      \"name\": \"Zachary Mayhew\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/8787486?v=4\",\n      \"profile\": \"http://zacklukem.info\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"jfaltis\",\n      \"name\": \"jfaltis\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/45465572?v=4\",\n      \"profile\": \"http://jfaltis.de\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Bios-Marcel\",\n      \"name\": \"Marcel Schramm\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/19377618?v=4\",\n      \"profile\": \"https://marcelschr.me\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"fangyi-zhou\",\n      \"name\": \"Fangyi Zhou\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/7815439?v=4\",\n      \"profile\": \"https://github.com/fangyi-zhou\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"synth-ruiner\",\n      \"name\": \"Max\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/8642013?v=4\",\n      \"profile\": \"https://github.com/synth-ruiner\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"svenvNL\",\n      \"name\": \"Sven van der Vlist\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/13982006?v=4\",\n      \"profile\": \"https://github.com/svenvNL\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"jacobchrismarsh\",\n      \"name\": \"jacobchrismarsh\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/15932179?v=4\",\n      \"profile\": \"https://github.com/jacobchrismarsh\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"TheWalkingLeek\",\n      \"name\": \"Nils Rauch\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/36076343?v=4\",\n      \"profile\": \"https://github.com/TheWalkingLeek\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"sputnick1124\",\n      \"name\": \"Nick Stockton\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/8843309?v=4\",\n      \"profile\": \"https://github.com/sputnick1124\",\n      \"contributions\": [\n        \"code\",\n        \"bug\",\n        \"maintenance\",\n        \"question\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"stuarth\",\n      \"name\": \"Stuart Hinson\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/7055?v=4\",\n      \"profile\": \"http://stuarth.github.io\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"samcal\",\n      \"name\": \"Sam Calvert\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/2117940?v=4\",\n      \"profile\": \"https://github.com/samcal\",\n      \"contributions\": [\n        \"code\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"jwijenbergh\",\n      \"name\": \"Jeroen Wijenbergh\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/46386452?v=4\",\n      \"profile\": \"https://github.com/jwijenbergh\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"KimberleyCook\",\n      \"name\": \"Kimberley Cook\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/2683270?v=4\",\n      \"profile\": \"https://twitter.com/KimberleyCook91\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"baxtea\",\n      \"name\": \"Audrey Baxter\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/22502477?v=4\",\n      \"profile\": \"https://github.com/baxtea\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"nkoehring\",\n      \"name\": \"Norman\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/246402?v=4\",\n      \"profile\": \"https://koehr.in\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"blackwolf12333\",\n      \"name\": \"Peter Maatman\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/1572975?v=4\",\n      \"profile\": \"https://github.com/blackwolf12333\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"AlexandreSi\",\n      \"name\": \"AlexandreS\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/32449369?v=4\",\n      \"profile\": \"https://github.com/AlexandreSi\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"fiinnnn\",\n      \"name\": \"Finn Vos\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/5011796?v=4\",\n      \"profile\": \"https://github.com/fiinnnn\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"hurricanehrndz\",\n      \"name\": \"Carlos Hernandez\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/5804237?v=4\",\n      \"profile\": \"https://github.com/hurricanehrndz\",\n      \"contributions\": [\n        \"platform\"\n      ]\n    },\n    {\n      \"login\": \"pedrohva\",\n      \"name\": \"Pedro Alves\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/33297928?v=4\",\n      \"profile\": \"https://github.com/pedrohva\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"jtagcat\",\n      \"name\": \"jtagcat\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/38327267?v=4\",\n      \"profile\": \"https://gitlab.com/jtagcat/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"BKitor\",\n      \"name\": \"Benjamin Kitor\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/16880850?v=4\",\n      \"profile\": \"https://github.com/BKitor\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"littleli\",\n      \"name\": \"Aleš Najmann\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/544082?v=4\",\n      \"profile\": \"https://ales.rocks\",\n      \"contributions\": [\n        \"doc\",\n        \"platform\"\n      ]\n    },\n    {\n      \"login\": \"jeremystucki\",\n      \"name\": \"Jeremy Stucki\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/7629727?v=4\",\n      \"profile\": \"https://github.com/jeremystucki\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"pt2121\",\n      \"name\": \"(´⌣`ʃƪ)\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/616399?v=4\",\n      \"profile\": \"http://pt2121.github.io\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"tim77\",\n      \"name\": \"Artem Polishchuk\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/5614476?v=4\",\n      \"profile\": \"https://github.com/tim77\",\n      \"contributions\": [\n        \"platform\"\n      ]\n    },\n    {\n      \"login\": \"slumber\",\n      \"name\": \"Chris Sosnin\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/48099298?v=4\",\n      \"profile\": \"https://github.com/slumber\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"bwbuhse\",\n      \"name\": \"Ben Buhse\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/21225303?v=4\",\n      \"profile\": \"http://www.benbuhse.com\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"ilnaes\",\n      \"name\": \"Sean Li\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/20805499?v=4\",\n      \"profile\": \"https://github.com/ilnaes\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"TimotheeGerber\",\n      \"name\": \"TimotheeGerber\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/37541513?v=4\",\n      \"profile\": \"https://github.com/TimotheeGerber\",\n      \"contributions\": [\n        \"code\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"fratajczak\",\n      \"name\": \"Ferdinand Ratajczak\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/33835579?v=4\",\n      \"profile\": \"https://github.com/fratajczak\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"sheelc\",\n      \"name\": \"Sheel Choksi\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/1355710?v=4\",\n      \"profile\": \"https://github.com/sheelc\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"mhellwig\",\n      \"name\": \"Michael Hellwig\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/414112?v=4\",\n      \"profile\": \"http://fnanp.in-ulm.de/microblog/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"oliver-daniel\",\n      \"name\": \"Oliver Daniel\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/17235417?v=4\",\n      \"profile\": \"https://github.com/oliver-daniel\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Drewsapple\",\n      \"name\": \"Drew Fisher\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/4532572?v=4\",\n      \"profile\": \"https://github.com/Drewsapple\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"ncoder-1\",\n      \"name\": \"ncoder-1\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/7622286?v=4\",\n      \"profile\": \"https://github.com/ncoder-1\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"macguirerintoul\",\n      \"name\": \"Macguire Rintoul\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/18323154?v=4\",\n      \"profile\": \"http://macguire.me\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"RicardoHE97\",\n      \"name\": \"Ricardo Holguin\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/28399979?v=4\",\n      \"profile\": \"http://ricardohe97.github.io\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"ksk001100\",\n      \"name\": \"Keisuke Toyota\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/13160198?v=4\",\n      \"profile\": \"https://ksk.netlify.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"jackson15j\",\n      \"name\": \"Craig Astill\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/3226988?v=4\",\n      \"profile\": \"https://jackson15j.github.io\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"onielfa\",\n      \"name\": \"Onielfa\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/4358172?v=4\",\n      \"profile\": \"https://github.com/onielfa\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"usrme\",\n      \"name\": \"usrme\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/5902545?v=4\",\n      \"profile\": \"https://usrme.xyz\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"murlakatamenka\",\n      \"name\": \"Sergey A.\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/7361274?v=4\",\n      \"profile\": \"https://github.com/murlakatamenka\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"elcih17\",\n      \"name\": \"Hideyuki Okada\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/17084445?v=4\",\n      \"profile\": \"https://github.com/elcih17\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"kepae\",\n      \"name\": \"kepae\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/4238598?v=4\",\n      \"profile\": \"https://github.com/kepae\",\n      \"contributions\": [\n        \"code\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"ericonr\",\n      \"name\": \"Érico Nogueira Rolim\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/34201958?v=4\",\n      \"profile\": \"https://github.com/ericonr\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"BeneCollyridam\",\n      \"name\": \"Alexander Meinhardt Scheurer\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/15802915?v=4\",\n      \"profile\": \"https://github.com/BeneCollyridam\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Toaster192\",\n      \"name\": \"Ondřej Kinšt\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/14369229?v=4\",\n      \"profile\": \"https://github.com/Toaster192\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Kryan90\",\n      \"name\": \"Kryan90\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/18740821?v=4\",\n      \"profile\": \"https://github.com/Kryan90\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"n-ivanov\",\n      \"name\": \"n-ivanov\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/11470871?v=4\",\n      \"profile\": \"https://github.com/n-ivanov\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"bi1yeu\",\n      \"name\": \"bi1yeu\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/1185129?v=4\",\n      \"profile\": \"http://matthewbilyeu.com/resume/\",\n      \"contributions\": [\n        \"code\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Utagai\",\n      \"name\": \"May\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/10730394?v=4\",\n      \"profile\": \"https://github.com/Utagai\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"mucinoab\",\n      \"name\": \"Bruno A. Muciño\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/28630268?v=4\",\n      \"profile\": \"https://mucinoab.github.io/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"OrangeFran\",\n      \"name\": \"Finn Hediger\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/55061632?v=4\",\n      \"profile\": \"https://github.com/OrangeFran\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"dp304\",\n      \"name\": \"dp304\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/34493835?v=4\",\n      \"profile\": \"https://github.com/dp304\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"marcomicera\",\n      \"name\": \"Marco Micera\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/13918587?v=4\",\n      \"profile\": \"http://marcomicera.github.io\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"MarcoIeni\",\n      \"name\": \"Marco Ieni\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/11428655?v=4\",\n      \"profile\": \"http://marcoieni.com\",\n      \"contributions\": [\n        \"infra\"\n      ]\n    },\n    {\n      \"login\": \"ArturKovacs\",\n      \"name\": \"Artúr Kovács\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/8320264?v=4\",\n      \"profile\": \"https://github.com/ArturKovacs\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"aokellermann\",\n      \"name\": \"Antony Kellermann\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/26678747?v=4\",\n      \"profile\": \"https://github.com/aokellermann\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"rasmuspeders1\",\n      \"name\": \"Rasmus Pedersen\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1898960?v=4\",\n      \"profile\": \"https://github.com/rasmuspeders1\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"noir-Z\",\n      \"name\": \"noir-Z\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/45096516?v=4\",\n      \"profile\": \"https://github.com/noir-Z\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"davidbailey00\",\n      \"name\": \"David Bailey\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/4248177?v=4\",\n      \"profile\": \"https://davidbailey.codes/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"sheepwall\",\n      \"name\": \"sheepwall\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/22132993?v=4\",\n      \"profile\": \"https://github.com/sheepwall\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Hwatwasthat\",\n      \"name\": \"Hwatwasthat\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/29790143?v=4\",\n      \"profile\": \"https://github.com/Hwatwasthat\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Jesse-Bakker\",\n      \"name\": \"Jesse\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/22473248?v=4\",\n      \"profile\": \"https://github.com/Jesse-Bakker\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"hantatsang\",\n      \"name\": \"Sang\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/11912225?v=4\",\n      \"profile\": \"https://github.com/hantatsang\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"yktakaha4\",\n      \"name\": \"Yuuki Takahashi\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/20282867?v=4\",\n      \"profile\": \"https://yktakaha4.github.io/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"alejandro-angulo\",\n      \"name\": \"Alejandro Angulo\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/5242883?v=4\",\n      \"profile\": \"https://alejandr0angul0.dev/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"masguit42\",\n      \"name\": \"Anton Kostin\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/11005780?v=4\",\n      \"profile\": \"http://t.me/lego1as\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"JSextonn\",\n      \"name\": \"Justin Sexton\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/20236003?v=4\",\n      \"profile\": \"https://justinsexton.net\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"lejiati\",\n      \"name\": \"Jiati Le\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6442124?v=4\",\n      \"profile\": \"https://github.com/lejiati\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"cobbinma\",\n      \"name\": \"Matthew Cobbing\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/578718?v=4\",\n      \"profile\": \"https://github.com/cobbinma\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Milo123459\",\n      \"name\": \"Milo\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/50248166?v=4\",\n      \"profile\": \"https://milo123459.vercel.app\",\n      \"contributions\": [\n        \"infra\"\n      ]\n    },\n    {\n      \"login\": \"diegov\",\n      \"name\": \"Diego Veralli\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/297206?v=4\",\n      \"profile\": \"https://www.diegoveralli.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"majabojarska\",\n      \"name\": \"Maja Bojarska\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/33836570?v=4\",\n      \"profile\": \"https://github.com/majabojarska\",\n      \"contributions\": [\n        \"code\"\n      ]\n    }\n  ],\n  \"contributorsPerLine\": 7,\n  \"projectName\": \"spotify-tui\",\n  \"projectOwner\": \"Rigellute\",\n  \"repoType\": \"github\",\n  \"repoHost\": \"https://github.com\",\n  \"skipCi\": true\n}\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: Rigellute\npatreon: rigellute\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"cargo\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\""
  },
  {
    "path": ".github/workflows/cd.yml",
    "content": "name: Continuous Deployment\n\non:\n  push:\n    tags:\n      - \"v*.*.*\"\n\njobs:\n  publish:\n    name: Publishing for ${{ matrix.os }}\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [macos-latest, ubuntu-latest, windows-latest]\n        rust: [stable]\n        include:\n          - os: macos-latest\n            artifact_prefix: macos\n            target: x86_64-apple-darwin\n            binary_postfix: \"\"\n          - os: ubuntu-latest\n            artifact_prefix: linux\n            target: x86_64-unknown-linux-gnu\n            binary_postfix: \"\"\n          - os: windows-latest\n            artifact_prefix: windows\n            target: x86_64-pc-windows-msvc\n            binary_postfix: \".exe\"\n\n    steps:\n      - name: Installing Rust toolchain\n        uses: actions-rs/toolchain@v1\n        with:\n          toolchain: ${{ matrix.rust }}\n          override: true\n      - name: Installing needed macOS dependencies\n        if: matrix.os == 'macos-latest'\n        run: brew install openssl@1.1\n      - name: Installing needed Ubuntu dependencies\n        if: matrix.os == 'ubuntu-latest'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev\n      - name: Checking out sources\n        uses: actions/checkout@v1\n      - name: Running cargo build\n        uses: actions-rs/cargo@v1\n        with:\n          command: build\n          toolchain: ${{ matrix.rust }}\n          args: --release --target ${{ matrix.target }}\n\n      - name: Packaging final binary\n        shell: bash\n        run: |\n          cd target/${{ matrix.target }}/release\n\n          BINARY_NAME=spt${{ matrix.binary_postfix }}\n          strip $BINARY_NAME\n\n          RELEASE_NAME=spotify-tui-${{ matrix.artifact_prefix }}\n          tar czvf $RELEASE_NAME.tar.gz $BINARY_NAME\n\n          if [[ ${{ runner.os }} == 'Windows' ]]; then\n            certutil -hashfile $RELEASE_NAME.tar.gz sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.sha256\n          else\n            shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.sha256\n          fi\n      - name: Releasing assets\n        uses: softprops/action-gh-release@v1\n        with:\n          files: |\n            target/${{ matrix.target }}/release/spotify-tui-${{ matrix.artifact_prefix }}.tar.gz\n            target/${{ matrix.target }}/release/spotify-tui-${{ matrix.artifact_prefix }}.sha256\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n  publish-cargo:\n    name: Publishing to Cargo\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@master\n      - uses: actions-rs/toolchain@v1\n        with:\n          toolchain: stable\n          override: true\n      - run: |\n          sudo apt-get update\n          sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev\n      - uses: actions-rs/cargo@v1\n        with:\n          command: publish\n          args: --token ${{ secrets.CARGO_API_KEY }} --allow-dirty\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "on:\n  pull_request:\n  push:\n    branches: master\n\nname: Continuous Integration\n\njobs:\n  # Workaround for making Github Actions skip based on commit message `[skip ci]`\n  # Source https://gist.github.com/ybiquitous/c80f15c18319c63cae8447a3be341267\n  prepare:\n    runs-on: ubuntu-latest\n    if: |\n      !contains(format('{0} {1} {2}', github.event.head_commit.message, github.event.pull_request.title, github.event.pull_request.body), '[skip ci]')\n    steps:\n      - run: |\n          cat <<'MESSAGE'\n          github.event_name: ${{ toJson(github.event_name) }}\n          github.event:\n          ${{ toJson(github.event) }}\n          MESSAGE\n\n  check:\n    name: Check\n    runs-on: ubuntu-latest\n    needs: prepare\n    steps:\n      - uses: actions/checkout@master\n      - uses: actions-rs/toolchain@v1\n        with:\n          toolchain: stable\n          profile: minimal\n          override: true\n      - uses: actions-rs/cargo@v1\n        with:\n          command: check\n\n  test:\n    name: Test Suite\n    runs-on: ubuntu-latest\n    needs: prepare\n    steps:\n      - uses: actions/checkout@master\n      - uses: actions-rs/toolchain@v1\n        with:\n          toolchain: stable\n          profile: minimal\n          override: true\n      # These dependencies are required for `clipboard`\n      - run: sudo apt-get install -y -qq libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev\n      - uses: Swatinem/rust-cache@v1\n      - uses: actions-rs/cargo@v1\n        with:\n          command: test\n\n  fmt:\n    name: Rustfmt\n    runs-on: ubuntu-latest\n    needs: prepare\n    steps:\n      - uses: actions/checkout@master\n      - uses: actions-rs/toolchain@v1\n        with:\n          toolchain: stable\n          profile: minimal\n          override: true\n          components: rustfmt\n      - uses: Swatinem/rust-cache@v1\n      - uses: actions-rs/cargo@v1\n        with:\n          command: fmt\n          args: --all -- --check\n\n  clippy:\n    name: Clippy\n    runs-on: ubuntu-latest\n    needs: prepare\n    steps:\n      - uses: actions/checkout@master\n      - uses: actions-rs/toolchain@v1\n        with:\n          toolchain: stable\n          profile: minimal\n          override: true\n          components: clippy\n      - uses: Swatinem/rust-cache@v1\n      - uses: actions-rs/cargo@v1\n        with:\n          command: clippy\n          args: -- -D warnings\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\n**/*.rs.bk\n\n.DS_store\n.env\n.spotify_token_cache.json \n.cached_device_id.txt\nspotify-tui.sketch\n\nspt*.txt\n*.snap\n.ci/lp-creds\nsnapcraft-login\nsecrets.tar\n\n*.swp\ntags\n\n.idea"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## [Unreleased]\n\n- Fix confirmation dialog handling on playlist delete [#910](https://github.com/Rigellute/spotify-tui/pull/910)\n\n### Added\n\n- Show `album_type` in Search panes [#868](https://github.com/Rigellute/spotify-tui/pull/868)\n- Add option to set window title to \"spt - Spotify TUI\" on startup [#844](https://github.com/Rigellute/spotify-tui/pull/844)\n\n## [0.25.0] - 2021-08-24\n\n### Fixed\n\n- Fixed rate limiting issue [#852](https://github.com/Rigellute/spotify-tui/pull/852)\n- Fix double navigation to same route [#826](https://github.com/Rigellute/spotify-tui/pull/826)\n\n## [0.24.0] - 2021-04-26\n\n### Fixed\n\n- Handle invalid Client ID/Secret [#668](https://github.com/Rigellute/spotify-tui/pull/668)\n- Fix default liked, shuffle, etc. icons to be more recognizable symbols [#702](https://github.com/Rigellute/spotify-tui/pull/702)\n- Replace black and white default colors with reset [#742](https://github.com/Rigellute/spotify-tui/pull/742)\n\n### Added\n\n- Add ability to seek from the CLI [#692](https://github.com/Rigellute/spotify-tui/pull/692)\n- Replace `clipboard` with `arboard` [#691](https://github.com/Rigellute/spotify-tui/pull/691)\n- Implement some episode table functions [#698](https://github.com/Rigellute/spotify-tui/pull/698)\n- Change `--like` that toggled the liked-state to explicit `--like` and `--dislike` flags [#717](https://github.com/Rigellute/spotify-tui/pull/717)\n- Add to config: `enforce_wide_search_bar` to make search bar bigger [#738](https://github.com/Rigellute/spotify-tui/pull/738)\n- Add Daily Drive to Made For You lists search [#743](https://github.com/Rigellute/spotify-tui/pull/743)\n\n## [0.23.0] - 2021-01-06\n\n### Fixed\n\n- Fix app crash when pressing Enter before a screen has loaded [#599](https://github.com/Rigellute/spotify-tui/pull/599)\n- Make layout more responsive to large/small screens [#502](https://github.com/Rigellute/spotify-tui/pull/502)\n- Fix use of incorrect playlist index when playing from an associated track table [#632](https://github.com/Rigellute/spotify-tui/pull/632)\n- Fix flickering help menu in small screens [#638](https://github.com/Rigellute/spotify-tui/pull/638)\n- Optimize seek [#640](https://github.com/Rigellute/spotify-tui/pull/640)\n- Fix centering of basic_view [#664](https://github.com/Rigellute/spotify-tui/pull/664)\n\n### Added\n\n- Implement next/previous page behavior for the Artists table [#604](https://github.com/Rigellute/spotify-tui/pull/604)\n- Show saved albums when getting an artist [#612](https://github.com/Rigellute/spotify-tui/pull/612)\n- Transfer playback when changing device [#408](https://github.com/Rigellute/spotify-tui/pull/408)\n- Search using Spotify share URLs and URIs like the desktop client [#623](https://github.com/Rigellute/spotify-tui/pull/623)\n- Make the liked icon configurable [#659](https://github.com/Rigellute/spotify-tui/pull/659)\n- Add CLI for controlling Spotify [#645](https://github.com/Rigellute/spotify-tui/pull/645)\n- Implement Podcasts Library page [#650](https://github.com/Rigellute/spotify-tui/pull/650)\n\n## [0.22.0] - 2020-10-05\n\n### Fixed\n\n- Show ♥ next to album name in saved list [#540](https://github.com/Rigellute/spotify-tui/pull/540)\n- Fix to be able to follow an artist in search result view [#565](https://github.com/Rigellute/spotify-tui/pull/565)\n- Don't add analysis view to stack if already in it [#580](https://github.com/Rigellute/spotify-tui/pull/580)\n\n### Added\n\n- Add additional line of help to show that 'w' can be used to save/like an album [#548](https://github.com/Rigellute/spotify-tui/pull/548)\n- Add handling Home and End buttons in user input [#550](https://github.com/Rigellute/spotify-tui/pull/550)\n- Add `playbar_progress_text` to user config and upgrade tui lib [#564](https://github.com/Rigellute/spotify-tui/pull/564)\n- Add basic playbar support for podcasts [#563](https://github.com/Rigellute/spotify-tui/pull/563)\n- Add 'enable_text_emphasis' behavior config option [#573](https://github.com/Rigellute/spotify-tui/pull/573)\n- Add next/prev page, jump to start/end to user config [#566](https://github.com/Rigellute/spotify-tui/pull/566)\n- Add possibility to queue a song [#567](https://github.com/Rigellute/spotify-tui/pull/567)\n- Add user-configurable header styling [#583](https://github.com/Rigellute/spotify-tui/pull/583)\n- Show active keybindings in Help [#585](https://github.com/Rigellute/spotify-tui/pull/585)\n- Full Podcast support [#581](https://github.com/Rigellute/spotify-tui/pull/581)\n\n## [0.21.0] - 2020-07-24\n\n### Fixed\n\n- Fix typo in help menu [#485](https://github.com/Rigellute/spotify-tui/pull/485)\n\n### Added\n\n- Add save album on album view [#506](https://github.com/Rigellute/spotify-tui/pull/506)\n- Add feature to like a song from basic view [#507](https://github.com/Rigellute/spotify-tui/pull/507)\n- Enable Unix and Linux shortcut keys in the input [#511](https://github.com/Rigellute/spotify-tui/pull/511)\n- Add album artist field to full album view [#519](https://github.com/Rigellute/spotify-tui/pull/519)\n- Handle track saving in non-album contexts (eg. playlist/Made for you). [#525](https://github.com/Rigellute/spotify-tui/pull/525)\n\n## [0.20.0] - 2020-05-28\n\n### Fixed\n\n- Move pagination instructions to top of help menu [#442](https://github.com/Rigellute/spotify-tui/pull/442)\n\n### Added\n\n- Add user configuration toggle for the loading indicator [#447](https://github.com/Rigellute/spotify-tui/pull/447)\n- Add support for saving an album and following an artist in artist view [#445](https://github.com/Rigellute/spotify-tui/pull/445)\n- Use the `▶` glyph to indicate the currently playing song [#472](https://github.com/Rigellute/spotify-tui/pull/472)\n- Jump to play context (if available) - default binding is `o` [#474](https://github.com/Rigellute/spotify-tui/pull/474)\n\n## [0.19.0] - 2020-05-04\n\n### Fixed\n\n- Fix re-authentication [#415](https://github.com/Rigellute/spotify-tui/pull/415)\n- Fix audio analysis feature [#435](https://github.com/Rigellute/spotify-tui/pull/435)\n\n### Added\n\n- Add more readline shortcuts to the search input [#425](https://github.com/Rigellute/spotify-tui/pull/425)\n\n## [0.18.0] - 2020-04-21\n\n### Fixed\n\n- Fix crash when opening playlist [#398](https://github.com/Rigellute/spotify-tui/pull/398)\n- Fix crash when there are no artists avaliable [#388](https://github.com/Rigellute/spotify-tui/pull/388)\n- Correctly handle playlist unfollowing [#399](https://github.com/Rigellute/spotify-tui/pull/399)\n\n### Added\n\n- Allow specifying alternative config file path [#391](https://github.com/Rigellute/spotify-tui/pull/391)\n- List artists names in the album view [#393](https://github.com/Rigellute/spotify-tui/pull/393)\n- Add confirmation modal for delete playlist action [#402](https://github.com/Rigellute/spotify-tui/pull/402)\n\n## [0.17.1] - 2020-03-30\n\n### Fixed\n\n- Artist name in songs block [#365](https://github.com/Rigellute/spotify-tui/pull/365) (fixes regression)\n- Add basic view key binding to help menu\n\n## [0.17.0] - 2020-03-20\n\n### Added\n\n- Show if search results are liked/followed [#342](https://github.com/Rigellute/spotify-tui/pull/342)\n- Show currently playing track in song search menu and play through the searched tracks [#343](https://github.com/Rigellute/spotify-tui/pull/343)\n- Add a \"basic view\" that only shows the playbar. Press `B` to get there [#344](https://github.com/Rigellute/spotify-tui/pull/344)\n- Show currently playing top track [#347](https://github.com/Rigellute/spotify-tui/pull/347)\n- Press shift-s (`S`) to pick a random song on track-lists [#339](https://github.com/Rigellute/spotify-tui/pull/339)\n\n### Fixed\n\n- Prevent search when there is no input [#351](https://github.com/Rigellute/spotify-tui/pull/351)\n\n## [0.16.0] - 2020-03-12\n\n### Fixed\n\n- Fix empty UI when pressing escape in the device and analysis views [#315](https://github.com/Rigellute/spotify-tui/pull/315)\n- Fix slow and frozen UI by implementing an asynchronous runtime for network events [#322](https://github.com/Rigellute/spotify-tui/pull/322). This fixes issues #24, #92, #207 and #218. Read more [here](https://keliris.dev/improving-spotify-tui/).\n\n## [0.15.0] - 2020-02-24\n\n- Add experimental audio visualizer (press `v` to navigate to it). The feature uses the audio analysis data from Spotify and animates the pitch information.\n- Display Artist layout when searching an artist url [#298](https://github.com/Rigellute/spotify-tui/pull/298)\n- Add pagination to the help menu [#302](https://github.com/Rigellute/spotify-tui/pull/302)\n\n## [0.14.0] - 2020-02-11\n\n### Added\n\n- Add high-middle-low navigation (`H`, `M`, `L` respectively) for jumping around lists [#234](https://github.com/Rigellute/spotify-tui/pull/234).\n- Play every known song with `e` [#228](https://github.com/Rigellute/spotify-tui/pull/228)\n- Search album by url: paste a spotify album link into the search input to go to that album [#281](https://github.com/Rigellute/spotify-tui/pull/281)\n- Implement 'Made For You' section of Library [#278](https://github.com/Rigellute/spotify-tui/pull/278)\n- Add user theme configuration [#284](https://github.com/Rigellute/spotify-tui/pull/284)\n- Allow user to define the volume increment [#288](https://github.com/Rigellute/spotify-tui/pull/288)\n\n### Fixed\n\n- Fix crash on small terminals [#231](https://github.com/Rigellute/spotify-tui/pull/231)\n\n## [0.13.0] - 2020-01-26\n\n### Fixed\n\n- Don't error if failed to open clipboard [#217](https://github.com/Rigellute/spotify-tui/pull/217)\n- Fix scrolling beyond the end of pagination. [#216](https://github.com/Rigellute/spotify-tui/pull/216)\n- Add copy album url functionality [#226](https://github.com/Rigellute/spotify-tui/pull/226)\n\n### Added\n\n- Allow user to configure the port for the Spotify auth Redirect URI [#204](https://github.com/Rigellute/spotify-tui/pull/204)\n- Add play recommendations for song/artist on pressing 'r' [#200](https://github.com/Rigellute/spotify-tui/pull/200)\n- Added continuous deployment for Windows [#222](https://github.com/Rigellute/spotify-tui/pull/222)\n\n### Changed\n\n- Change behavior of previous button (`p`) to mimic behavior in official Spotify client. When the track is more than three seconds in, pressing previous will restart the track. When less than three seconds it will jump to previous. [#219](https://github.com/Rigellute/spotify-tui/pull/219)\n\n## [0.12.0] - 2020-01-23\n\n### Added\n\n- Add Windows support [#99](https://github.com/Rigellute/spotify-tui/pull/99)\n- Add support for Related artists and top tacks [#191](https://github.com/Rigellute/spotify-tui/pull/191)\n\n## [0.11.0] - 2019-12-23\n\n### Added\n\n- Add support for adding an album and following a playlist. NOTE: that this will require the user to grant more permissions [#172](https://github.com/Rigellute/spotify-tui/pull/172)\n- Add shortcuts to jump to the start or the end of a playlist [#167](https://github.com/Rigellute/spotify-tui/pull/167)\n- Make seeking amount configurable [#168](https://github.com/Rigellute/spotify-tui/pull/168)\n\n### Fixed\n\n- Fix playlist index after search [#177](https://github.com/Rigellute/spotify-tui/pull/177)\n- Fix cursor offset in search input [#183](https://github.com/Rigellute/spotify-tui/pull/183)\n\n### Changed\n\n- Remove focus on input when jumping back [#184](https://github.com/Rigellute/spotify-tui/pull/184)\n- Pad strings in status bar to prevent reformatting [#188](https://github.com/Rigellute/spotify-tui/pull/188)\n\n## [0.10.0] - 2019-11-30\n\n### Added\n\n- Added pagination to user playlists [#150](https://github.com/Rigellute/spotify-tui/pull/150)\n- Add ability to delete a saved album (hover over the album you wish to delete and press `D`) [#152](https://github.com/Rigellute/spotify-tui/pull/152)\n- Add support for following/unfollowing artists [#155](https://github.com/Rigellute/spotify-tui/pull/155)\n- Add hotkey to copy url of currently playing track (default binding is `c`)[#156](https://github.com/Rigellute/spotify-tui/pull/156)\n\n### Fixed\n\n- Refine Spotify result limits, which should fit your current terminal size. Most notably this will increase the number of results from a search [#154](https://github.com/Rigellute/spotify-tui/pull/154)\n- Navigation from \"Liked Songs\" [#151](https://github.com/Rigellute/spotify-tui/pull/151)\n- App hang upon trying to authenticate with Spotify on FreeBSD [#148](https://github.com/Rigellute/spotify-tui/pull/148)\n- Showing \"Release Date\" in saved albums table [#162](https://github.com/Rigellute/spotify-tui/pull/162)\n- Showing \"Length\" in library -> recently played table [#164](https://github.com/Rigellute/spotify-tui/pull/164)\n- Typo: \"AlbumTracks\" -> \"Albums\" [#165](https://github.com/Rigellute/spotify-tui/pull/165)\n- Janky volume control [#166](https://github.com/Rigellute/spotify-tui/pull/166)\n- Volume bug that would prevent volumes of 0 and 100 [#170](https://github.com/Rigellute/spotify-tui/pull/170)\n- Playing a wrong track in playlist [#173](https://github.com/Rigellute/spotify-tui/pull/173)\n\n## [0.9.0] - 2019-11-13\n\n### Added\n\n- Add custom keybindings feature. Check the README for an example configuration [#112](https://github.com/Rigellute/spotify-tui/pull/112)\n\n### Fixed\n\n- Fix panic when seeking beyond track boundaries [#124](https://github.com/Rigellute/spotify-tui/pull/124)\n- Add scrolling to library album list. Can now use `ctrl+d/u` to scroll between result pages [#128](https://github.com/Rigellute/spotify-tui/pull/128)\n- Fix showing wrong album in library album view - [#130](https://github.com/Rigellute/spotify-tui/pull/130)\n- Fix scrolling in table views [#135](https://github.com/Rigellute/spotify-tui/pull/135)\n- Use space more efficiently in small terminals [#143](https://github.com/Rigellute/spotify-tui/pull/143)\n\n## [0.8.0] - 2019-10-29\n\n### Added\n\n- Improve onboarding: auto fill the redirect url [#98](https://github.com/Rigellute/spotify-tui/pull/98)\n- Indicate if a track is \"liked\" in Recently Played, Album tracks and song list views using \"♥\" - [#103](https://github.com/Rigellute/spotify-tui/pull/103)\n- Add ability to toggle the saved state of a track: pressing `s` on an already saved track will unsave it. [#104](https://github.com/Rigellute/spotify-tui/pull/104)\n- Add collaborative playlists scope. You'll need to reauthenticate due to this change. [#115](https://github.com/Rigellute/spotify-tui/pull/115)\n- Add Ctrl-f and Ctrl-b emacs style keybindings for left and right motion. [#114](https://github.com/Rigellute/spotify-tui/pull/114)\n\n### Fixed\n\n- Fix app crash when pressing `enter`, `q` and then `down`. [#109](https://github.com/Rigellute/spotify-tui/pull/109)\n- Fix trying to save a track in the album view [#119](https://github.com/Rigellute/spotify-tui/pull/119)\n- Fix UI saved indicator when toggling saved track [#119](https://github.com/Rigellute/spotify-tui/pull/119)\n\n## [0.7.0] - 2019-10-20\n\n- Implement library \"Artists\" view - [#67](https://github.com/Rigellute/spotify-tui/pull/67) thanks [@svenvNL](https://github.com/svenvNL). NOTE that this adds an additional scope (`user-follow-read`), so you'll be prompted to grant this new permissions when you upgrade.\n- Fix searching with non-english characters - [#30](https://github.com/Rigellute/spotify-tui/pull/30). Thanks to [@fangyi-zhou](https://github.com/fangyi-zhou)\n- Remove hardcoded country (was always set to UK). We now fetch the user to get their country. [#68](https://github.com/Rigellute/spotify-tui/pull/68). Thanks to [@svenvNL](https://github.com/svenvNL)\n- Save currently playing track - the playbar is now selectable/hoverable [#80](https://github.com/Rigellute/spotify-tui/pull/80)\n- Lay foundation for showing if a track is saved. You can now see if the currently playing track is saved (indicated by ♥)\n\n## [0.6.0] - 2019-10-14\n\n### Added\n\n- Start a web server on localhost to display a simple webpage for the Redirect URI. Should hopefully improve the onboarding experience.\n- Add ability to skip to tracks using `n` for next and `p` for previous - thanks to [@samcal](https://github.com/samcal)\n- Implement seek functionality - you can now use `<` to seek backwards 5 seconds and `>` to go forwards 5 seconds\n- The event `A` will jump to the album list of the first artist in the track's artists list - closing [#45](https://github.com/Rigellute/spotify-tui/issues/45)\n- Add volume controls - use `-` to decrease and `+` to increase volume in 10% increments. Closes [#57](https://github.com/Rigellute/spotify-tui/issues/57)\n\n### Fixed\n\n- Keep format of highlighted track when it is playing - [#44](https://github.com/Rigellute/spotify-tui/pull/44) thanks to [@jfaltis](https://github.com/jfaltis)\n- Search input bug: Fix \"out-of-bounds\" crash when pressing left too many times [#63](https://github.com/Rigellute/spotify-tui/issues/63)\n- Search input bug: Fix issue that backspace always deleted the end of input, not where the cursor was [#33](https://github.com/Rigellute/spotify-tui/issues/33)\n\n## [0.5.0] - 2019-10-11\n\n### Added\n\n- Add `Ctrl-r` to cycle repeat mode ([@baxtea](https://github.com/baxtea))\n- Refresh token when token expires ([@fangyi-zhou](https://github.com/fangyi-zhou))\n- Upgrade `rspotify` to fix [#39](https://github.com/Rigellute/spotify-tui/issues/39) ([@epwalsh](https://github.com/epwalsh))\n\n### Changed\n\n- Fix duplicate albums showing in artist discographies ([@baxtea](https://github.com/baxtea))\n- Slightly better error message with some debug tips when tracks fail to play\n\n## [0.4.0] - 2019-10-05\n\n### Added\n\n- Can now install `spotify-tui` using `brew reinstall Rigellute/tap/spotify-tui` and `cargo install spotify-tui` 🎉\n- Credentials (auth token, chosen device, and CLIENT_ID & CLIENT_SECRET) are now all stored in the same place (`${HOME}/.config/spotify-tui/client.yml`), which closes [this issue](https://github.com/Rigellute/spotify-tui/issues/4)\n\n## [0.3.0] - 2019-10-04\n\n### Added\n\n- Improved onboarding experience\n- On first startup instructions will (hopefully) guide the user on how to get setup\n\n## [0.2.0] - 2019-09-17\n\n### Added\n\n- General navigation improvements\n- Improved search input: it should now behave how one would expect\n- Add `Ctrl-d/u` for scrolling up and down through result pages (currently only implemented for \"Liked Songs\")\n- Minor theme improvements\n- Make tables responsive\n- Implement resume playback feature\n- Add saved albums table\n- Show which track is currently playing within a table or list\n- Add `a` event to jump to currently playing track's album\n- Add `s` event to save a track from within the \"Recently Played\" view (eventually this should be everywhere)\n- Add `Ctrl-s` to toggle shuffle\n- Add the following journey: search -> select artist and see their albums -> select album -> go to album and play tracks\n\n# What is this?\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"spotify-tui\"\ndescription = \"A terminal user interface for Spotify\"\nhomepage = \"https://github.com/Rigellute/spotify-tui\"\ndocumentation = \"https://github.com/Rigellute/spotify-tui\"\nrepository = \"https://github.com/Rigellute/spotify-tui\"\nkeywords = [\"spotify\", \"tui\", \"cli\", \"terminal\"]\ncategories = [\"command-line-utilities\"]\nversion = \"0.25.0\"\nauthors = [\"Alexander Keliris <rigellute@gmail.com>\"]\nedition = \"2018\"\nlicense = \"MIT OR Apache-2.0\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nrspotify = \"0.10.0\"\ntui = { version = \"0.16.0\", features = [\"crossterm\"], default-features = false }\nserde = { version = \"1.0\", features = [\"derive\"] }\nserde_json = \"1.0\"\nserde_yaml = \"0.8\"\ndirs = \"3.0.2\"\nclap = \"2.33.3\"\nunicode-width = \"0.1.8\"\nbacktrace = \"0.3.57\"\narboard = \"1.2.0\"\ncrossterm = \"0.20\"\ntokio = { version = \"0.2\", features = [\"full\"] }\nrand = \"0.8.4\"\nanyhow = \"1.0.43\"\n\n[[bin]]\nbench = false\npath = \"src/main.rs\"\nname = \"spt\"\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 Alexander Keliris\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Spotify TUI\n\n![Continuous Integration](https://github.com/Rigellute/spotify-tui/workflows/Continuous%20Integration/badge.svg?branch=master&event=push)\n![](https://img.shields.io/badge/license-MIT-blueviolet.svg)\n![](https://tokei.rs/b1/github/Rigellute/spotify-tui?category=code)\n[![Crates.io](https://img.shields.io/crates/v/spotify-tui.svg)](https://crates.io/crates/spotify-tui)\n![](https://img.shields.io/github/v/release/Rigellute/spotify-tui?color=%23c694ff)\n\n<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->\n[![All Contributors](https://img.shields.io/badge/all_contributors-94-orange.svg?style=flat-square)](#contributors-)\n<!-- ALL-CONTRIBUTORS-BADGE:END -->\n\n[![Follow Alexander Keliris (Rigellute)](https://img.shields.io/twitter/follow/AlexKeliris?label=Follow%20Alexander%20Keliris%20%28Rigellute%29&style=social)](https://twitter.com/intent/follow?screen_name=AlexKeliris)\n\nA Spotify client for the terminal written in Rust.\n\n![Demo](https://user-images.githubusercontent.com/12150276/75177190-91d4ab00-572d-11ea-80bd-c5e28c7b17ad.gif)\n\nThe terminal in the demo above is using the [Rigel theme](https://rigel.netlify.com/).\n\n- [Spotify TUI](#spotify-tui)\n  - [Installation](#installation)\n    - [Homebrew](#homebrew)\n    - [Snap](#snap)\n    - [AUR](#aur)\n    - [Nix](#nix)\n    - [Void Linux](#void-linux)\n    - [Fedora/CentOS](#fedora-centos)\n    - [Cargo](#cargo)\n      - [Note on Linux](#note-on-linux)\n    - [Windows](#windows-10)\n      - [Scoop installer](#scoop-installer)\n    - [Manual](#manual)\n  - [Connecting to Spotify’s API](#connecting-to-spotifys-api)\n  - [Usage](#usage)\n- [Configuration](#configuration)\n  - [Limitations](#limitations)\n  - [Using with spotifyd](#using-with-spotifyd)\n  - [Libraries used](#libraries-used)\n  - [Development](#development)\n    - [Windows Subsystem for Linux](#windows-subsystem-for-linux)\n  - [Contributors](#contributors)\n  - [Roadmap](#roadmap)\n    - [High-level requirements yet to be implemented](#high-level-requirements-yet-to-be-implemented)\n\n## Installation\n\nThe binary executable is `spt`.\n\n### Homebrew\n\nFor both macOS and Linux\n\n```bash\nbrew install spotify-tui\n```\n\nTo update, run\n\n```bash\nbrew upgrade spotify-tui\n```\n\n### Snap\n\nFor a system with Snap installed, run\n\n```bash\nsnap install spt\n```\n\nThe stable version will be installed for you automatically.\n\nIf you want to install the nightly build, run\n\n```bash\nsnap install spt --edge\n```\n\n### AUR\n\nFor those on Arch Linux you can find the package on AUR [here](https://aur.archlinux.org/packages/spotify-tui/). If however you're using an AUR helper you can install directly from that, for example (in the case of [yay](https://github.com/Jguer/yay)), run\n\n```bash\nyay -S spotify-tui\n```\n\n### Nix\n\nAvailable as the package `spotify-tui`. To install run:\n\n```bash\nnix-env -iA nixpkgs.spotify-tui\n```\n\nWhere `nixpkgs` is the channel name in your configuration. For a more up-to-date installation, use the unstable channel.\nIt is also possible to add the package to `environment.systemPackages` (for NixOS), or `home.packages` when using [home-manager](https://github.com/rycee/home-manager).\n\n### Void Linux\n\nAvailable on the official repositories. To install, run\n\n```bash\nsudo xbps-install -Su spotify-tui\n```\n\n### Fedora/CentOS\n\nAvailable on the [Copr](https://copr.fedorainfracloud.org/coprs/atim/spotify-tui/) repositories. To install, run\n\n```bash\nsudo dnf copr enable atim/spotify-tui -y && sudo dnf install spotify-tui\n```\n\n### Cargo\n\nUse this option if your architecture is not supported by the pre-built binaries found on the [releases page](https://github.com/Rigellute/spotify-tui/releases).\n\nFirst, install [Rust](https://www.rust-lang.org/tools/install) (using the recommended `rustup` installation method) and then\n\n```bash\ncargo install spotify-tui\n```\n\nThis method will build the binary from source.\n\nTo update, run the same command again.\n\n#### Note on Linux\n\nFor compilation on Linux the development packages for `libssl` are required.\nFor basic installation instructions, see [install OpenSSL](https://docs.rs/openssl/0.10.25/openssl/#automatic).\nIn order to locate dependencies, the compilation also requires `pkg-config` to be installed.\n\nIf you are using the Windows Subsystem for Linux, you'll need to [install additional dependencies](#windows-subsystem-for-linux).\n\n### Windows 10\n\n#### Scoop installer\n\nFirst, make sure scoop installer is on your windows box, for instruction please visit [scoop.sh](https://scoop.sh)\n\nThen open powershell and run following two commands:\n\n```bash\nscoop bucket add scoop-bucket https://github.com/Rigellute/scoop-bucket\nscoop install spotify-tui\n```\n\nAfter that program is available as: `spt` or `spt.exe`\n\n### Manual\n\n1. Download the latest [binary](https://github.com/Rigellute/spotify-tui/releases) for your OS.\n1. `cd` to the file you just downloaded and unzip\n1. `cd` to `spotify-tui` and run with `./spt`\n\n## Connecting to Spotify’s API\n\n`spotify-tui` needs to connect to Spotify’s API in order to find music by\nname, play tracks etc.\n\nInstructions on how to set this up will be shown when you first run the app.\n\nBut here they are again:\n\n1. Go to the [Spotify dashboard](https://developer.spotify.com/dashboard/applications)\n1. Click `Create an app`\n    - You now can see your `Client ID` and `Client Secret`\n1. Now click `Edit Settings`\n1. Add `http://localhost:8888/callback` to the Redirect URIs\n1. Scroll down and click `Save`\n1. You are now ready to authenticate with Spotify!\n1. Go back to the terminal\n1. Run `spt`\n1. Enter your `Client ID`\n1. Enter your `Client Secret`\n1. Press enter to confirm the default port (8888) or enter a custom port\n1. You will be redirected to an official Spotify webpage to ask you for permissions.\n1. After accepting the permissions, you'll be redirected to localhost. If all goes well, the redirect URL will be parsed automatically and now you're done. If the local webserver fails for some reason you'll be redirected to a blank webpage that might say something like \"Connection Refused\" since no server is running. Regardless, copy the URL and paste into the prompt in the terminal.\n\nAnd now you are ready to use the `spotify-tui` 🎉\n\nYou can edit the config at anytime at `${HOME}/.config/spotify-tui/client.yml`. (for snap `${HOME}/snap/spt/current/.config/spotify-tui/client.yml`)\n\n## Usage\n\nThe binary is named `spt`.\n\nRunning `spt` with no arguments will bring up the UI. Press `?` to bring up a help menu that shows currently implemented key events and their actions.\nThere is also a CLI that is able to do most of the stuff the UI does. Use `spt --help` to learn more.\n\nHere are some example to get you excited.\n```\nspt --completions zsh # Prints shell completions for zsh to stdout (bash, power-shell and more are supported)\n\nspt play --name \"Your Playlist\" --playlist --random # Plays a random song from \"Your Playlist\"\nspt play --name \"A cool song\" --track # Plays 'A cool song'\n\nspt playback --like --shuffle # Likes the current song and toggles shuffle mode\nspt playback --toggle # Plays/pauses the current playback\n\nspt list --liked --limit 50 # See your liked songs (50 is the max limit)\n\n# Looks for 'An even cooler song' and gives you the '{name} from {album}' of up to 30 matches\nspt search \"An even cooler song\" --tracks --format \"%t from %b\" --limit 30\n```\n\n# Configuration\n\nA configuration file is located at `${HOME}/.config/spotify-tui/config.yml`, for snap `${HOME}/snap/spt/current/.config/spotify-tui/config.yml`\n(not to be confused with client.yml which handles spotify authentication)\n\nThe following is a sample config.yml file:\n\n```yaml\n# Sample config file\n\n# The theme colours can be an rgb string of the form \"255, 255, 255\" or a string that references the colours from your terminal theme: Reset, Black, Red, Green, Yellow, Blue, Magenta, Cyan, Gray, DarkGray, LightRed, LightGreen, LightYellow, LightBlue, LightMagenta, LightCyan, White.\ntheme:\n  active: Cyan # current playing song in list\n  banner: LightCyan # the \"spotify-tui\" banner on launch\n  error_border: Red # error dialog border\n  error_text: LightRed # error message text (e.g. \"Spotify API reported error 404\")\n  hint: Yellow # hint text in errors\n  hovered: Magenta # hovered pane border\n  inactive: Gray # borders of inactive panes\n  playbar_background: Black # background of progress bar\n  playbar_progress: LightCyan # filled-in part of the progress bar\n  playbar_progress_text: Cyan # song length and time played/left indicator in the progress bar\n  playbar_text: White # artist name in player pane\n  selected: LightCyan # a) selected pane border, b) hovered item in list, & c) track title in player\n  text: \"255, 255, 255\" # text in panes\n  header: White # header text in panes (e.g. 'Title', 'Artist', etc.)\n\nbehavior:\n  seek_milliseconds: 5000\n  volume_increment: 10\n  # The lower the number the higher the \"frames per second\". You can decrease this number so that the audio visualisation is smoother but this can be expensive!\n  tick_rate_milliseconds: 250\n  # Enable text emphasis (typically italic/bold text styling). Disabling this might be important if the terminal config is otherwise restricted and rendering text escapes interferes with the UI.\n  enable_text_emphasis: true\n  # Controls whether to show a loading indicator in the top right of the UI whenever communicating with Spotify API\n  show_loading_indicator: true\n  # Disables the responsive layout that makes the search bar smaller on bigger\n  # screens and enforces a wide search bar\n  enforce_wide_search_bar: false\n  # Determines the text icon to display next to \"liked\" Spotify items, such as\n  # liked songs and albums, or followed artists. Can be any length string.\n  # These icons require a patched nerd font.\n  liked_icon: ♥\n  shuffle_icon: 🔀\n  repeat_track_icon: 🔂\n  repeat_context_icon: 🔁\n  playing_icon: ▶\n  paused_icon: ⏸\n  # Sets the window title to \"spt - Spotify TUI\" via ANSI escape code.\n  set_window_title: true\n\nkeybindings:\n  # Key stroke can be used if it only uses two keys:\n  # ctrl-q works,\n  # ctrl-alt-q doesn't.\n  back: \"ctrl-q\"\n\n  jump_to_album: \"a\"\n\n  # Shift modifiers use a capital letter (also applies with other modifier keys\n  # like ctrl-A)\n  jump_to_artist_album: \"A\"\n\n  manage_devices: \"d\"\n  decrease_volume: \"-\"\n  increase_volume: \"+\"\n  toggle_playback: \" \"\n  seek_backwards: \"<\"\n  seek_forwards: \">\"\n  next_track: \"n\"\n  previous_track: \"p\"\n  copy_song_url: \"c\"\n  copy_album_url: \"C\"\n  help: \"?\"\n  shuffle: \"ctrl-s\"\n  repeat: \"r\"\n  search: \"/\"\n  audio_analysis: \"v\"\n  jump_to_context: \"o\"\n  basic_view: \"B\"\n  add_item_to_queue: \"z\"\n```\n\n## Limitations\n\nThis app uses the [Web API](https://developer.spotify.com/documentation/web-api/) from Spotify, which doesn't handle streaming itself. So you'll need either an official Spotify client open or a lighter weight alternative such as [spotifyd](https://github.com/Spotifyd/spotifyd).\n\nIf you want to play tracks, Spotify requires that you have a Premium account.\n\n## Using with [spotifyd](https://github.com/Spotifyd/spotifyd)\n\nFollow the spotifyd documentation to get set up.\n\nAfter that there is not much to it.\n\n1. Start running the spotifyd daemon.\n1. Start up `spt`\n1. Press `d` to go to the device selection menu and the spotifyd \"device\" should be there - if not check [these docs](https://github.com/Spotifyd/spotifyd#logging)\n\n## Libraries used\n\n- [tui-rs](https://github.com/fdehau/tui-rs)\n- [rspotify](https://github.com/ramsayleung/rspotify)\n\n## Development\n\n1. [Install OpenSSL](https://docs.rs/openssl/0.10.25/openssl/#automatic)\n1. [Install Rust](https://www.rust-lang.org/tools/install)\n1. [Install `xorg-dev`](https://github.com/aweinstock314/rust-clipboard#prerequisites) (required for clipboard support)\n1. Clone or fork this repo and `cd` to it\n1. And then `cargo run`\n\n### Windows Subsystem for Linux\n\nYou might get a linking error. If so, you'll probably need to install additional dependencies required by the clipboard package\n\n```bash\nsudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev\n```\n\n## Contributors\n\nThanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):\n\n<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->\n<!-- prettier-ignore-start -->\n<!-- markdownlint-disable -->\n<table>\n  <tr>\n    <td align=\"center\"><a href=\"https://keliris.dev/\"><img src=\"https://avatars2.githubusercontent.com/u/12150276?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Alexander Keliris</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=Rigellute\" title=\"Code\">💻</a> <a href=\"https://github.com/Rigellute/spotify-tui/commits?author=Rigellute\" title=\"Documentation\">📖</a> <a href=\"#design-Rigellute\" title=\"Design\">🎨</a> <a href=\"#blog-Rigellute\" title=\"Blogposts\">📝</a> <a href=\"#ideas-Rigellute\" title=\"Ideas, Planning, & Feedback\">🤔</a> <a href=\"#infra-Rigellute\" title=\"Infrastructure (Hosting, Build-Tools, etc)\">🚇</a> <a href=\"#maintenance-Rigellute\" title=\"Maintenance\">🚧</a> <a href=\"#platform-Rigellute\" title=\"Packaging/porting to new platform\">📦</a> <a href=\"https://github.com/Rigellute/spotify-tui/pulls?q=is%3Apr+reviewed-by%3ARigellute\" title=\"Reviewed Pull Requests\">👀</a></td>\n    <td align=\"center\"><a href=\"https://github.com/mikepombal\"><img src=\"https://avatars3.githubusercontent.com/u/6864231?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Mickael Marques</b></sub></a><br /><a href=\"#financial-mikepombal\" title=\"Financial\">💵</a></td>\n    <td align=\"center\"><a href=\"https://github.com/HakierGrzonzo\"><img src=\"https://avatars0.githubusercontent.com/u/36668331?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Grzegorz Koperwas</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=HakierGrzonzo\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/amgassert\"><img src=\"https://avatars2.githubusercontent.com/u/22896005?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Austin Gassert</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=amgassert\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://robinette.dev\"><img src=\"https://avatars2.githubusercontent.com/u/30757528?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Calen Robinette</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=calenrobinette\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://mcofficer.me\"><img src=\"https://avatars0.githubusercontent.com/u/22377202?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>M*C*O</b></sub></a><br /><a href=\"#infra-MCOfficer\" title=\"Infrastructure (Hosting, Build-Tools, etc)\">🚇</a></td>\n    <td align=\"center\"><a href=\"https://github.com/eminence\"><img src=\"https://avatars0.githubusercontent.com/u/402454?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Andrew Chin</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=eminence\" title=\"Code\">💻</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"https://www.samnaser.com/\"><img src=\"https://avatars0.githubusercontent.com/u/4377348?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Sam Naser</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=Monkeyanator\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/radogost\"><img src=\"https://avatars0.githubusercontent.com/u/15713820?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Micha</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=radogost\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/neriglissar\"><img src=\"https://avatars2.githubusercontent.com/u/53038761?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>neriglissar</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=neriglissar\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/TimonPost\"><img src=\"https://avatars3.githubusercontent.com/u/19969910?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Timon</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=TimonPost\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/echoSayonara\"><img src=\"https://avatars2.githubusercontent.com/u/54503126?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>echoSayonara</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=echoSayonara\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/D-Nice\"><img src=\"https://avatars1.githubusercontent.com/u/2888248?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>D-Nice</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=D-Nice\" title=\"Documentation\">📖</a> <a href=\"#infra-D-Nice\" title=\"Infrastructure (Hosting, Build-Tools, etc)\">🚇</a></td>\n    <td align=\"center\"><a href=\"http://gpawlik.com\"><img src=\"https://avatars3.githubusercontent.com/u/6296883?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Grzegorz Pawlik</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=gpawlik\" title=\"Code\">💻</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"http://lenny.ninja\"><img src=\"https://avatars1.githubusercontent.com/u/4027243?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Lennart Bernhardt</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=LennyPenny\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/BlackYoup\"><img src=\"https://avatars3.githubusercontent.com/u/6098160?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Arnaud Lefebvre</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=BlackYoup\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/tem1029\"><img src=\"https://avatars3.githubusercontent.com/u/57712713?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>tem1029</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=tem1029\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"http://peter.moss.dk\"><img src=\"https://avatars2.githubusercontent.com/u/12544579?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Peter K. Moss</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=Peterkmoss\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"http://www.zephyrizing.net/\"><img src=\"https://avatars1.githubusercontent.com/u/113102?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Geoff Shannon</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=RadicalZephyr\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"http://zacklukem.info\"><img src=\"https://avatars0.githubusercontent.com/u/8787486?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Zachary Mayhew</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=zacklukem\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"http://jfaltis.de\"><img src=\"https://avatars2.githubusercontent.com/u/45465572?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>jfaltis</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=jfaltis\" title=\"Code\">💻</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"https://marcelschr.me\"><img src=\"https://avatars3.githubusercontent.com/u/19377618?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Marcel Schramm</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=Bios-Marcel\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/fangyi-zhou\"><img src=\"https://avatars3.githubusercontent.com/u/7815439?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Fangyi Zhou</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=fangyi-zhou\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/synth-ruiner\"><img src=\"https://avatars1.githubusercontent.com/u/8642013?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Max</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=synth-ruiner\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/svenvNL\"><img src=\"https://avatars1.githubusercontent.com/u/13982006?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Sven van der Vlist</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=svenvNL\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/jacobchrismarsh\"><img src=\"https://avatars2.githubusercontent.com/u/15932179?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>jacobchrismarsh</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=jacobchrismarsh\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/TheWalkingLeek\"><img src=\"https://avatars2.githubusercontent.com/u/36076343?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Nils Rauch</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=TheWalkingLeek\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/sputnick1124\"><img src=\"https://avatars1.githubusercontent.com/u/8843309?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Nick Stockton</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=sputnick1124\" title=\"Code\">💻</a> <a href=\"https://github.com/Rigellute/spotify-tui/issues?q=author%3Asputnick1124\" title=\"Bug reports\">🐛</a> <a href=\"#maintenance-sputnick1124\" title=\"Maintenance\">🚧</a> <a href=\"#question-sputnick1124\" title=\"Answering Questions\">💬</a> <a href=\"https://github.com/Rigellute/spotify-tui/commits?author=sputnick1124\" title=\"Documentation\">📖</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"http://stuarth.github.io\"><img src=\"https://avatars3.githubusercontent.com/u/7055?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Stuart Hinson</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=stuarth\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/samcal\"><img src=\"https://avatars3.githubusercontent.com/u/2117940?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Sam Calvert</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=samcal\" title=\"Code\">💻</a> <a href=\"https://github.com/Rigellute/spotify-tui/commits?author=samcal\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/jwijenbergh\"><img src=\"https://avatars0.githubusercontent.com/u/46386452?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Jeroen Wijenbergh</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=jwijenbergh\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://twitter.com/KimberleyCook91\"><img src=\"https://avatars3.githubusercontent.com/u/2683270?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Kimberley Cook</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=KimberleyCook\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/baxtea\"><img src=\"https://avatars0.githubusercontent.com/u/22502477?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Audrey Baxter</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=baxtea\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://koehr.in\"><img src=\"https://avatars2.githubusercontent.com/u/246402?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Norman</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=nkoehring\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/blackwolf12333\"><img src=\"https://avatars0.githubusercontent.com/u/1572975?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Peter Maatman</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=blackwolf12333\" title=\"Code\">💻</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"https://github.com/AlexandreSi\"><img src=\"https://avatars1.githubusercontent.com/u/32449369?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>AlexandreS</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=AlexandreSi\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/fiinnnn\"><img src=\"https://avatars2.githubusercontent.com/u/5011796?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Finn Vos</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=fiinnnn\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/hurricanehrndz\"><img src=\"https://avatars0.githubusercontent.com/u/5804237?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Carlos Hernandez</b></sub></a><br /><a href=\"#platform-hurricanehrndz\" title=\"Packaging/porting to new platform\">📦</a></td>\n    <td align=\"center\"><a href=\"https://github.com/pedrohva\"><img src=\"https://avatars3.githubusercontent.com/u/33297928?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Pedro Alves</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=pedrohva\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://gitlab.com/jtagcat/\"><img src=\"https://avatars1.githubusercontent.com/u/38327267?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>jtagcat</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=jtagcat\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/BKitor\"><img src=\"https://avatars0.githubusercontent.com/u/16880850?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Benjamin Kitor</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=BKitor\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://ales.rocks\"><img src=\"https://avatars0.githubusercontent.com/u/544082?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Aleš Najmann</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=littleli\" title=\"Documentation\">📖</a> <a href=\"#platform-littleli\" title=\"Packaging/porting to new platform\">📦</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"https://github.com/jeremystucki\"><img src=\"https://avatars3.githubusercontent.com/u/7629727?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Jeremy Stucki</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=jeremystucki\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"http://pt2121.github.io\"><img src=\"https://avatars0.githubusercontent.com/u/616399?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>(´⌣`ʃƪ)</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=pt2121\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/tim77\"><img src=\"https://avatars0.githubusercontent.com/u/5614476?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Artem Polishchuk</b></sub></a><br /><a href=\"#platform-tim77\" title=\"Packaging/porting to new platform\">📦</a></td>\n    <td align=\"center\"><a href=\"https://github.com/slumber\"><img src=\"https://avatars2.githubusercontent.com/u/48099298?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Chris Sosnin</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=slumber\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"http://www.benbuhse.com\"><img src=\"https://avatars1.githubusercontent.com/u/21225303?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Ben Buhse</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=bwbuhse\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/ilnaes\"><img src=\"https://avatars1.githubusercontent.com/u/20805499?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Sean Li</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=ilnaes\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/TimotheeGerber\"><img src=\"https://avatars3.githubusercontent.com/u/37541513?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>TimotheeGerber</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=TimotheeGerber\" title=\"Code\">💻</a> <a href=\"https://github.com/Rigellute/spotify-tui/commits?author=TimotheeGerber\" title=\"Documentation\">📖</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"https://github.com/fratajczak\"><img src=\"https://avatars2.githubusercontent.com/u/33835579?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Ferdinand Ratajczak</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=fratajczak\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/sheelc\"><img src=\"https://avatars0.githubusercontent.com/u/1355710?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Sheel Choksi</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=sheelc\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"http://fnanp.in-ulm.de/microblog/\"><img src=\"https://avatars1.githubusercontent.com/u/414112?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Michael Hellwig</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=mhellwig\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/oliver-daniel\"><img src=\"https://avatars2.githubusercontent.com/u/17235417?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Oliver Daniel</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=oliver-daniel\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/Drewsapple\"><img src=\"https://avatars2.githubusercontent.com/u/4532572?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Drew Fisher</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=Drewsapple\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/ncoder-1\"><img src=\"https://avatars0.githubusercontent.com/u/7622286?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>ncoder-1</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=ncoder-1\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"http://macguire.me\"><img src=\"https://avatars3.githubusercontent.com/u/18323154?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Macguire Rintoul</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=macguirerintoul\" title=\"Documentation\">📖</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"http://ricardohe97.github.io\"><img src=\"https://avatars3.githubusercontent.com/u/28399979?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Ricardo Holguin</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=RicardoHE97\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://ksk.netlify.com\"><img src=\"https://avatars3.githubusercontent.com/u/13160198?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Keisuke Toyota</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=ksk001100\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://jackson15j.github.io\"><img src=\"https://avatars1.githubusercontent.com/u/3226988?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Craig Astill</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=jackson15j\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/onielfa\"><img src=\"https://avatars0.githubusercontent.com/u/4358172?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Onielfa</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=onielfa\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://usrme.xyz\"><img src=\"https://avatars3.githubusercontent.com/u/5902545?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>usrme</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=usrme\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/murlakatamenka\"><img src=\"https://avatars2.githubusercontent.com/u/7361274?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Sergey A.</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=murlakatamenka\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/elcih17\"><img src=\"https://avatars3.githubusercontent.com/u/17084445?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Hideyuki Okada</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=elcih17\" title=\"Code\">💻</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"https://github.com/kepae\"><img src=\"https://avatars2.githubusercontent.com/u/4238598?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>kepae</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=kepae\" title=\"Code\">💻</a> <a href=\"https://github.com/Rigellute/spotify-tui/commits?author=kepae\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/ericonr\"><img src=\"https://avatars0.githubusercontent.com/u/34201958?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Érico Nogueira Rolim</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=ericonr\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/BeneCollyridam\"><img src=\"https://avatars2.githubusercontent.com/u/15802915?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Alexander Meinhardt Scheurer</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=BeneCollyridam\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/Toaster192\"><img src=\"https://avatars0.githubusercontent.com/u/14369229?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Ondřej Kinšt</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=Toaster192\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/Kryan90\"><img src=\"https://avatars3.githubusercontent.com/u/18740821?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Kryan90</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=Kryan90\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/n-ivanov\"><img src=\"https://avatars3.githubusercontent.com/u/11470871?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>n-ivanov</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=n-ivanov\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"http://matthewbilyeu.com/resume/\"><img src=\"https://avatars3.githubusercontent.com/u/1185129?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>bi1yeu</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=bi1yeu\" title=\"Code\">💻</a> <a href=\"https://github.com/Rigellute/spotify-tui/commits?author=bi1yeu\" title=\"Documentation\">📖</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"https://github.com/Utagai\"><img src=\"https://avatars2.githubusercontent.com/u/10730394?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>May</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=Utagai\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://mucinoab.github.io/\"><img src=\"https://avatars1.githubusercontent.com/u/28630268?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Bruno A. Muciño</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=mucinoab\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/OrangeFran\"><img src=\"https://avatars2.githubusercontent.com/u/55061632?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Finn Hediger</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=OrangeFran\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/dp304\"><img src=\"https://avatars1.githubusercontent.com/u/34493835?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>dp304</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=dp304\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"http://marcomicera.github.io\"><img src=\"https://avatars0.githubusercontent.com/u/13918587?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Marco Micera</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=marcomicera\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"http://marcoieni.com\"><img src=\"https://avatars3.githubusercontent.com/u/11428655?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Marco Ieni</b></sub></a><br /><a href=\"#infra-MarcoIeni\" title=\"Infrastructure (Hosting, Build-Tools, etc)\">🚇</a></td>\n    <td align=\"center\"><a href=\"https://github.com/ArturKovacs\"><img src=\"https://avatars3.githubusercontent.com/u/8320264?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Artúr Kovács</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=ArturKovacs\" title=\"Code\">💻</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"https://github.com/aokellermann\"><img src=\"https://avatars.githubusercontent.com/u/26678747?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Antony Kellermann</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=aokellermann\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/rasmuspeders1\"><img src=\"https://avatars.githubusercontent.com/u/1898960?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Rasmus Pedersen</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=rasmuspeders1\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/noir-Z\"><img src=\"https://avatars.githubusercontent.com/u/45096516?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>noir-Z</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=noir-Z\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://davidbailey.codes/\"><img src=\"https://avatars.githubusercontent.com/u/4248177?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>David Bailey</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=davidbailey00\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/sheepwall\"><img src=\"https://avatars.githubusercontent.com/u/22132993?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>sheepwall</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=sheepwall\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/Hwatwasthat\"><img src=\"https://avatars.githubusercontent.com/u/29790143?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Hwatwasthat</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=Hwatwasthat\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/Jesse-Bakker\"><img src=\"https://avatars.githubusercontent.com/u/22473248?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Jesse</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=Jesse-Bakker\" title=\"Code\">💻</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"https://github.com/hantatsang\"><img src=\"https://avatars.githubusercontent.com/u/11912225?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Sang</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=hantatsang\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://yktakaha4.github.io/\"><img src=\"https://avatars.githubusercontent.com/u/20282867?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Yuuki Takahashi</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=yktakaha4\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://alejandr0angul0.dev/\"><img src=\"https://avatars.githubusercontent.com/u/5242883?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Alejandro Angulo</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=alejandro-angulo\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"http://t.me/lego1as\"><img src=\"https://avatars.githubusercontent.com/u/11005780?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Anton Kostin</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=masguit42\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://justinsexton.net\"><img src=\"https://avatars.githubusercontent.com/u/20236003?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Justin Sexton</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=JSextonn\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/lejiati\"><img src=\"https://avatars.githubusercontent.com/u/6442124?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Jiati Le</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=lejiati\" title=\"Documentation\">📖</a></td>\n    <td align=\"center\"><a href=\"https://github.com/cobbinma\"><img src=\"https://avatars.githubusercontent.com/u/578718?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Matthew Cobbing</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=cobbinma\" title=\"Code\">💻</a></td>\n  </tr>\n  <tr>\n    <td align=\"center\"><a href=\"https://milo123459.vercel.app\"><img src=\"https://avatars.githubusercontent.com/u/50248166?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Milo</b></sub></a><br /><a href=\"#infra-Milo123459\" title=\"Infrastructure (Hosting, Build-Tools, etc)\">🚇</a></td>\n    <td align=\"center\"><a href=\"https://www.diegoveralli.com\"><img src=\"https://avatars.githubusercontent.com/u/297206?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Diego Veralli</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=diegov\" title=\"Code\">💻</a></td>\n    <td align=\"center\"><a href=\"https://github.com/majabojarska\"><img src=\"https://avatars.githubusercontent.com/u/33836570?v=4?s=100\" width=\"100px;\" alt=\"\"/><br /><sub><b>Maja Bojarska</b></sub></a><br /><a href=\"https://github.com/Rigellute/spotify-tui/commits?author=majabojarska\" title=\"Code\">💻</a></td>\n  </tr>\n</table>\n\n<!-- markdownlint-restore -->\n<!-- prettier-ignore-end -->\n\n<!-- ALL-CONTRIBUTORS-LIST:END -->\n\nThis project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!\n\n## Roadmap\n\nThe goal is to eventually implement almost every Spotify feature.\n\n### High-level requirements yet to be implemented\n\n- Add songs to a playlist\n- Be able to scroll through result pages in every view\n\nThis table shows all that is possible with the Spotify API, what is implemented already, and whether that is essential.\n\n| API method                                        | Implemented yet? | Explanation                                                                                                                                                  | Essential? |\n| ------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |\n| track                                             | No               | returns a single track given the track's ID, URI or URL                                                                                                      | No         |\n| tracks                                            | No               | returns a list of tracks given a list of track IDs, URIs, or URLs                                                                                            | No         |\n| artist                                            | No               | returns a single artist given the artist's ID, URI or URL                                                                                                    | Yes        |\n| artists                                           | No               | returns a list of artists given the artist IDs, URIs, or URLs                                                                                                | No         |\n| artist_albums                                     | Yes              | Get Spotify catalog information about an artist's albums                                                                                                     | Yes        |\n| artist_top_tracks                                 | Yes              | Get Spotify catalog information about an artist's top 10 tracks by country.                                                                                  | Yes        |\n| artist_related_artists                            | Yes              | Get Spotify catalog information about artists similar to an identified artist. Similarity is based on analysis of the Spotify community's listening history. | Yes        |\n| album                                             | Yes              | returns a single album given the album's ID, URIs or URL                                                                                                     | Yes        |\n| albums                                            | No               | returns a list of albums given the album IDs, URIs, or URLs                                                                                                  | No         |\n| search_album                                      | Yes              | Search album based on query                                                                                                                                  | Yes        |\n| search_artist                                     | Yes              | Search artist based on query                                                                                                                                 | Yes        |\n| search_track                                      | Yes              | Search track based on query                                                                                                                                  | Yes        |\n| search_playlist                                   | Yes              | Search playlist based on query                                                                                                                               | Yes        |\n| album_track                                       | Yes              | Get Spotify catalog information about an album's tracks                                                                                                      | Yes        |\n| user                                              | No               | Gets basic profile information about a Spotify User                                                                                                          | No         |\n| playlist                                          | Yes              | Get full details about Spotify playlist                                                                                                                      | Yes        |\n| current_user_playlists                            | Yes              | Get current user playlists without required getting his profile                                                                                              | Yes        |\n| user_playlists                                    | No               | Gets playlists of a user                                                                                                                                     | No         |\n| user_playlist                                     | No               | Gets playlist of a user                                                                                                                                      | No         |\n| user_playlist_tracks                              | Yes              | Get full details of the tracks of a playlist owned by a user                                                                                                 | Yes        |\n| user_playlist_create                              | No               | Creates a playlist for a user                                                                                                                                | Yes        |\n| user_playlist_change_detail                       | No               | Changes a playlist's name and/or public/private state                                                                                                        | Yes        |\n| user_playlist_unfollow                            | Yes              | Unfollows (deletes) a playlist for a user                                                                                                                    | Yes        |\n| user_playlist_add_track                           | No               | Adds tracks to a playlist                                                                                                                                    | Yes        |\n| user_playlist_replace_track                       | No               | Replace all tracks in a playlist                                                                                                                             | No         |\n| user_playlist_recorder_tracks                     | No               | Reorder tracks in a playlist                                                                                                                                 | No         |\n| user_playlist_remove_all_occurrences_of_track     | No               | Removes all occurrences of the given tracks from the given playlist                                                                                          | No         |\n| user_playlist_remove_specific_occurrenes_of_track | No               | Removes all occurrences of the given tracks from the given playlist                                                                                          | No         |\n| user_playlist_follow_playlist                     | Yes              | Add the current authenticated user as a follower of a playlist.                                                                                              | Yes        |\n| user_playlist_check_follow                        | No               | Check to see if the given users are following the given playlist                                                                                             | Yes        |\n| me                                                | No               | Get detailed profile information about the current user.                                                                                                     | Yes        |\n| current_user                                      | No               | Alias for `me`                                                                                                                                               | Yes        |\n| current_user_playing_track                        | Yes              | Get information about the current users currently playing track.                                                                                             | Yes        |\n| current_user_saved_albums                         | Yes              | Gets a list of the albums saved in the current authorized user's \"Your Music\" library                                                                        | Yes        |\n| current_user_saved_tracks                         | Yes              | Gets the user's saved tracks or \"Liked Songs\"                                                                                                                | Yes        |\n| current_user_followed_artists                     | Yes              | Gets a list of the artists followed by the current authorized user                                                                                           | Yes        |\n| current_user_saved_tracks_delete                  | Yes              | Remove one or more tracks from the current user's \"Your Music\" library.                                                                                      | Yes        |\n| current_user_saved_tracks_contain                 | No               | Check if one or more tracks is already saved in the current Spotify user’s “Your Music” library.                                                             | Yes        |\n| current_user_saved_tracks_add                     | Yes              | Save one or more tracks to the current user's \"Your Music\" library.                                                                                          | Yes        |\n| current_user_top_artists                          | No               | Get the current user's top artists                                                                                                                           | Yes        |\n| current_user_top_tracks                           | No               | Get the current user's top tracks                                                                                                                            | Yes        |\n| current_user_recently_played                      | Yes              | Get the current user's recently played tracks                                                                                                                | Yes        |\n| current_user_saved_albums_add                     | Yes              | Add one or more albums to the current user's \"Your Music\" library.                                                                                           | Yes        |\n| current_user_saved_albums_delete                  | Yes              | Remove one or more albums from the current user's \"Your Music\" library.                                                                                      | Yes        |\n| user_follow_artists                               | Yes              | Follow one or more artists                                                                                                                                   | Yes        |\n| user_unfollow_artists                             | Yes              | Unfollow one or more artists                                                                                                                                 | Yes        |\n| user_follow_users                                 | No               | Follow one or more users                                                                                                                                     | No         |\n| user_unfollow_users                               | No               | Unfollow one or more users                                                                                                                                   | No         |\n| featured_playlists                                | No               | Get a list of Spotify featured playlists                                                                                                                     | Yes        |\n| new_releases                                      | No               | Get a list of new album releases featured in Spotify                                                                                                         | Yes        |\n| categories                                        | No               | Get a list of categories used to tag items in Spotify                                                                                                        | Yes        |\n| recommendations                                   | Yes              | Get Recommendations Based on Seeds                                                                                                                           | Yes        |\n| audio_features                                    | No               | Get audio features for a track                                                                                                                               | No         |\n| audios_features                                   | No               | Get Audio Features for Several Tracks                                                                                                                        | No         |\n| audio_analysis                                    | Yes              | Get Audio Analysis for a Track                                                                                                                               | Yes        |\n| device                                            | Yes              | Get a User’s Available Devices                                                                                                                               | Yes        |\n| current_playback                                  | Yes              | Get Information About The User’s Current Playback                                                                                                            | Yes        |\n| current_playing                                   | No               | Get the User’s Currently Playing Track                                                                                                                       | No         |\n| transfer_playback                                 | Yes              | Transfer a User’s Playback                                                                                                                                   | Yes        |\n| start_playback                                    | Yes              | Start/Resume a User’s Playback                                                                                                                               | Yes        |\n| pause_playback                                    | Yes              | Pause a User’s Playback                                                                                                                                      | Yes        |\n| next_track                                        | Yes              | Skip User’s Playback To Next Track                                                                                                                           | Yes        |\n| previous_track                                    | Yes              | Skip User’s Playback To Previous Track                                                                                                                       | Yes        |\n| seek_track                                        | Yes              | Seek To Position In Currently Playing Track                                                                                                                  | Yes        |\n| repeat                                            | Yes              | Set Repeat Mode On User’s Playback                                                                                                                           | Yes        |\n| volume                                            | Yes              | Set Volume For User’s Playback                                                                                                                               | Yes        |\n| shuffle                                           | Yes              | Toggle Shuffle For User’s Playback                                                                                                                           | Yes        |\n"
  },
  {
    "path": "how_to_release.md",
    "content": "# To create a release\n\nThe releases are automated via GitHub actions, using [this configuration file](https://github.com/Rigellute/spotify-tui/blob/master/.github/workflows/cd.yml).\n\nThe release is triggered by pushing a tag.\n\n1. Bump the version in `Cargo.toml` and run the app to update the `lock` file\n1. Update the \"Unreleased\" header for the new version in the `CHANGELOG`. Use `### Added/Fixed/Changed` headers as appropriate\n1. Commit the changes and push them.\n1. Create a new tag e.g. `git tag -a v0.7.0` and add the CHANGELOG to the commit body\n1. Push the tag `git push --tags`\n1. Wait for the build to finish on the [Actions page](https://github.com/Rigellute/spotify-tui/actions)\n1. This should publish to cargo as well\n\n### Update `brew`\n\n1. `cd` to the [`tap` repo](https://github.com/Rigellute/homebrew-tap)\n1. Run script to update the Formula `sh scripts/spotify-tui.sh $VERSION`\n\n### Update `scoop` (Windows 10)\n\n1. `cd` to [the `scoop` repo](https://github.com/Rigellute/scoop-bucket)\n1. Run the script to update the manifest `sh scripts/spotify-tui.sh $VERSION`\n"
  },
  {
    "path": "rustfmt.toml",
    "content": "tab_spaces=2\nedition = \"2018\"\n"
  },
  {
    "path": "snap/gui/spt.desktop",
    "content": "[Desktop Entry]\nName=Spotify TUI\nExec=spt\nComment=Spotify for the terminal written in Rust\nIcon=${SNAP}/meta/gui/spt.png\nType=Application\nTerminal=true\nStartupNotify=false\nCategories=Music;\n"
  },
  {
    "path": "snap/snapcraft.yaml",
    "content": "name: spt-temp\nbase: core18\nadopt-info: spotify-tui\nsummary: Spotify TUI\ndescription: |\n  Spotify for the terminal written in Rust\n\ngrade: stable\nconfinement: strict\n\nparts:\n  spotify-tui:\n    plugin: rust\n    source: .\n    build-packages:\n      - libssl-dev\n      - pkg-config\n      - libxcb1-dev\n      - libxcb-render0-dev\n      - libxcb-shape0-dev\n      - libxcb-xfixes0-dev\n    stage-packages:\n      - libxau6\n      - libxcb-render0\n      - libxcb-shape0\n      - libxcb-xfixes0\n      - libxcb1\n      - libxdmcp6\n    override-pull: |\n      snapcraftctl pull\n      last_committed_tag=\"$(git describe --tags --abbrev=0)\"\n      echo $last_committed_tag\n      VERSION=\"$(git describe --first-parent --tags --always)\"\n      echo \"Setting version to $VERSION\"\n      snapcraftctl set-version \"${VERSION}\"\n\napps:\n  spt-temp:\n    environment:\n      LD_LIBRARY_PATH: $SNAP/usr/lib/$SNAPCRAFT_ARCH_TRIPLET/pulseaudio:$LD_LIBRARY_PATH\n    command: spt\n    plugs:\n      - desktop\n      - network\n      - network-bind\n      - pulseaudio\n"
  },
  {
    "path": "src/app.rs",
    "content": "use super::user_config::UserConfig;\nuse crate::network::IoEvent;\nuse anyhow::anyhow;\nuse rspotify::{\n  model::{\n    album::{FullAlbum, SavedAlbum, SimplifiedAlbum},\n    artist::FullArtist,\n    audio::AudioAnalysis,\n    context::CurrentlyPlaybackContext,\n    device::DevicePayload,\n    page::{CursorBasedPage, Page},\n    playing::PlayHistory,\n    playlist::{PlaylistTrack, SimplifiedPlaylist},\n    show::{FullShow, Show, SimplifiedEpisode, SimplifiedShow},\n    track::{FullTrack, SavedTrack, SimplifiedTrack},\n    user::PrivateUser,\n    PlayingItem,\n  },\n  senum::Country,\n};\nuse std::str::FromStr;\nuse std::sync::mpsc::Sender;\nuse std::{\n  cmp::{max, min},\n  collections::HashSet,\n  time::{Instant, SystemTime},\n};\nuse tui::layout::Rect;\n\nuse arboard::Clipboard;\n\npub const LIBRARY_OPTIONS: [&str; 6] = [\n  \"Made For You\",\n  \"Recently Played\",\n  \"Liked Songs\",\n  \"Albums\",\n  \"Artists\",\n  \"Podcasts\",\n];\n\nconst DEFAULT_ROUTE: Route = Route {\n  id: RouteId::Home,\n  active_block: ActiveBlock::Empty,\n  hovered_block: ActiveBlock::Library,\n};\n\n#[derive(Clone)]\npub struct ScrollableResultPages<T> {\n  index: usize,\n  pub pages: Vec<T>,\n}\n\nimpl<T> ScrollableResultPages<T> {\n  pub fn new() -> ScrollableResultPages<T> {\n    ScrollableResultPages {\n      index: 0,\n      pages: vec![],\n    }\n  }\n\n  pub fn get_results(&self, at_index: Option<usize>) -> Option<&T> {\n    self.pages.get(at_index.unwrap_or(self.index))\n  }\n\n  pub fn get_mut_results(&mut self, at_index: Option<usize>) -> Option<&mut T> {\n    self.pages.get_mut(at_index.unwrap_or(self.index))\n  }\n\n  pub fn add_pages(&mut self, new_pages: T) {\n    self.pages.push(new_pages);\n    // Whenever a new page is added, set the active index to the end of the vector\n    self.index = self.pages.len() - 1;\n  }\n}\n\n#[derive(Default)]\npub struct SpotifyResultAndSelectedIndex<T> {\n  pub index: usize,\n  pub result: T,\n}\n\n#[derive(Clone)]\npub struct Library {\n  pub selected_index: usize,\n  pub saved_tracks: ScrollableResultPages<Page<SavedTrack>>,\n  pub made_for_you_playlists: ScrollableResultPages<Page<SimplifiedPlaylist>>,\n  pub saved_albums: ScrollableResultPages<Page<SavedAlbum>>,\n  pub saved_shows: ScrollableResultPages<Page<Show>>,\n  pub saved_artists: ScrollableResultPages<CursorBasedPage<FullArtist>>,\n  pub show_episodes: ScrollableResultPages<Page<SimplifiedEpisode>>,\n}\n\n#[derive(PartialEq, Debug)]\npub enum SearchResultBlock {\n  AlbumSearch,\n  SongSearch,\n  ArtistSearch,\n  PlaylistSearch,\n  ShowSearch,\n  Empty,\n}\n\n#[derive(PartialEq, Debug, Clone)]\npub enum ArtistBlock {\n  TopTracks,\n  Albums,\n  RelatedArtists,\n  Empty,\n}\n\n#[derive(Clone, Copy, PartialEq, Debug)]\npub enum DialogContext {\n  PlaylistWindow,\n  PlaylistSearch,\n}\n\n#[derive(Clone, Copy, PartialEq, Debug)]\npub enum ActiveBlock {\n  Analysis,\n  PlayBar,\n  AlbumTracks,\n  AlbumList,\n  ArtistBlock,\n  Empty,\n  Error,\n  HelpMenu,\n  Home,\n  Input,\n  Library,\n  MyPlaylists,\n  Podcasts,\n  EpisodeTable,\n  RecentlyPlayed,\n  SearchResultBlock,\n  SelectDevice,\n  TrackTable,\n  MadeForYou,\n  Artists,\n  BasicView,\n  Dialog(DialogContext),\n}\n\n#[derive(Clone, PartialEq, Debug)]\npub enum RouteId {\n  Analysis,\n  AlbumTracks,\n  AlbumList,\n  Artist,\n  BasicView,\n  Error,\n  Home,\n  RecentlyPlayed,\n  Search,\n  SelectedDevice,\n  TrackTable,\n  MadeForYou,\n  Artists,\n  Podcasts,\n  PodcastEpisodes,\n  Recommendations,\n  Dialog,\n}\n\n#[derive(Debug)]\npub struct Route {\n  pub id: RouteId,\n  pub active_block: ActiveBlock,\n  pub hovered_block: ActiveBlock,\n}\n\n// Is it possible to compose enums?\n#[derive(PartialEq, Debug)]\npub enum TrackTableContext {\n  MyPlaylists,\n  AlbumSearch,\n  PlaylistSearch,\n  SavedTracks,\n  RecommendedTracks,\n  MadeForYou,\n}\n\n// Is it possible to compose enums?\n#[derive(Clone, PartialEq, Debug, Copy)]\npub enum AlbumTableContext {\n  Simplified,\n  Full,\n}\n\n#[derive(Clone, PartialEq, Debug, Copy)]\npub enum EpisodeTableContext {\n  Simplified,\n  Full,\n}\n\n#[derive(Clone, PartialEq, Debug)]\npub enum RecommendationsContext {\n  Artist,\n  Song,\n}\n\npub struct SearchResult {\n  pub albums: Option<Page<SimplifiedAlbum>>,\n  pub artists: Option<Page<FullArtist>>,\n  pub playlists: Option<Page<SimplifiedPlaylist>>,\n  pub tracks: Option<Page<FullTrack>>,\n  pub shows: Option<Page<SimplifiedShow>>,\n  pub selected_album_index: Option<usize>,\n  pub selected_artists_index: Option<usize>,\n  pub selected_playlists_index: Option<usize>,\n  pub selected_tracks_index: Option<usize>,\n  pub selected_shows_index: Option<usize>,\n  pub hovered_block: SearchResultBlock,\n  pub selected_block: SearchResultBlock,\n}\n\n#[derive(Default)]\npub struct TrackTable {\n  pub tracks: Vec<FullTrack>,\n  pub selected_index: usize,\n  pub context: Option<TrackTableContext>,\n}\n\n#[derive(Clone)]\npub struct SelectedShow {\n  pub show: SimplifiedShow,\n}\n\n#[derive(Clone)]\npub struct SelectedFullShow {\n  pub show: FullShow,\n}\n\n#[derive(Clone)]\npub struct SelectedAlbum {\n  pub album: SimplifiedAlbum,\n  pub tracks: Page<SimplifiedTrack>,\n  pub selected_index: usize,\n}\n\n#[derive(Clone)]\npub struct SelectedFullAlbum {\n  pub album: FullAlbum,\n  pub selected_index: usize,\n}\n\n#[derive(Clone)]\npub struct Artist {\n  pub artist_name: String,\n  pub albums: Page<SimplifiedAlbum>,\n  pub related_artists: Vec<FullArtist>,\n  pub top_tracks: Vec<FullTrack>,\n  pub selected_album_index: usize,\n  pub selected_related_artist_index: usize,\n  pub selected_top_track_index: usize,\n  pub artist_hovered_block: ArtistBlock,\n  pub artist_selected_block: ArtistBlock,\n}\n\npub struct App {\n  pub instant_since_last_current_playback_poll: Instant,\n  navigation_stack: Vec<Route>,\n  pub audio_analysis: Option<AudioAnalysis>,\n  pub home_scroll: u16,\n  pub user_config: UserConfig,\n  pub artists: Vec<FullArtist>,\n  pub artist: Option<Artist>,\n  pub album_table_context: AlbumTableContext,\n  pub saved_album_tracks_index: usize,\n  pub api_error: String,\n  pub current_playback_context: Option<CurrentlyPlaybackContext>,\n  pub devices: Option<DevicePayload>,\n  // Inputs:\n  // input is the string for input;\n  // input_idx is the index of the cursor in terms of character;\n  // input_cursor_position is the sum of the width of characters preceding the cursor.\n  // Reason for this complication is due to non-ASCII characters, they may\n  // take more than 1 bytes to store and more than 1 character width to display.\n  pub input: Vec<char>,\n  pub input_idx: usize,\n  pub input_cursor_position: u16,\n  pub liked_song_ids_set: HashSet<String>,\n  pub followed_artist_ids_set: HashSet<String>,\n  pub saved_album_ids_set: HashSet<String>,\n  pub saved_show_ids_set: HashSet<String>,\n  pub large_search_limit: u32,\n  pub library: Library,\n  pub playlist_offset: u32,\n  pub made_for_you_offset: u32,\n  pub playlist_tracks: Option<Page<PlaylistTrack>>,\n  pub made_for_you_tracks: Option<Page<PlaylistTrack>>,\n  pub playlists: Option<Page<SimplifiedPlaylist>>,\n  pub recently_played: SpotifyResultAndSelectedIndex<Option<CursorBasedPage<PlayHistory>>>,\n  pub recommended_tracks: Vec<FullTrack>,\n  pub recommendations_seed: String,\n  pub recommendations_context: Option<RecommendationsContext>,\n  pub search_results: SearchResult,\n  pub selected_album_simplified: Option<SelectedAlbum>,\n  pub selected_album_full: Option<SelectedFullAlbum>,\n  pub selected_device_index: Option<usize>,\n  pub selected_playlist_index: Option<usize>,\n  pub active_playlist_index: Option<usize>,\n  pub size: Rect,\n  pub small_search_limit: u32,\n  pub song_progress_ms: u128,\n  pub seek_ms: Option<u128>,\n  pub track_table: TrackTable,\n  pub episode_table_context: EpisodeTableContext,\n  pub selected_show_simplified: Option<SelectedShow>,\n  pub selected_show_full: Option<SelectedFullShow>,\n  pub user: Option<PrivateUser>,\n  pub album_list_index: usize,\n  pub made_for_you_index: usize,\n  pub artists_list_index: usize,\n  pub clipboard: Option<Clipboard>,\n  pub shows_list_index: usize,\n  pub episode_list_index: usize,\n  pub help_docs_size: u32,\n  pub help_menu_page: u32,\n  pub help_menu_max_lines: u32,\n  pub help_menu_offset: u32,\n  pub is_loading: bool,\n  io_tx: Option<Sender<IoEvent>>,\n  pub is_fetching_current_playback: bool,\n  pub spotify_token_expiry: SystemTime,\n  pub dialog: Option<String>,\n  pub confirm: bool,\n}\n\nimpl Default for App {\n  fn default() -> Self {\n    App {\n      audio_analysis: None,\n      album_table_context: AlbumTableContext::Full,\n      album_list_index: 0,\n      made_for_you_index: 0,\n      artists_list_index: 0,\n      shows_list_index: 0,\n      episode_list_index: 0,\n      artists: vec![],\n      artist: None,\n      user_config: UserConfig::new(),\n      saved_album_tracks_index: 0,\n      recently_played: Default::default(),\n      size: Rect::default(),\n      selected_album_simplified: None,\n      selected_album_full: None,\n      home_scroll: 0,\n      library: Library {\n        saved_tracks: ScrollableResultPages::new(),\n        made_for_you_playlists: ScrollableResultPages::new(),\n        saved_albums: ScrollableResultPages::new(),\n        saved_shows: ScrollableResultPages::new(),\n        saved_artists: ScrollableResultPages::new(),\n        show_episodes: ScrollableResultPages::new(),\n        selected_index: 0,\n      },\n      liked_song_ids_set: HashSet::new(),\n      followed_artist_ids_set: HashSet::new(),\n      saved_album_ids_set: HashSet::new(),\n      saved_show_ids_set: HashSet::new(),\n      navigation_stack: vec![DEFAULT_ROUTE],\n      large_search_limit: 20,\n      small_search_limit: 4,\n      api_error: String::new(),\n      current_playback_context: None,\n      devices: None,\n      input: vec![],\n      input_idx: 0,\n      input_cursor_position: 0,\n      playlist_offset: 0,\n      made_for_you_offset: 0,\n      playlist_tracks: None,\n      made_for_you_tracks: None,\n      playlists: None,\n      recommended_tracks: vec![],\n      recommendations_context: None,\n      recommendations_seed: \"\".to_string(),\n      search_results: SearchResult {\n        hovered_block: SearchResultBlock::SongSearch,\n        selected_block: SearchResultBlock::Empty,\n        albums: None,\n        artists: None,\n        playlists: None,\n        shows: None,\n        selected_album_index: None,\n        selected_artists_index: None,\n        selected_playlists_index: None,\n        selected_tracks_index: None,\n        selected_shows_index: None,\n        tracks: None,\n      },\n      song_progress_ms: 0,\n      seek_ms: None,\n      selected_device_index: None,\n      selected_playlist_index: None,\n      active_playlist_index: None,\n      track_table: Default::default(),\n      episode_table_context: EpisodeTableContext::Full,\n      selected_show_simplified: None,\n      selected_show_full: None,\n      user: None,\n      instant_since_last_current_playback_poll: Instant::now(),\n      clipboard: Clipboard::new().ok(),\n      help_docs_size: 0,\n      help_menu_page: 0,\n      help_menu_max_lines: 0,\n      help_menu_offset: 0,\n      is_loading: false,\n      io_tx: None,\n      is_fetching_current_playback: false,\n      spotify_token_expiry: SystemTime::now(),\n      dialog: None,\n      confirm: false,\n    }\n  }\n}\n\nimpl App {\n  pub fn new(\n    io_tx: Sender<IoEvent>,\n    user_config: UserConfig,\n    spotify_token_expiry: SystemTime,\n  ) -> App {\n    App {\n      io_tx: Some(io_tx),\n      user_config,\n      spotify_token_expiry,\n      ..App::default()\n    }\n  }\n\n  // Send a network event to the network thread\n  pub fn dispatch(&mut self, action: IoEvent) {\n    // `is_loading` will be set to false again after the async action has finished in network.rs\n    self.is_loading = true;\n    if let Some(io_tx) = &self.io_tx {\n      if let Err(e) = io_tx.send(action) {\n        self.is_loading = false;\n        println!(\"Error from dispatch {}\", e);\n        // TODO: handle error\n      };\n    }\n  }\n\n  fn apply_seek(&mut self, seek_ms: u32) {\n    if let Some(CurrentlyPlaybackContext {\n      item: Some(item), ..\n    }) = &self.current_playback_context\n    {\n      let duration_ms = match item {\n        PlayingItem::Track(track) => track.duration_ms,\n        PlayingItem::Episode(episode) => episode.duration_ms,\n      };\n\n      let event = if seek_ms < duration_ms {\n        IoEvent::Seek(seek_ms)\n      } else {\n        IoEvent::NextTrack\n      };\n\n      self.dispatch(event);\n    }\n  }\n\n  fn poll_current_playback(&mut self) {\n    // Poll every 5 seconds\n    let poll_interval_ms = 5_000;\n\n    let elapsed = self\n      .instant_since_last_current_playback_poll\n      .elapsed()\n      .as_millis();\n\n    if !self.is_fetching_current_playback && elapsed >= poll_interval_ms {\n      self.is_fetching_current_playback = true;\n      // Trigger the seek if the user has set a new position\n      match self.seek_ms {\n        Some(seek_ms) => self.apply_seek(seek_ms as u32),\n        None => self.dispatch(IoEvent::GetCurrentPlayback),\n      }\n    }\n  }\n\n  pub fn update_on_tick(&mut self) {\n    self.poll_current_playback();\n    if let Some(CurrentlyPlaybackContext {\n      item: Some(item),\n      progress_ms: Some(progress_ms),\n      is_playing,\n      ..\n    }) = &self.current_playback_context\n    {\n      // Update progress even when the song is not playing,\n      // because seeking is possible while paused\n      let elapsed = if *is_playing {\n        self\n          .instant_since_last_current_playback_poll\n          .elapsed()\n          .as_millis()\n      } else {\n        0u128\n      } + u128::from(*progress_ms);\n\n      let duration_ms = match item {\n        PlayingItem::Track(track) => track.duration_ms,\n        PlayingItem::Episode(episode) => episode.duration_ms,\n      };\n\n      if elapsed < u128::from(duration_ms) {\n        self.song_progress_ms = elapsed;\n      } else {\n        self.song_progress_ms = duration_ms.into();\n      }\n    }\n  }\n\n  pub fn seek_forwards(&mut self) {\n    if let Some(CurrentlyPlaybackContext {\n      item: Some(item), ..\n    }) = &self.current_playback_context\n    {\n      let duration_ms = match item {\n        PlayingItem::Track(track) => track.duration_ms,\n        PlayingItem::Episode(episode) => episode.duration_ms,\n      };\n\n      let old_progress = match self.seek_ms {\n        Some(seek_ms) => seek_ms,\n        None => self.song_progress_ms,\n      };\n\n      let new_progress = min(\n        old_progress as u32 + self.user_config.behavior.seek_milliseconds,\n        duration_ms,\n      );\n\n      self.seek_ms = Some(new_progress as u128);\n    }\n  }\n\n  pub fn seek_backwards(&mut self) {\n    let old_progress = match self.seek_ms {\n      Some(seek_ms) => seek_ms,\n      None => self.song_progress_ms,\n    };\n    let new_progress = if old_progress as u32 > self.user_config.behavior.seek_milliseconds {\n      old_progress as u32 - self.user_config.behavior.seek_milliseconds\n    } else {\n      0u32\n    };\n    self.seek_ms = Some(new_progress as u128);\n  }\n\n  pub fn get_recommendations_for_seed(\n    &mut self,\n    seed_artists: Option<Vec<String>>,\n    seed_tracks: Option<Vec<String>>,\n    first_track: Option<FullTrack>,\n  ) {\n    let user_country = self.get_user_country();\n    self.dispatch(IoEvent::GetRecommendationsForSeed(\n      seed_artists,\n      seed_tracks,\n      Box::new(first_track),\n      user_country,\n    ));\n  }\n\n  pub fn get_recommendations_for_track_id(&mut self, id: String) {\n    let user_country = self.get_user_country();\n    self.dispatch(IoEvent::GetRecommendationsForTrackId(id, user_country));\n  }\n\n  pub fn increase_volume(&mut self) {\n    if let Some(context) = self.current_playback_context.clone() {\n      let current_volume = context.device.volume_percent as u8;\n      let next_volume = min(\n        current_volume + self.user_config.behavior.volume_increment,\n        100,\n      );\n\n      if next_volume != current_volume {\n        self.dispatch(IoEvent::ChangeVolume(next_volume));\n      }\n    }\n  }\n\n  pub fn decrease_volume(&mut self) {\n    if let Some(context) = self.current_playback_context.clone() {\n      let current_volume = context.device.volume_percent as i8;\n      let next_volume = max(\n        current_volume - self.user_config.behavior.volume_increment as i8,\n        0,\n      );\n\n      if next_volume != current_volume {\n        self.dispatch(IoEvent::ChangeVolume(next_volume as u8));\n      }\n    }\n  }\n\n  pub fn handle_error(&mut self, e: anyhow::Error) {\n    self.push_navigation_stack(RouteId::Error, ActiveBlock::Error);\n    self.api_error = e.to_string();\n  }\n\n  pub fn toggle_playback(&mut self) {\n    if let Some(CurrentlyPlaybackContext {\n      is_playing: true, ..\n    }) = &self.current_playback_context\n    {\n      self.dispatch(IoEvent::PausePlayback);\n    } else {\n      // When no offset or uris are passed, spotify will resume current playback\n      self.dispatch(IoEvent::StartPlayback(None, None, None));\n    }\n  }\n\n  pub fn previous_track(&mut self) {\n    if self.song_progress_ms >= 3_000 {\n      self.dispatch(IoEvent::Seek(0));\n    } else {\n      self.dispatch(IoEvent::PreviousTrack);\n    }\n  }\n\n  // The navigation_stack actually only controls the large block to the right of `library` and\n  // `playlists`\n  pub fn push_navigation_stack(&mut self, next_route_id: RouteId, next_active_block: ActiveBlock) {\n    if !self\n      .navigation_stack\n      .last()\n      .map(|last_route| last_route.id == next_route_id)\n      .unwrap_or(false)\n    {\n      self.navigation_stack.push(Route {\n        id: next_route_id,\n        active_block: next_active_block,\n        hovered_block: next_active_block,\n      });\n    }\n  }\n\n  pub fn pop_navigation_stack(&mut self) -> Option<Route> {\n    if self.navigation_stack.len() == 1 {\n      None\n    } else {\n      self.navigation_stack.pop()\n    }\n  }\n\n  pub fn get_current_route(&self) -> &Route {\n    // if for some reason there is no route return the default\n    self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE)\n  }\n\n  fn get_current_route_mut(&mut self) -> &mut Route {\n    self.navigation_stack.last_mut().unwrap()\n  }\n\n  pub fn set_current_route_state(\n    &mut self,\n    active_block: Option<ActiveBlock>,\n    hovered_block: Option<ActiveBlock>,\n  ) {\n    let mut current_route = self.get_current_route_mut();\n    if let Some(active_block) = active_block {\n      current_route.active_block = active_block;\n    }\n    if let Some(hovered_block) = hovered_block {\n      current_route.hovered_block = hovered_block;\n    }\n  }\n\n  pub fn copy_song_url(&mut self) {\n    let clipboard = match &mut self.clipboard {\n      Some(ctx) => ctx,\n      None => return,\n    };\n\n    if let Some(CurrentlyPlaybackContext {\n      item: Some(item), ..\n    }) = &self.current_playback_context\n    {\n      match item {\n        PlayingItem::Track(track) => {\n          if let Err(e) = clipboard.set_text(format!(\n            \"https://open.spotify.com/track/{}\",\n            track.id.to_owned().unwrap_or_default()\n          )) {\n            self.handle_error(anyhow!(\"failed to set clipboard content: {}\", e));\n          }\n        }\n        PlayingItem::Episode(episode) => {\n          if let Err(e) = clipboard.set_text(format!(\n            \"https://open.spotify.com/episode/{}\",\n            episode.id.to_owned()\n          )) {\n            self.handle_error(anyhow!(\"failed to set clipboard content: {}\", e));\n          }\n        }\n      }\n    }\n  }\n\n  pub fn copy_album_url(&mut self) {\n    let clipboard = match &mut self.clipboard {\n      Some(ctx) => ctx,\n      None => return,\n    };\n\n    if let Some(CurrentlyPlaybackContext {\n      item: Some(item), ..\n    }) = &self.current_playback_context\n    {\n      match item {\n        PlayingItem::Track(track) => {\n          if let Err(e) = clipboard.set_text(format!(\n            \"https://open.spotify.com/album/{}\",\n            track.album.id.to_owned().unwrap_or_default()\n          )) {\n            self.handle_error(anyhow!(\"failed to set clipboard content: {}\", e));\n          }\n        }\n        PlayingItem::Episode(episode) => {\n          if let Err(e) = clipboard.set_text(format!(\n            \"https://open.spotify.com/show/{}\",\n            episode.show.id.to_owned()\n          )) {\n            self.handle_error(anyhow!(\"failed to set clipboard content: {}\", e));\n          }\n        }\n      }\n    }\n  }\n\n  pub fn set_saved_tracks_to_table(&mut self, saved_track_page: &Page<SavedTrack>) {\n    self.dispatch(IoEvent::SetTracksToTable(\n      saved_track_page\n        .items\n        .clone()\n        .into_iter()\n        .map(|item| item.track)\n        .collect::<Vec<FullTrack>>(),\n    ));\n  }\n\n  pub fn set_saved_artists_to_table(&mut self, saved_artists_page: &CursorBasedPage<FullArtist>) {\n    self.dispatch(IoEvent::SetArtistsToTable(\n      saved_artists_page\n        .items\n        .clone()\n        .into_iter()\n        .collect::<Vec<FullArtist>>(),\n    ))\n  }\n\n  pub fn get_current_user_saved_artists_next(&mut self) {\n    match self\n      .library\n      .saved_artists\n      .get_results(Some(self.library.saved_artists.index + 1))\n      .cloned()\n    {\n      Some(saved_artists) => {\n        self.set_saved_artists_to_table(&saved_artists);\n        self.library.saved_artists.index += 1\n      }\n      None => {\n        if let Some(saved_artists) = &self.library.saved_artists.clone().get_results(None) {\n          if let Some(last_artist) = saved_artists.items.last() {\n            self.dispatch(IoEvent::GetFollowedArtists(Some(last_artist.id.clone())));\n          }\n        }\n      }\n    }\n  }\n\n  pub fn get_current_user_saved_artists_previous(&mut self) {\n    if self.library.saved_artists.index > 0 {\n      self.library.saved_artists.index -= 1;\n    }\n\n    if let Some(saved_artists) = &self.library.saved_artists.get_results(None).cloned() {\n      self.set_saved_artists_to_table(saved_artists);\n    }\n  }\n\n  pub fn get_current_user_saved_tracks_next(&mut self) {\n    // Before fetching the next tracks, check if we have already fetched them\n    match self\n      .library\n      .saved_tracks\n      .get_results(Some(self.library.saved_tracks.index + 1))\n      .cloned()\n    {\n      Some(saved_tracks) => {\n        self.set_saved_tracks_to_table(&saved_tracks);\n        self.library.saved_tracks.index += 1\n      }\n      None => {\n        if let Some(saved_tracks) = &self.library.saved_tracks.get_results(None) {\n          let offset = Some(saved_tracks.offset + saved_tracks.limit);\n          self.dispatch(IoEvent::GetCurrentSavedTracks(offset));\n        }\n      }\n    }\n  }\n\n  pub fn get_current_user_saved_tracks_previous(&mut self) {\n    if self.library.saved_tracks.index > 0 {\n      self.library.saved_tracks.index -= 1;\n    }\n\n    if let Some(saved_tracks) = &self.library.saved_tracks.get_results(None).cloned() {\n      self.set_saved_tracks_to_table(saved_tracks);\n    }\n  }\n\n  pub fn shuffle(&mut self) {\n    if let Some(context) = &self.current_playback_context.clone() {\n      self.dispatch(IoEvent::Shuffle(context.shuffle_state));\n    };\n  }\n\n  pub fn get_current_user_saved_albums_next(&mut self) {\n    match self\n      .library\n      .saved_albums\n      .get_results(Some(self.library.saved_albums.index + 1))\n      .cloned()\n    {\n      Some(_) => self.library.saved_albums.index += 1,\n      None => {\n        if let Some(saved_albums) = &self.library.saved_albums.get_results(None) {\n          let offset = Some(saved_albums.offset + saved_albums.limit);\n          self.dispatch(IoEvent::GetCurrentUserSavedAlbums(offset));\n        }\n      }\n    }\n  }\n\n  pub fn get_current_user_saved_albums_previous(&mut self) {\n    if self.library.saved_albums.index > 0 {\n      self.library.saved_albums.index -= 1;\n    }\n  }\n\n  pub fn current_user_saved_album_delete(&mut self, block: ActiveBlock) {\n    match block {\n      ActiveBlock::SearchResultBlock => {\n        if let Some(albums) = &self.search_results.albums {\n          if let Some(selected_index) = self.search_results.selected_album_index {\n            let selected_album = &albums.items[selected_index];\n            if let Some(album_id) = selected_album.id.clone() {\n              self.dispatch(IoEvent::CurrentUserSavedAlbumDelete(album_id));\n            }\n          }\n        }\n      }\n      ActiveBlock::AlbumList => {\n        if let Some(albums) = self.library.saved_albums.get_results(None) {\n          if let Some(selected_album) = albums.items.get(self.album_list_index) {\n            let album_id = selected_album.album.id.clone();\n            self.dispatch(IoEvent::CurrentUserSavedAlbumDelete(album_id));\n          }\n        }\n      }\n      ActiveBlock::ArtistBlock => {\n        if let Some(artist) = &self.artist {\n          if let Some(selected_album) = artist.albums.items.get(artist.selected_album_index) {\n            if let Some(album_id) = selected_album.id.clone() {\n              self.dispatch(IoEvent::CurrentUserSavedAlbumDelete(album_id));\n            }\n          }\n        }\n      }\n      _ => (),\n    }\n  }\n\n  pub fn current_user_saved_album_add(&mut self, block: ActiveBlock) {\n    match block {\n      ActiveBlock::SearchResultBlock => {\n        if let Some(albums) = &self.search_results.albums {\n          if let Some(selected_index) = self.search_results.selected_album_index {\n            let selected_album = &albums.items[selected_index];\n            if let Some(album_id) = selected_album.id.clone() {\n              self.dispatch(IoEvent::CurrentUserSavedAlbumAdd(album_id));\n            }\n          }\n        }\n      }\n      ActiveBlock::ArtistBlock => {\n        if let Some(artist) = &self.artist {\n          if let Some(selected_album) = artist.albums.items.get(artist.selected_album_index) {\n            if let Some(album_id) = selected_album.id.clone() {\n              self.dispatch(IoEvent::CurrentUserSavedAlbumAdd(album_id));\n            }\n          }\n        }\n      }\n      _ => (),\n    }\n  }\n\n  pub fn get_current_user_saved_shows_next(&mut self) {\n    match self\n      .library\n      .saved_shows\n      .get_results(Some(self.library.saved_shows.index + 1))\n      .cloned()\n    {\n      Some(_) => self.library.saved_shows.index += 1,\n      None => {\n        if let Some(saved_shows) = &self.library.saved_shows.get_results(None) {\n          let offset = Some(saved_shows.offset + saved_shows.limit);\n          self.dispatch(IoEvent::GetCurrentUserSavedShows(offset));\n        }\n      }\n    }\n  }\n\n  pub fn get_current_user_saved_shows_previous(&mut self) {\n    if self.library.saved_shows.index > 0 {\n      self.library.saved_shows.index -= 1;\n    }\n  }\n\n  pub fn get_episode_table_next(&mut self, show_id: String) {\n    match self\n      .library\n      .show_episodes\n      .get_results(Some(self.library.show_episodes.index + 1))\n      .cloned()\n    {\n      Some(_) => self.library.show_episodes.index += 1,\n      None => {\n        if let Some(show_episodes) = &self.library.show_episodes.get_results(None) {\n          let offset = Some(show_episodes.offset + show_episodes.limit);\n          self.dispatch(IoEvent::GetCurrentShowEpisodes(show_id, offset));\n        }\n      }\n    }\n  }\n\n  pub fn get_episode_table_previous(&mut self) {\n    if self.library.show_episodes.index > 0 {\n      self.library.show_episodes.index -= 1;\n    }\n  }\n\n  pub fn user_unfollow_artists(&mut self, block: ActiveBlock) {\n    match block {\n      ActiveBlock::SearchResultBlock => {\n        if let Some(artists) = &self.search_results.artists {\n          if let Some(selected_index) = self.search_results.selected_artists_index {\n            let selected_artist: &FullArtist = &artists.items[selected_index];\n            let artist_id = selected_artist.id.clone();\n            self.dispatch(IoEvent::UserUnfollowArtists(vec![artist_id]));\n          }\n        }\n      }\n      ActiveBlock::AlbumList => {\n        if let Some(artists) = self.library.saved_artists.get_results(None) {\n          if let Some(selected_artist) = artists.items.get(self.artists_list_index) {\n            let artist_id = selected_artist.id.clone();\n            self.dispatch(IoEvent::UserUnfollowArtists(vec![artist_id]));\n          }\n        }\n      }\n      ActiveBlock::ArtistBlock => {\n        if let Some(artist) = &self.artist {\n          let selected_artis = &artist.related_artists[artist.selected_related_artist_index];\n          let artist_id = selected_artis.id.clone();\n          self.dispatch(IoEvent::UserUnfollowArtists(vec![artist_id]));\n        }\n      }\n      _ => (),\n    };\n  }\n\n  pub fn user_follow_artists(&mut self, block: ActiveBlock) {\n    match block {\n      ActiveBlock::SearchResultBlock => {\n        if let Some(artists) = &self.search_results.artists {\n          if let Some(selected_index) = self.search_results.selected_artists_index {\n            let selected_artist: &FullArtist = &artists.items[selected_index];\n            let artist_id = selected_artist.id.clone();\n            self.dispatch(IoEvent::UserFollowArtists(vec![artist_id]));\n          }\n        }\n      }\n      ActiveBlock::ArtistBlock => {\n        if let Some(artist) = &self.artist {\n          let selected_artis = &artist.related_artists[artist.selected_related_artist_index];\n          let artist_id = selected_artis.id.clone();\n          self.dispatch(IoEvent::UserFollowArtists(vec![artist_id]));\n        }\n      }\n      _ => (),\n    }\n  }\n\n  pub fn user_follow_playlist(&mut self) {\n    if let SearchResult {\n      playlists: Some(ref playlists),\n      selected_playlists_index: Some(selected_index),\n      ..\n    } = self.search_results\n    {\n      let selected_playlist: &SimplifiedPlaylist = &playlists.items[selected_index];\n      let selected_id = selected_playlist.id.clone();\n      let selected_public = selected_playlist.public;\n      let selected_owner_id = selected_playlist.owner.id.clone();\n      self.dispatch(IoEvent::UserFollowPlaylist(\n        selected_owner_id,\n        selected_id,\n        selected_public,\n      ));\n    }\n  }\n\n  pub fn user_unfollow_playlist(&mut self) {\n    if let (Some(playlists), Some(selected_index), Some(user)) =\n      (&self.playlists, self.selected_playlist_index, &self.user)\n    {\n      let selected_playlist = &playlists.items[selected_index];\n      let selected_id = selected_playlist.id.clone();\n      let user_id = user.id.clone();\n      self.dispatch(IoEvent::UserUnfollowPlaylist(user_id, selected_id))\n    }\n  }\n\n  pub fn user_unfollow_playlist_search_result(&mut self) {\n    if let (Some(playlists), Some(selected_index), Some(user)) = (\n      &self.search_results.playlists,\n      self.search_results.selected_playlists_index,\n      &self.user,\n    ) {\n      let selected_playlist = &playlists.items[selected_index];\n      let selected_id = selected_playlist.id.clone();\n      let user_id = user.id.clone();\n      self.dispatch(IoEvent::UserUnfollowPlaylist(user_id, selected_id))\n    }\n  }\n\n  pub fn user_follow_show(&mut self, block: ActiveBlock) {\n    match block {\n      ActiveBlock::SearchResultBlock => {\n        if let Some(shows) = &self.search_results.shows {\n          if let Some(selected_index) = self.search_results.selected_shows_index {\n            if let Some(show_id) = shows.items.get(selected_index).map(|item| item.id.clone()) {\n              self.dispatch(IoEvent::CurrentUserSavedShowAdd(show_id));\n            }\n          }\n        }\n      }\n      ActiveBlock::EpisodeTable => match self.episode_table_context {\n        EpisodeTableContext::Full => {\n          if let Some(selected_episode) = self.selected_show_full.clone() {\n            let show_id = selected_episode.show.id;\n            self.dispatch(IoEvent::CurrentUserSavedShowAdd(show_id));\n          }\n        }\n        EpisodeTableContext::Simplified => {\n          if let Some(selected_episode) = self.selected_show_simplified.clone() {\n            let show_id = selected_episode.show.id;\n            self.dispatch(IoEvent::CurrentUserSavedShowAdd(show_id));\n          }\n        }\n      },\n      _ => (),\n    }\n  }\n\n  pub fn user_unfollow_show(&mut self, block: ActiveBlock) {\n    match block {\n      ActiveBlock::Podcasts => {\n        if let Some(shows) = self.library.saved_shows.get_results(None) {\n          if let Some(selected_show) = shows.items.get(self.shows_list_index) {\n            let show_id = selected_show.show.id.clone();\n            self.dispatch(IoEvent::CurrentUserSavedShowDelete(show_id));\n          }\n        }\n      }\n      ActiveBlock::SearchResultBlock => {\n        if let Some(shows) = &self.search_results.shows {\n          if let Some(selected_index) = self.search_results.selected_shows_index {\n            let show_id = shows.items[selected_index].id.to_owned();\n            self.dispatch(IoEvent::CurrentUserSavedShowDelete(show_id));\n          }\n        }\n      }\n      ActiveBlock::EpisodeTable => match self.episode_table_context {\n        EpisodeTableContext::Full => {\n          if let Some(selected_episode) = self.selected_show_full.clone() {\n            let show_id = selected_episode.show.id;\n            self.dispatch(IoEvent::CurrentUserSavedShowDelete(show_id));\n          }\n        }\n        EpisodeTableContext::Simplified => {\n          if let Some(selected_episode) = self.selected_show_simplified.clone() {\n            let show_id = selected_episode.show.id;\n            self.dispatch(IoEvent::CurrentUserSavedShowDelete(show_id));\n          }\n        }\n      },\n      _ => (),\n    }\n  }\n\n  pub fn get_made_for_you(&mut self) {\n    // TODO: replace searches when relevant endpoint is added\n    const DISCOVER_WEEKLY: &str = \"Discover Weekly\";\n    const RELEASE_RADAR: &str = \"Release Radar\";\n    const ON_REPEAT: &str = \"On Repeat\";\n    const REPEAT_REWIND: &str = \"Repeat Rewind\";\n    const DAILY_DRIVE: &str = \"Daily Drive\";\n\n    if self.library.made_for_you_playlists.pages.is_empty() {\n      // We shouldn't be fetching all the results immediately - only load the data when the\n      // user selects the playlist\n      self.made_for_you_search_and_add(DISCOVER_WEEKLY);\n      self.made_for_you_search_and_add(RELEASE_RADAR);\n      self.made_for_you_search_and_add(ON_REPEAT);\n      self.made_for_you_search_and_add(REPEAT_REWIND);\n      self.made_for_you_search_and_add(DAILY_DRIVE);\n    }\n  }\n\n  fn made_for_you_search_and_add(&mut self, search_string: &str) {\n    let user_country = self.get_user_country();\n    self.dispatch(IoEvent::MadeForYouSearchAndAdd(\n      search_string.to_string(),\n      user_country,\n    ));\n  }\n\n  pub fn get_audio_analysis(&mut self) {\n    if let Some(CurrentlyPlaybackContext {\n      item: Some(item), ..\n    }) = &self.current_playback_context\n    {\n      match item {\n        PlayingItem::Track(track) => {\n          if self.get_current_route().id != RouteId::Analysis {\n            let uri = track.uri.clone();\n            self.dispatch(IoEvent::GetAudioAnalysis(uri));\n            self.push_navigation_stack(RouteId::Analysis, ActiveBlock::Analysis);\n          }\n        }\n        PlayingItem::Episode(_episode) => {\n          // No audio analysis available for podcast uris, so just default to the empty analysis\n          // view to avoid a 400 error code\n          self.push_navigation_stack(RouteId::Analysis, ActiveBlock::Analysis);\n        }\n      }\n    }\n  }\n\n  pub fn repeat(&mut self) {\n    if let Some(context) = &self.current_playback_context.clone() {\n      self.dispatch(IoEvent::Repeat(context.repeat_state));\n    }\n  }\n\n  pub fn get_artist(&mut self, artist_id: String, input_artist_name: String) {\n    let user_country = self.get_user_country();\n    self.dispatch(IoEvent::GetArtist(\n      artist_id,\n      input_artist_name,\n      user_country,\n    ));\n  }\n\n  pub fn get_user_country(&self) -> Option<Country> {\n    self\n      .user\n      .to_owned()\n      .and_then(|user| Country::from_str(&user.country.unwrap_or_else(|| \"\".to_string())).ok())\n  }\n\n  pub fn calculate_help_menu_offset(&mut self) {\n    let old_offset = self.help_menu_offset;\n\n    if self.help_menu_max_lines < self.help_docs_size {\n      self.help_menu_offset = self.help_menu_page * self.help_menu_max_lines;\n    }\n    if self.help_menu_offset > self.help_docs_size {\n      self.help_menu_offset = old_offset;\n      self.help_menu_page -= 1;\n    }\n  }\n}\n"
  },
  {
    "path": "src/banner.rs",
    "content": "pub const BANNER: &str = \"\n   _________  ____  / /_(_) __/_  __      / /___  __(_)\n  / ___/ __ \\\\/ __ \\\\/ __/ / /_/ / / /_____/ __/ / / / / \n (__  ) /_/ / /_/ / /_/ / __/ /_/ /_____/ /_/ /_/ / /  \n/____/ .___/\\\\____/\\\\__/_/_/  \\\\__, /      \\\\__/\\\\__,_/_/   \n    /_/                    /____/                      \n\";\n"
  },
  {
    "path": "src/cli/clap.rs",
    "content": "use clap::{App, Arg, ArgGroup, SubCommand};\n\nfn device_arg() -> Arg<'static, 'static> {\n  Arg::with_name(\"device\")\n    .short(\"d\")\n    .long(\"device\")\n    .takes_value(true)\n    .value_name(\"DEVICE\")\n    .help(\"Specifies the spotify device to use\")\n}\n\nfn format_arg() -> Arg<'static, 'static> {\n  Arg::with_name(\"format\")\n    .short(\"f\")\n    .long(\"format\")\n    .takes_value(true)\n    .value_name(\"FORMAT\")\n    .help(\"Specifies the output format\")\n    .long_help(\n      \"There are multiple format specifiers you can use: %a: artist, %b: album, %p: playlist, \\\n%t: track, %h: show, %f: flags (shuffle, repeat, like), %s: playback status, %v: volume, %d: current device. \\\nExample: spt pb -s -f 'playing on %d at %v%'\",\n    )\n}\n\npub fn playback_subcommand() -> App<'static, 'static> {\n  SubCommand::with_name(\"playback\")\n    .version(env!(\"CARGO_PKG_VERSION\"))\n    .author(env!(\"CARGO_PKG_AUTHORS\"))\n    .about(\"Interacts with the playback of a device\")\n    .long_about(\n      \"Use `playback` to interact with the playback of the current or any other device. \\\nYou can specify another device with `--device`. If no options were provided, spt \\\nwill default to just displaying the current playback. Actually, after every action \\\nspt will display the updated playback. The output format is configurable with the \\\n`--format` flag. Some options can be used together, other options have to be alone.\n\nHere's a list:\n\n* `--next` and `--previous` cannot be used with other options\n* `--status`, `--toggle`, `--transfer`, `--volume`, `--like`, `--repeat` and `--shuffle` \\\ncan be used together\n* `--share-track` and `--share-album` cannot be used with other options\",\n    )\n    .visible_alias(\"pb\")\n    .arg(device_arg())\n    .arg(\n      format_arg()\n        .default_value(\"%f %s %t - %a\")\n        .default_value_ifs(&[\n          (\"seek\", None, \"%f %s %t - %a %r\"),\n          (\"volume\", None, \"%v% %f %s %t - %a\"),\n          (\"transfer\", None, \"%f %s %t - %a on %d\"),\n        ]),\n    )\n    .arg(\n      Arg::with_name(\"toggle\")\n        .short(\"t\")\n        .long(\"toggle\")\n        .help(\"Pauses/resumes the playback of a device\"),\n    )\n    .arg(\n      Arg::with_name(\"status\")\n        .short(\"s\")\n        .long(\"status\")\n        .help(\"Prints out the current status of a device (default)\"),\n    )\n    .arg(\n      Arg::with_name(\"share-track\")\n        .long(\"share-track\")\n        .help(\"Returns the url to the current track\"),\n    )\n    .arg(\n      Arg::with_name(\"share-album\")\n        .long(\"share-album\")\n        .help(\"Returns the url to the album of the current track\"),\n    )\n    .arg(\n      Arg::with_name(\"transfer\")\n        .long(\"transfer\")\n        .takes_value(true)\n        .value_name(\"DEVICE\")\n        .help(\"Transfers the playback to new DEVICE\"),\n    )\n    .arg(\n      Arg::with_name(\"like\")\n        .long(\"like\")\n        .help(\"Likes the current song if possible\"),\n    )\n    .arg(\n      Arg::with_name(\"dislike\")\n        .long(\"dislike\")\n        .help(\"Dislikes the current song if possible\"),\n    )\n    .arg(\n      Arg::with_name(\"shuffle\")\n        .long(\"shuffle\")\n        .help(\"Toggles shuffle mode\"),\n    )\n    .arg(\n      Arg::with_name(\"repeat\")\n        .long(\"repeat\")\n        .help(\"Switches between repeat modes\"),\n    )\n    .arg(\n      Arg::with_name(\"next\")\n        .short(\"n\")\n        .long(\"next\")\n        .multiple(true)\n        .help(\"Jumps to the next song\")\n        .long_help(\n          \"This jumps to the next song if specied once. If you want to jump, let's say 3 songs \\\nforward, you can use `--next` 3 times: `spt pb -nnn`.\",\n        ),\n    )\n    .arg(\n      Arg::with_name(\"previous\")\n        .short(\"p\")\n        .long(\"previous\")\n        .multiple(true)\n        .help(\"Jumps to the previous song\")\n        .long_help(\n          \"This jumps to the beginning of the current song if specied once. You probably want to \\\njump to the previous song though, so you can use the previous flag twice: `spt pb -pp`. To jump \\\ntwo songs back, you can use `spt pb -ppp` and so on.\",\n        ),\n    )\n    .arg(\n      Arg::with_name(\"seek\")\n        .long(\"seek\")\n        .takes_value(true)\n        .value_name(\"±SECONDS\")\n        .allow_hyphen_values(true)\n        .help(\"Jumps SECONDS forwards (+) or backwards (-)\")\n        .long_help(\n          \"For example: `spt pb --seek +10` jumps ten second forwards, `spt pb --seek -10` ten \\\nseconds backwards and `spt pb --seek 10` to the tenth second of the track.\",\n        ),\n    )\n    .arg(\n      Arg::with_name(\"volume\")\n        .short(\"v\")\n        .long(\"volume\")\n        .takes_value(true)\n        .value_name(\"VOLUME\")\n        .help(\"Sets the volume of a device to VOLUME (1 - 100)\"),\n    )\n    .group(\n      ArgGroup::with_name(\"jumps\")\n        .args(&[\"next\", \"previous\"])\n        .multiple(false)\n        .conflicts_with_all(&[\"single\", \"flags\", \"actions\"]),\n    )\n    .group(\n      ArgGroup::with_name(\"likes\")\n        .args(&[\"like\", \"dislike\"])\n        .multiple(false),\n    )\n    .group(\n      ArgGroup::with_name(\"flags\")\n        .args(&[\"like\", \"dislike\", \"shuffle\", \"repeat\"])\n        .multiple(true)\n        .conflicts_with_all(&[\"single\", \"jumps\"]),\n    )\n    .group(\n      ArgGroup::with_name(\"actions\")\n        .args(&[\"toggle\", \"status\", \"transfer\", \"volume\"])\n        .multiple(true)\n        .conflicts_with_all(&[\"single\", \"jumps\"]),\n    )\n    .group(\n      ArgGroup::with_name(\"single\")\n        .args(&[\"share-track\", \"share-album\"])\n        .multiple(false)\n        .conflicts_with_all(&[\"actions\", \"flags\", \"jumps\"]),\n    )\n}\n\npub fn play_subcommand() -> App<'static, 'static> {\n  SubCommand::with_name(\"play\")\n    .version(env!(\"CARGO_PKG_VERSION\"))\n    .author(env!(\"CARGO_PKG_AUTHORS\"))\n    .about(\"Plays a uri or another spotify item by name\")\n    .long_about(\n      \"If you specify a uri, the type can be inferred. If you want to play something by \\\nname, you have to specify the type: `--track`, `--album`, `--artist`, `--playlist` \\\nor `--show`. The first item which was found will be played without confirmation. \\\nTo add a track to the queue, use `--queue`. To play a random song from a playlist, \\\nuse `--random`. Again, with `--format` you can specify how the output will look. \\\nThe same function as found in `playback` will be called.\",\n    )\n    .visible_alias(\"p\")\n    .arg(device_arg())\n    .arg(format_arg().default_value(\"%f %s %t - %a\"))\n    .arg(\n      Arg::with_name(\"uri\")\n        .short(\"u\")\n        .long(\"uri\")\n        .takes_value(true)\n        .value_name(\"URI\")\n        .help(\"Plays the URI\"),\n    )\n    .arg(\n      Arg::with_name(\"name\")\n        .short(\"n\")\n        .long(\"name\")\n        .takes_value(true)\n        .value_name(\"NAME\")\n        .requires(\"contexts\")\n        .help(\"Plays the first match with NAME from the specified category\"),\n    )\n    .arg(\n      Arg::with_name(\"queue\")\n        .short(\"q\")\n        .long(\"queue\")\n        // Only works with tracks\n        .conflicts_with_all(&[\"album\", \"artist\", \"playlist\", \"show\"])\n        .help(\"Adds track to queue instead of playing it directly\"),\n    )\n    .arg(\n      Arg::with_name(\"random\")\n        .short(\"r\")\n        .long(\"random\")\n        // Only works with playlists\n        .conflicts_with_all(&[\"track\", \"album\", \"artist\", \"show\"])\n        .help(\"Plays a random track (only works with playlists)\"),\n    )\n    .arg(\n      Arg::with_name(\"album\")\n        .short(\"b\")\n        .long(\"album\")\n        .help(\"Looks for an album\"),\n    )\n    .arg(\n      Arg::with_name(\"artist\")\n        .short(\"a\")\n        .long(\"artist\")\n        .help(\"Looks for an artist\"),\n    )\n    .arg(\n      Arg::with_name(\"track\")\n        .short(\"t\")\n        .long(\"track\")\n        .help(\"Looks for a track\"),\n    )\n    .arg(\n      Arg::with_name(\"show\")\n        .short(\"w\")\n        .long(\"show\")\n        .help(\"Looks for a show\"),\n    )\n    .arg(\n      Arg::with_name(\"playlist\")\n        .short(\"p\")\n        .long(\"playlist\")\n        .help(\"Looks for a playlist\"),\n    )\n    .group(\n      ArgGroup::with_name(\"contexts\")\n        .args(&[\"track\", \"artist\", \"playlist\", \"album\", \"show\"])\n        .multiple(false),\n    )\n    .group(\n      ArgGroup::with_name(\"actions\")\n        .args(&[\"uri\", \"name\"])\n        .multiple(false)\n        .required(true),\n    )\n}\n\npub fn list_subcommand() -> App<'static, 'static> {\n  SubCommand::with_name(\"list\")\n    .version(env!(\"CARGO_PKG_VERSION\"))\n    .author(env!(\"CARGO_PKG_AUTHORS\"))\n    .about(\"Lists devices, liked songs and playlists\")\n    .long_about(\n      \"This will list devices, liked songs or playlists. With the `--limit` flag you are \\\nable to specify the amount of results (between 1 and 50). Here, the `--format` is \\\neven more awesome, get your output exactly the way you want. The format option will \\\nbe applied to every item found.\",\n    )\n    .visible_alias(\"l\")\n    .arg(format_arg().default_value_ifs(&[\n      (\"devices\", None, \"%v% %d\"),\n      (\"liked\", None, \"%t - %a (%u)\"),\n      (\"playlists\", None, \"%p (%u)\"),\n    ]))\n    .arg(\n      Arg::with_name(\"devices\")\n        .short(\"d\")\n        .long(\"devices\")\n        .help(\"Lists devices\"),\n    )\n    .arg(\n      Arg::with_name(\"playlists\")\n        .short(\"p\")\n        .long(\"playlists\")\n        .help(\"Lists playlists\"),\n    )\n    .arg(\n      Arg::with_name(\"liked\")\n        .long(\"liked\")\n        .help(\"Lists liked songs\"),\n    )\n    .arg(\n      Arg::with_name(\"limit\")\n        .long(\"limit\")\n        .takes_value(true)\n        .help(\"Specifies the maximum number of results (1 - 50)\"),\n    )\n    .group(\n      ArgGroup::with_name(\"listable\")\n        .args(&[\"devices\", \"playlists\", \"liked\"])\n        .required(true)\n        .multiple(false),\n    )\n}\n\npub fn search_subcommand() -> App<'static, 'static> {\n  SubCommand::with_name(\"search\")\n    .version(env!(\"CARGO_PKG_VERSION\"))\n    .author(env!(\"CARGO_PKG_AUTHORS\"))\n    .about(\"Searches for tracks, albums and more\")\n    .long_about(\n      \"This will search for something on spotify and displays you the items. The output \\\nformat can be changed with the `--format` flag and the limit can be changed with \\\nthe `--limit` flag (between 1 and 50). The type can't be inferred, so you have to \\\nspecify it.\",\n    )\n    .visible_alias(\"s\")\n    .arg(format_arg().default_value_ifs(&[\n      (\"tracks\", None, \"%t - %a (%u)\"),\n      (\"playlists\", None, \"%p (%u)\"),\n      (\"artists\", None, \"%a (%u)\"),\n      (\"albums\", None, \"%b - %a (%u)\"),\n      (\"shows\", None, \"%h - %a (%u)\"),\n    ]))\n    .arg(\n      Arg::with_name(\"search\")\n        .required(true)\n        .takes_value(true)\n        .value_name(\"SEARCH\")\n        .help(\"Specifies the search query\"),\n    )\n    .arg(\n      Arg::with_name(\"albums\")\n        .short(\"b\")\n        .long(\"albums\")\n        .help(\"Looks for albums\"),\n    )\n    .arg(\n      Arg::with_name(\"artists\")\n        .short(\"a\")\n        .long(\"artists\")\n        .help(\"Looks for artists\"),\n    )\n    .arg(\n      Arg::with_name(\"playlists\")\n        .short(\"p\")\n        .long(\"playlists\")\n        .help(\"Looks for playlists\"),\n    )\n    .arg(\n      Arg::with_name(\"tracks\")\n        .short(\"t\")\n        .long(\"tracks\")\n        .help(\"Looks for tracks\"),\n    )\n    .arg(\n      Arg::with_name(\"shows\")\n        .short(\"w\")\n        .long(\"shows\")\n        .help(\"Looks for shows\"),\n    )\n    .arg(\n      Arg::with_name(\"limit\")\n        .long(\"limit\")\n        .takes_value(true)\n        .help(\"Specifies the maximum number of results (1 - 50)\"),\n    )\n    .group(\n      ArgGroup::with_name(\"searchable\")\n        .args(&[\"playlists\", \"tracks\", \"albums\", \"artists\", \"shows\"])\n        .required(true)\n        .multiple(false),\n    )\n}\n"
  },
  {
    "path": "src/cli/cli_app.rs",
    "content": "use crate::network::{IoEvent, Network};\nuse crate::user_config::UserConfig;\n\nuse super::util::{Flag, Format, FormatType, JumpDirection, Type};\n\nuse anyhow::{anyhow, Result};\nuse rand::{thread_rng, Rng};\nuse rspotify::model::{context::CurrentlyPlaybackContext, PlayingItem};\n\npub struct CliApp<'a> {\n  pub net: Network<'a>,\n  pub config: UserConfig,\n}\n\n// Non-concurrent functions\n// I feel that async in a cli is not working\n// I just .await all processes and directly interact\n// by calling network.handle_network_event\nimpl<'a> CliApp<'a> {\n  pub fn new(net: Network<'a>, config: UserConfig) -> Self {\n    Self { net, config }\n  }\n\n  async fn is_a_saved_track(&mut self, id: &str) -> bool {\n    // Update the liked_song_ids_set\n    self\n      .net\n      .handle_network_event(IoEvent::CurrentUserSavedTracksContains(\n        vec![id.to_string()],\n      ))\n      .await;\n    self.net.app.lock().await.liked_song_ids_set.contains(id)\n  }\n\n  pub fn format_output(&self, mut format: String, values: Vec<Format>) -> String {\n    for val in values {\n      format = format.replace(val.get_placeholder(), &val.inner(self.config.clone()));\n    }\n    // Replace unsupported flags with 'None'\n    for p in &[\"%a\", \"%b\", \"%t\", \"%p\", \"%h\", \"%u\", \"%d\", \"%v\", \"%f\", \"%s\"] {\n      format = format.replace(p, \"None\");\n    }\n    format.trim().to_string()\n  }\n\n  // spt playback -t\n  pub async fn toggle_playback(&mut self) {\n    let context = self.net.app.lock().await.current_playback_context.clone();\n    if let Some(c) = context {\n      if c.is_playing {\n        self.net.handle_network_event(IoEvent::PausePlayback).await;\n        return;\n      }\n    }\n    self\n      .net\n      .handle_network_event(IoEvent::StartPlayback(None, None, None))\n      .await;\n  }\n\n  // spt pb --share-track (share the current playing song)\n  // Basically copy-pasted the 'copy_song_url' function\n  pub async fn share_track_or_episode(&mut self) -> Result<String> {\n    let app = self.net.app.lock().await;\n    if let Some(CurrentlyPlaybackContext {\n      item: Some(item), ..\n    }) = &app.current_playback_context\n    {\n      match item {\n        PlayingItem::Track(track) => Ok(format!(\n          \"https://open.spotify.com/track/{}\",\n          track.id.to_owned().unwrap_or_default()\n        )),\n        PlayingItem::Episode(episode) => Ok(format!(\n          \"https://open.spotify.com/episode/{}\",\n          episode.id.to_owned()\n        )),\n      }\n    } else {\n      Err(anyhow!(\n        \"failed to generate a shareable url for the current song\"\n      ))\n    }\n  }\n\n  // spt pb --share-album (share the current album)\n  // Basically copy-pasted the 'copy_album_url' function\n  pub async fn share_album_or_show(&mut self) -> Result<String> {\n    let app = self.net.app.lock().await;\n    if let Some(CurrentlyPlaybackContext {\n      item: Some(item), ..\n    }) = &app.current_playback_context\n    {\n      match item {\n        PlayingItem::Track(track) => Ok(format!(\n          \"https://open.spotify.com/album/{}\",\n          track.album.id.to_owned().unwrap_or_default()\n        )),\n        PlayingItem::Episode(episode) => Ok(format!(\n          \"https://open.spotify.com/show/{}\",\n          episode.show.id.to_owned()\n        )),\n      }\n    } else {\n      Err(anyhow!(\n        \"failed to generate a shareable url for the current song\"\n      ))\n    }\n  }\n\n  // spt ... -d ... (specify device to control)\n  pub async fn set_device(&mut self, name: String) -> Result<()> {\n    // Change the device if specified by user\n    let mut app = self.net.app.lock().await;\n    let mut device_index = 0;\n    if let Some(dp) = &app.devices {\n      for (i, d) in dp.devices.iter().enumerate() {\n        if d.name == name {\n          device_index = i;\n          // Save the id of the device\n          self\n            .net\n            .client_config\n            .set_device_id(d.id.clone())\n            .map_err(|_e| anyhow!(\"failed to use device with name '{}'\", d.name))?;\n        }\n      }\n    } else {\n      // Error out if no device is available\n      return Err(anyhow!(\"no device available\"));\n    }\n    app.selected_device_index = Some(device_index);\n    Ok(())\n  }\n\n  // spt query ... --limit LIMIT (set max search limit)\n  pub async fn update_query_limits(&mut self, max: String) -> Result<()> {\n    let num = max\n      .parse::<u32>()\n      .map_err(|_e| anyhow!(\"limit must be between 1 and 50\"))?;\n\n    // 50 seems to be the maximum limit\n    if num > 50 || num == 0 {\n      return Err(anyhow!(\"limit must be between 1 and 50\"));\n    };\n\n    self\n      .net\n      .handle_network_event(IoEvent::UpdateSearchLimits(num, num))\n      .await;\n    Ok(())\n  }\n\n  pub async fn volume(&mut self, vol: String) -> Result<()> {\n    let num = vol\n      .parse::<u32>()\n      .map_err(|_e| anyhow!(\"volume must be between 0 and 100\"))?;\n\n    // Check if it's in range\n    if num > 100 {\n      return Err(anyhow!(\"volume must be between 0 and 100\"));\n    };\n\n    self\n      .net\n      .handle_network_event(IoEvent::ChangeVolume(num as u8))\n      .await;\n    Ok(())\n  }\n\n  // spt playback --next / --previous\n  pub async fn jump(&mut self, d: &JumpDirection) {\n    match d {\n      JumpDirection::Next => self.net.handle_network_event(IoEvent::NextTrack).await,\n      JumpDirection::Previous => self.net.handle_network_event(IoEvent::PreviousTrack).await,\n    }\n  }\n\n  // spt query -l ...\n  pub async fn list(&mut self, item: Type, format: &str) -> String {\n    match item {\n      Type::Device => {\n        if let Some(devices) = &self.net.app.lock().await.devices {\n          devices\n            .devices\n            .iter()\n            .map(|d| {\n              self.format_output(\n                format.to_string(),\n                vec![\n                  Format::Device(d.name.clone()),\n                  Format::Volume(d.volume_percent),\n                ],\n              )\n            })\n            .collect::<Vec<String>>()\n            .join(\"\\n\")\n        } else {\n          \"No devices available\".to_string()\n        }\n      }\n      Type::Playlist => {\n        self.net.handle_network_event(IoEvent::GetPlaylists).await;\n        if let Some(playlists) = &self.net.app.lock().await.playlists {\n          playlists\n            .items\n            .iter()\n            .map(|p| {\n              self.format_output(\n                format.to_string(),\n                Format::from_type(FormatType::Playlist(Box::new(p.clone()))),\n              )\n            })\n            .collect::<Vec<String>>()\n            .join(\"\\n\")\n        } else {\n          \"No playlists found\".to_string()\n        }\n      }\n      Type::Liked => {\n        self\n          .net\n          .handle_network_event(IoEvent::GetCurrentSavedTracks(None))\n          .await;\n        let liked_songs = self\n          .net\n          .app\n          .lock()\n          .await\n          .track_table\n          .tracks\n          .iter()\n          .map(|t| {\n            self.format_output(\n              format.to_string(),\n              Format::from_type(FormatType::Track(Box::new(t.clone()))),\n            )\n          })\n          .collect::<Vec<String>>();\n        // Check if there are any liked songs\n        if liked_songs.is_empty() {\n          \"No liked songs found\".to_string()\n        } else {\n          liked_songs.join(\"\\n\")\n        }\n      }\n      // Enforced by clap\n      _ => unreachable!(),\n    }\n  }\n\n  // spt playback --transfer DEVICE\n  pub async fn transfer_playback(&mut self, device: &str) -> Result<()> {\n    // Get the device id by name\n    let mut id = String::new();\n    if let Some(devices) = &self.net.app.lock().await.devices {\n      for d in &devices.devices {\n        if d.name == device {\n          id.push_str(d.id.as_str());\n          break;\n        }\n      }\n    };\n\n    if id.is_empty() {\n      Err(anyhow!(\"no device with name '{}'\", device))\n    } else {\n      self\n        .net\n        .handle_network_event(IoEvent::TransferPlaybackToDevice(id.to_string()))\n        .await;\n      Ok(())\n    }\n  }\n\n  pub async fn seek(&mut self, seconds_str: String) -> Result<()> {\n    let seconds = match seconds_str.parse::<i32>() {\n      Ok(s) => s.abs() as u32,\n      Err(_) => return Err(anyhow!(\"failed to convert seconds to i32\")),\n    };\n\n    let (current_pos, duration) = {\n      self\n        .net\n        .handle_network_event(IoEvent::GetCurrentPlayback)\n        .await;\n      let app = self.net.app.lock().await;\n      if let Some(CurrentlyPlaybackContext {\n        progress_ms: Some(ms),\n        item: Some(item),\n        ..\n      }) = &app.current_playback_context\n      {\n        let duration = match item {\n          PlayingItem::Track(track) => track.duration_ms,\n          PlayingItem::Episode(episode) => episode.duration_ms,\n        };\n\n        (*ms as u32, duration)\n      } else {\n        return Err(anyhow!(\"no context available\"));\n      }\n    };\n\n    // Convert secs to ms\n    let ms = seconds * 1000;\n    // Calculate new positon\n    let position_to_seek = if seconds_str.starts_with('+') {\n      current_pos + ms\n    } else if seconds_str.starts_with('-') {\n      // Jump to the beginning if the position_to_seek would be\n      // negative, must be checked before the calculation to avoid\n      // an 'underflow'\n      if ms > current_pos {\n        0u32\n      } else {\n        current_pos - ms\n      }\n    } else {\n      // Absolute value of the track\n      seconds * 1000\n    };\n\n    // Check if position_to_seek is greater than duration (next track)\n    if position_to_seek > duration {\n      self.jump(&JumpDirection::Next).await;\n    } else {\n      // This seeks to a position in the current song\n      self\n        .net\n        .handle_network_event(IoEvent::Seek(position_to_seek))\n        .await;\n    }\n\n    Ok(())\n  }\n\n  // spt playback --like / --dislike / --shuffle / --repeat\n  pub async fn mark(&mut self, flag: Flag) -> Result<()> {\n    let c = {\n      let app = self.net.app.lock().await;\n      app\n        .current_playback_context\n        .clone()\n        .ok_or_else(|| anyhow!(\"no context available\"))?\n    };\n\n    match flag {\n      Flag::Like(s) => {\n        // Get the id of the current song\n        let id = match c.item {\n          Some(i) => match i {\n            PlayingItem::Track(t) => t.id.ok_or_else(|| anyhow!(\"item has no id\")),\n            PlayingItem::Episode(_) => Err(anyhow!(\"saving episodes not yet implemented\")),\n          },\n          None => Err(anyhow!(\"no item playing\")),\n        }?;\n\n        // Want to like but is already liked -> do nothing\n        // Want to like and is not liked yet -> like\n        if s && !self.is_a_saved_track(&id).await {\n          self\n            .net\n            .handle_network_event(IoEvent::ToggleSaveTrack(id))\n            .await;\n        // Want to dislike but is already disliked -> do nothing\n        // Want to dislike and is liked currently -> remove like\n        } else if !s && self.is_a_saved_track(&id).await {\n          self\n            .net\n            .handle_network_event(IoEvent::ToggleSaveTrack(id))\n            .await;\n        }\n      }\n      Flag::Shuffle => {\n        self\n          .net\n          .handle_network_event(IoEvent::Shuffle(c.shuffle_state))\n          .await\n      }\n      Flag::Repeat => {\n        self\n          .net\n          .handle_network_event(IoEvent::Repeat(c.repeat_state))\n          .await;\n      }\n    }\n\n    Ok(())\n  }\n\n  // spt playback -s\n  pub async fn get_status(&mut self, format: String) -> Result<String> {\n    // Update info on current playback\n    self\n      .net\n      .handle_network_event(IoEvent::GetCurrentPlayback)\n      .await;\n    self\n      .net\n      .handle_network_event(IoEvent::GetCurrentSavedTracks(None))\n      .await;\n\n    let context = self\n      .net\n      .app\n      .lock()\n      .await\n      .current_playback_context\n      .clone()\n      .ok_or_else(|| anyhow!(\"no context available\"))?;\n\n    let playing_item = context.item.ok_or_else(|| anyhow!(\"no track playing\"))?;\n\n    let mut hs = match playing_item {\n      PlayingItem::Track(track) => {\n        let id = track.id.clone().unwrap_or_default();\n        let mut hs = Format::from_type(FormatType::Track(Box::new(track.clone())));\n        if let Some(ms) = context.progress_ms {\n          hs.push(Format::Position((ms, track.duration_ms)))\n        }\n        hs.push(Format::Flags((\n          context.repeat_state,\n          context.shuffle_state,\n          self.is_a_saved_track(&id).await,\n        )));\n        hs\n      }\n      PlayingItem::Episode(episode) => {\n        let mut hs = Format::from_type(FormatType::Episode(Box::new(episode.clone())));\n        if let Some(ms) = context.progress_ms {\n          hs.push(Format::Position((ms, episode.duration_ms)))\n        }\n        hs.push(Format::Flags((\n          context.repeat_state,\n          context.shuffle_state,\n          false,\n        )));\n        hs\n      }\n    };\n\n    hs.push(Format::Device(context.device.name));\n    hs.push(Format::Volume(context.device.volume_percent));\n    hs.push(Format::Playing(context.is_playing));\n\n    Ok(self.format_output(format, hs))\n  }\n\n  // spt play -u URI\n  pub async fn play_uri(&mut self, uri: String, queue: bool, random: bool) {\n    let offset = if random {\n      // Only works with playlists for now\n      if uri.contains(\"spotify:playlist:\") {\n        let id = uri.split(':').last().unwrap();\n        match self.net.spotify.playlist(id, None, None).await {\n          Ok(p) => {\n            let num = p.tracks.total;\n            Some(thread_rng().gen_range(0..num) as usize)\n          }\n          Err(e) => {\n            self\n              .net\n              .app\n              .lock()\n              .await\n              .handle_error(anyhow!(e.to_string()));\n            return;\n          }\n        }\n      } else {\n        None\n      }\n    } else {\n      None\n    };\n\n    if uri.contains(\"spotify:track:\") {\n      if queue {\n        self\n          .net\n          .handle_network_event(IoEvent::AddItemToQueue(uri))\n          .await;\n      } else {\n        self\n          .net\n          .handle_network_event(IoEvent::StartPlayback(\n            None,\n            Some(vec![uri.clone()]),\n            Some(0),\n          ))\n          .await;\n      }\n    } else {\n      self\n        .net\n        .handle_network_event(IoEvent::StartPlayback(Some(uri.clone()), None, offset))\n        .await;\n    }\n  }\n\n  // spt play -n NAME ...\n  pub async fn play(&mut self, name: String, item: Type, queue: bool, random: bool) -> Result<()> {\n    self\n      .net\n      .handle_network_event(IoEvent::GetSearchResults(name.clone(), None))\n      .await;\n    // Get the uri of the first found\n    // item + the offset or return an error message\n    let uri = {\n      let results = &self.net.app.lock().await.search_results;\n      match item {\n        Type::Track => {\n          if let Some(r) = &results.tracks {\n            r.items[0].uri.clone()\n          } else {\n            return Err(anyhow!(\"no tracks with name '{}'\", name));\n          }\n        }\n        Type::Album => {\n          if let Some(r) = &results.albums {\n            let album = &r.items[0];\n            if let Some(uri) = &album.uri {\n              uri.clone()\n            } else {\n              return Err(anyhow!(\"album {} has no uri\", album.name));\n            }\n          } else {\n            return Err(anyhow!(\"no albums with name '{}'\", name));\n          }\n        }\n        Type::Artist => {\n          if let Some(r) = &results.artists {\n            r.items[0].uri.clone()\n          } else {\n            return Err(anyhow!(\"no artists with name '{}'\", name));\n          }\n        }\n        Type::Show => {\n          if let Some(r) = &results.shows {\n            r.items[0].uri.clone()\n          } else {\n            return Err(anyhow!(\"no shows with name '{}'\", name));\n          }\n        }\n        Type::Playlist => {\n          if let Some(r) = &results.playlists {\n            let p = &r.items[0];\n            // For a random song, create a random offset\n            p.uri.clone()\n          } else {\n            return Err(anyhow!(\"no playlists with name '{}'\", name));\n          }\n        }\n        _ => unreachable!(),\n      }\n    };\n\n    // Play or queue the uri\n    self.play_uri(uri, queue, random).await;\n\n    Ok(())\n  }\n\n  // spt query -s SEARCH ...\n  pub async fn query(&mut self, search: String, format: String, item: Type) -> String {\n    self\n      .net\n      .handle_network_event(IoEvent::GetSearchResults(search.clone(), None))\n      .await;\n\n    let app = self.net.app.lock().await;\n    match item {\n      Type::Playlist => {\n        if let Some(results) = &app.search_results.playlists {\n          results\n            .items\n            .iter()\n            .map(|r| {\n              self.format_output(\n                format.clone(),\n                Format::from_type(FormatType::Playlist(Box::new(r.clone()))),\n              )\n            })\n            .collect::<Vec<String>>()\n            .join(\"\\n\")\n        } else {\n          format!(\"no playlists with name '{}'\", search)\n        }\n      }\n      Type::Track => {\n        if let Some(results) = &app.search_results.tracks {\n          results\n            .items\n            .iter()\n            .map(|r| {\n              self.format_output(\n                format.clone(),\n                Format::from_type(FormatType::Track(Box::new(r.clone()))),\n              )\n            })\n            .collect::<Vec<String>>()\n            .join(\"\\n\")\n        } else {\n          format!(\"no tracks with name '{}'\", search)\n        }\n      }\n      Type::Artist => {\n        if let Some(results) = &app.search_results.artists {\n          results\n            .items\n            .iter()\n            .map(|r| {\n              self.format_output(\n                format.clone(),\n                Format::from_type(FormatType::Artist(Box::new(r.clone()))),\n              )\n            })\n            .collect::<Vec<String>>()\n            .join(\"\\n\")\n        } else {\n          format!(\"no artists with name '{}'\", search)\n        }\n      }\n      Type::Show => {\n        if let Some(results) = &app.search_results.shows {\n          results\n            .items\n            .iter()\n            .map(|r| {\n              self.format_output(\n                format.clone(),\n                Format::from_type(FormatType::Show(Box::new(r.clone()))),\n              )\n            })\n            .collect::<Vec<String>>()\n            .join(\"\\n\")\n        } else {\n          format!(\"no shows with name '{}'\", search)\n        }\n      }\n      Type::Album => {\n        if let Some(results) = &app.search_results.albums {\n          results\n            .items\n            .iter()\n            .map(|r| {\n              self.format_output(\n                format.clone(),\n                Format::from_type(FormatType::Album(Box::new(r.clone()))),\n              )\n            })\n            .collect::<Vec<String>>()\n            .join(\"\\n\")\n        } else {\n          format!(\"no albums with name '{}'\", search)\n        }\n      }\n      // Enforced by clap\n      _ => unreachable!(),\n    }\n  }\n}\n"
  },
  {
    "path": "src/cli/handle.rs",
    "content": "use crate::network::{IoEvent, Network};\nuse crate::user_config::UserConfig;\n\nuse super::{\n  util::{Flag, JumpDirection, Type},\n  CliApp,\n};\n\nuse anyhow::{anyhow, Result};\nuse clap::ArgMatches;\n\n// Handle the different subcommands\npub async fn handle_matches(\n  matches: &ArgMatches<'_>,\n  cmd: String,\n  net: Network<'_>,\n  config: UserConfig,\n) -> Result<String> {\n  let mut cli = CliApp::new(net, config);\n\n  cli.net.handle_network_event(IoEvent::GetDevices).await;\n  cli\n    .net\n    .handle_network_event(IoEvent::GetCurrentPlayback)\n    .await;\n\n  let devices_list = match &cli.net.app.lock().await.devices {\n    Some(p) => p\n      .devices\n      .iter()\n      .map(|d| d.id.clone())\n      .collect::<Vec<String>>(),\n    None => Vec::new(),\n  };\n\n  // If the device_id is not specified, select the first available device\n  let device_id = cli.net.client_config.device_id.clone();\n  if device_id.is_none() || !devices_list.contains(&device_id.unwrap()) {\n    // Select the first device available\n    if let Some(d) = devices_list.get(0) {\n      cli.net.client_config.set_device_id(d.clone())?;\n    }\n  }\n\n  if let Some(d) = matches.value_of(\"device\") {\n    cli.set_device(d.to_string()).await?;\n  }\n\n  // Evalute the subcommand\n  let output = match cmd.as_str() {\n    \"playback\" => {\n      let format = matches.value_of(\"format\").unwrap();\n\n      // Commands that are 'single'\n      if matches.is_present(\"share-track\") {\n        return cli.share_track_or_episode().await;\n      } else if matches.is_present(\"share-album\") {\n        return cli.share_album_or_show().await;\n      }\n\n      // Run the action, and print out the status\n      // No 'else if's because multiple different commands are possible\n      if matches.is_present(\"toggle\") {\n        cli.toggle_playback().await;\n      }\n      if let Some(d) = matches.value_of(\"transfer\") {\n        cli.transfer_playback(d).await?;\n      }\n      // Multiple flags are possible\n      if matches.is_present(\"flags\") {\n        let flags = Flag::from_matches(matches);\n        for f in flags {\n          cli.mark(f).await?;\n        }\n      }\n      if matches.is_present(\"jumps\") {\n        let (direction, amount) = JumpDirection::from_matches(matches);\n        for _ in 0..amount {\n          cli.jump(&direction).await;\n        }\n      }\n      if let Some(vol) = matches.value_of(\"volume\") {\n        cli.volume(vol.to_string()).await?;\n      }\n      if let Some(secs) = matches.value_of(\"seek\") {\n        cli.seek(secs.to_string()).await?;\n      }\n\n      // Print out the status if no errors were found\n      cli.get_status(format.to_string()).await\n    }\n    \"play\" => {\n      let queue = matches.is_present(\"queue\");\n      let random = matches.is_present(\"random\");\n      let format = matches.value_of(\"format\").unwrap();\n\n      if let Some(uri) = matches.value_of(\"uri\") {\n        cli.play_uri(uri.to_string(), queue, random).await;\n      } else if let Some(name) = matches.value_of(\"name\") {\n        let category = Type::play_from_matches(matches);\n        cli.play(name.to_string(), category, queue, random).await?;\n      }\n\n      cli.get_status(format.to_string()).await\n    }\n    \"list\" => {\n      let format = matches.value_of(\"format\").unwrap().to_string();\n\n      // Update the limits for the list and search functions\n      // I think the small and big search limits are very confusing\n      // so I just set them both to max, is this okay?\n      if let Some(max) = matches.value_of(\"limit\") {\n        cli.update_query_limits(max.to_string()).await?;\n      }\n\n      let category = Type::list_from_matches(matches);\n      Ok(cli.list(category, &format).await)\n    }\n    \"search\" => {\n      let format = matches.value_of(\"format\").unwrap().to_string();\n\n      // Update the limits for the list and search functions\n      // I think the small and big search limits are very confusing\n      // so I just set them both to max, is this okay?\n      if let Some(max) = matches.value_of(\"limit\") {\n        cli.update_query_limits(max.to_string()).await?;\n      }\n\n      let category = Type::search_from_matches(matches);\n      Ok(\n        cli\n          .query(\n            matches.value_of(\"search\").unwrap().to_string(),\n            format,\n            category,\n          )\n          .await,\n      )\n    }\n    // Clap enforces that one of the things above is specified\n    _ => unreachable!(),\n  };\n\n  // Check if there was an error\n  let api_error = cli.net.app.lock().await.api_error.clone();\n  if api_error.is_empty() {\n    output\n  } else {\n    Err(anyhow!(\"{}\", api_error))\n  }\n}\n"
  },
  {
    "path": "src/cli/mod.rs",
    "content": "mod clap;\nmod cli_app;\nmod handle;\nmod util;\n\npub use self::clap::{list_subcommand, play_subcommand, playback_subcommand, search_subcommand};\nuse cli_app::CliApp;\npub use handle::handle_matches;\n"
  },
  {
    "path": "src/cli/util.rs",
    "content": "use clap::ArgMatches;\nuse rspotify::{\n  model::{\n    album::SimplifiedAlbum, artist::FullArtist, artist::SimplifiedArtist,\n    playlist::SimplifiedPlaylist, show::FullEpisode, show::SimplifiedShow, track::FullTrack,\n  },\n  senum::RepeatState,\n};\n\nuse crate::user_config::UserConfig;\n\n// Possible types to list or search\n#[derive(Debug)]\npub enum Type {\n  Playlist,\n  Track,\n  Artist,\n  Album,\n  Show,\n  Device,\n  Liked,\n}\n\nimpl Type {\n  pub fn play_from_matches(m: &ArgMatches<'_>) -> Self {\n    if m.is_present(\"playlist\") {\n      Self::Playlist\n    } else if m.is_present(\"track\") {\n      Self::Track\n    } else if m.is_present(\"artist\") {\n      Self::Artist\n    } else if m.is_present(\"album\") {\n      Self::Album\n    } else if m.is_present(\"show\") {\n      Self::Show\n    }\n    // Enforced by clap\n    else {\n      unreachable!()\n    }\n  }\n\n  pub fn search_from_matches(m: &ArgMatches<'_>) -> Self {\n    if m.is_present(\"playlists\") {\n      Self::Playlist\n    } else if m.is_present(\"tracks\") {\n      Self::Track\n    } else if m.is_present(\"artists\") {\n      Self::Artist\n    } else if m.is_present(\"albums\") {\n      Self::Album\n    } else if m.is_present(\"shows\") {\n      Self::Show\n    }\n    // Enforced by clap\n    else {\n      unreachable!()\n    }\n  }\n\n  pub fn list_from_matches(m: &ArgMatches<'_>) -> Self {\n    if m.is_present(\"playlists\") {\n      Self::Playlist\n    } else if m.is_present(\"devices\") {\n      Self::Device\n    } else if m.is_present(\"liked\") {\n      Self::Liked\n    }\n    // Enforced by clap\n    else {\n      unreachable!()\n    }\n  }\n}\n\n//\n// Possible flags to set\n//\n\npub enum Flag {\n  // Does not get toggled\n  // * User chooses like -> Flag::Like(true)\n  // * User chooses dislike -> Flag::Like(false)\n  Like(bool),\n  Shuffle,\n  Repeat,\n}\n\nimpl Flag {\n  pub fn from_matches(m: &ArgMatches<'_>) -> Vec<Self> {\n    // Multiple flags are possible\n    let mut flags = Vec::new();\n\n    // Only one of these two\n    if m.is_present(\"like\") {\n      flags.push(Self::Like(true));\n    } else if m.is_present(\"dislike\") {\n      flags.push(Self::Like(false));\n    }\n\n    if m.is_present(\"shuffle\") {\n      flags.push(Self::Shuffle);\n    }\n    if m.is_present(\"repeat\") {\n      flags.push(Self::Repeat);\n    }\n    flags\n  }\n}\n\n// Possible directions to jump to\npub enum JumpDirection {\n  Next,\n  Previous,\n}\n\nimpl JumpDirection {\n  pub fn from_matches(m: &ArgMatches<'_>) -> (Self, u64) {\n    if m.is_present(\"next\") {\n      (Self::Next, m.occurrences_of(\"next\"))\n    } else if m.is_present(\"previous\") {\n      (Self::Previous, m.occurrences_of(\"previous\"))\n    // Enforced by clap\n    } else {\n      unreachable!()\n    }\n  }\n}\n\n// For fomatting (-f / --format flag)\n\n// Types to create a Format enum from\n// Boxing was proposed by cargo clippy\n// to reduce the size of this enum\npub enum FormatType {\n  Album(Box<SimplifiedAlbum>),\n  Artist(Box<FullArtist>),\n  Playlist(Box<SimplifiedPlaylist>),\n  Track(Box<FullTrack>),\n  Episode(Box<FullEpisode>),\n  Show(Box<SimplifiedShow>),\n}\n\n// Types that can be formatted\n#[derive(Clone)]\npub enum Format {\n  Album(String),\n  Artist(String),\n  Playlist(String),\n  Track(String),\n  Show(String),\n  Uri(String),\n  Device(String),\n  Volume(u32),\n  // Current position, duration\n  Position((u32, u32)),\n  // This is a bit long, should it be splitted up?\n  Flags((RepeatState, bool, bool)),\n  Playing(bool),\n}\n\npub fn join_artists(a: Vec<SimplifiedArtist>) -> String {\n  a.iter()\n    .map(|l| l.name.clone())\n    .collect::<Vec<String>>()\n    .join(\", \")\n}\n\nimpl Format {\n  // Extract important information from types\n  pub fn from_type(t: FormatType) -> Vec<Self> {\n    match t {\n      FormatType::Album(a) => {\n        let joined_artists = join_artists(a.artists.clone());\n        let mut vec = vec![Self::Album(a.name), Self::Artist(joined_artists)];\n        if let Some(uri) = a.uri {\n          vec.push(Self::Uri(uri));\n        }\n        vec\n      }\n      FormatType::Artist(a) => vec![Self::Artist(a.name), Self::Uri(a.uri)],\n      FormatType::Playlist(p) => vec![Self::Playlist(p.name), Self::Uri(p.uri)],\n      FormatType::Track(t) => {\n        let joined_artists = join_artists(t.artists.clone());\n        vec![\n          Self::Album(t.album.name),\n          Self::Artist(joined_artists),\n          Self::Track(t.name),\n          Self::Uri(t.uri),\n        ]\n      }\n      FormatType::Show(r) => vec![\n        Self::Artist(r.publisher),\n        Self::Show(r.name),\n        Self::Uri(r.uri),\n      ],\n      FormatType::Episode(e) => vec![\n        Self::Show(e.show.name),\n        Self::Artist(e.show.publisher),\n        Self::Track(e.name),\n        Self::Uri(e.uri),\n      ],\n    }\n  }\n\n  // Is there a better way?\n  pub fn inner(&self, conf: UserConfig) -> String {\n    match self {\n      Self::Album(s) => s.clone(),\n      Self::Artist(s) => s.clone(),\n      Self::Playlist(s) => s.clone(),\n      Self::Track(s) => s.clone(),\n      Self::Show(s) => s.clone(),\n      Self::Uri(s) => s.clone(),\n      Self::Device(s) => s.clone(),\n      // Because this match statements\n      // needs to return a &String, I have to do it this way\n      Self::Volume(s) => s.to_string(),\n      Self::Position((curr, duration)) => {\n        crate::ui::util::display_track_progress(*curr as u128, *duration)\n      }\n      Self::Flags((r, s, l)) => {\n        let like = if *l {\n          conf.behavior.liked_icon\n        } else {\n          String::new()\n        };\n        let shuffle = if *s {\n          conf.behavior.shuffle_icon\n        } else {\n          String::new()\n        };\n        let repeat = match r {\n          RepeatState::Off => String::new(),\n          RepeatState::Track => conf.behavior.repeat_track_icon,\n          RepeatState::Context => conf.behavior.repeat_context_icon,\n        };\n\n        // Add them together (only those that aren't empty)\n        [shuffle, repeat, like]\n          .iter()\n          .filter(|a| !a.is_empty())\n          // Convert &String to String to join them\n          .map(|s| s.to_string())\n          .collect::<Vec<String>>()\n          .join(\" \")\n      }\n      Self::Playing(s) => {\n        if *s {\n          conf.behavior.playing_icon\n        } else {\n          conf.behavior.paused_icon\n        }\n      }\n    }\n  }\n\n  pub fn get_placeholder(&self) -> &str {\n    match self {\n      Self::Album(_) => \"%b\",\n      Self::Artist(_) => \"%a\",\n      Self::Playlist(_) => \"%p\",\n      Self::Track(_) => \"%t\",\n      Self::Show(_) => \"%h\",\n      Self::Uri(_) => \"%u\",\n      Self::Device(_) => \"%d\",\n      Self::Volume(_) => \"%v\",\n      Self::Position(_) => \"%r\",\n      Self::Flags(_) => \"%f\",\n      Self::Playing(_) => \"%s\",\n    }\n  }\n}\n"
  },
  {
    "path": "src/config.rs",
    "content": "use super::banner::BANNER;\nuse anyhow::{anyhow, Error, Result};\nuse serde::{Deserialize, Serialize};\nuse std::{\n  fs,\n  io::{stdin, Write},\n  path::{Path, PathBuf},\n};\n\nconst DEFAULT_PORT: u16 = 8888;\nconst FILE_NAME: &str = \"client.yml\";\nconst CONFIG_DIR: &str = \".config\";\nconst APP_CONFIG_DIR: &str = \"spotify-tui\";\nconst TOKEN_CACHE_FILE: &str = \".spotify_token_cache.json\";\n\n#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]\npub struct ClientConfig {\n  pub client_id: String,\n  pub client_secret: String,\n  pub device_id: Option<String>,\n  // FIXME: port should be defined in `user_config` not in here\n  pub port: Option<u16>,\n}\n\npub struct ConfigPaths {\n  pub config_file_path: PathBuf,\n  pub token_cache_path: PathBuf,\n}\n\nimpl ClientConfig {\n  pub fn new() -> ClientConfig {\n    ClientConfig {\n      client_id: \"\".to_string(),\n      client_secret: \"\".to_string(),\n      device_id: None,\n      port: None,\n    }\n  }\n\n  pub fn get_redirect_uri(&self) -> String {\n    format!(\"http://localhost:{}/callback\", self.get_port())\n  }\n\n  pub fn get_port(&self) -> u16 {\n    self.port.unwrap_or(DEFAULT_PORT)\n  }\n\n  pub fn get_or_build_paths(&self) -> Result<ConfigPaths> {\n    match dirs::home_dir() {\n      Some(home) => {\n        let path = Path::new(&home);\n        let home_config_dir = path.join(CONFIG_DIR);\n        let app_config_dir = home_config_dir.join(APP_CONFIG_DIR);\n\n        if !home_config_dir.exists() {\n          fs::create_dir(&home_config_dir)?;\n        }\n\n        if !app_config_dir.exists() {\n          fs::create_dir(&app_config_dir)?;\n        }\n\n        let config_file_path = &app_config_dir.join(FILE_NAME);\n        let token_cache_path = &app_config_dir.join(TOKEN_CACHE_FILE);\n\n        let paths = ConfigPaths {\n          config_file_path: config_file_path.to_path_buf(),\n          token_cache_path: token_cache_path.to_path_buf(),\n        };\n\n        Ok(paths)\n      }\n      None => Err(anyhow!(\"No $HOME directory found for client config\")),\n    }\n  }\n\n  pub fn set_device_id(&mut self, device_id: String) -> Result<()> {\n    let paths = self.get_or_build_paths()?;\n    let config_string = fs::read_to_string(&paths.config_file_path)?;\n    let mut config_yml: ClientConfig = serde_yaml::from_str(&config_string)?;\n\n    self.device_id = Some(device_id.clone());\n    config_yml.device_id = Some(device_id);\n\n    let new_config = serde_yaml::to_string(&config_yml)?;\n    let mut config_file = fs::File::create(&paths.config_file_path)?;\n    write!(config_file, \"{}\", new_config)?;\n    Ok(())\n  }\n\n  pub fn load_config(&mut self) -> Result<()> {\n    let paths = self.get_or_build_paths()?;\n    if paths.config_file_path.exists() {\n      let config_string = fs::read_to_string(&paths.config_file_path)?;\n      let config_yml: ClientConfig = serde_yaml::from_str(&config_string)?;\n\n      self.client_id = config_yml.client_id;\n      self.client_secret = config_yml.client_secret;\n      self.device_id = config_yml.device_id;\n      self.port = config_yml.port;\n\n      Ok(())\n    } else {\n      println!(\"{}\", BANNER);\n\n      println!(\n        \"Config will be saved to {}\",\n        paths.config_file_path.display()\n      );\n\n      println!(\"\\nHow to get setup:\\n\");\n\n      let instructions = [\n        \"Go to the Spotify dashboard - https://developer.spotify.com/dashboard/applications\",\n        \"Click `Create a Client ID` and create an app\",\n        \"Now click `Edit Settings`\",\n        &format!(\n          \"Add `http://localhost:{}/callback` to the Redirect URIs\",\n          DEFAULT_PORT\n        ),\n        \"You are now ready to authenticate with Spotify!\",\n      ];\n\n      let mut number = 1;\n      for item in instructions.iter() {\n        println!(\"  {}. {}\", number, item);\n        number += 1;\n      }\n\n      let client_id = ClientConfig::get_client_key_from_input(\"Client ID\")?;\n      let client_secret = ClientConfig::get_client_key_from_input(\"Client Secret\")?;\n\n      let mut port = String::new();\n      println!(\"\\nEnter port of redirect uri (default {}): \", DEFAULT_PORT);\n      stdin().read_line(&mut port)?;\n      let port = port.trim().parse::<u16>().unwrap_or(DEFAULT_PORT);\n\n      let config_yml = ClientConfig {\n        client_id,\n        client_secret,\n        device_id: None,\n        port: Some(port),\n      };\n\n      let content_yml = serde_yaml::to_string(&config_yml)?;\n\n      let mut new_config = fs::File::create(&paths.config_file_path)?;\n      write!(new_config, \"{}\", content_yml)?;\n\n      self.client_id = config_yml.client_id;\n      self.client_secret = config_yml.client_secret;\n      self.device_id = config_yml.device_id;\n      self.port = config_yml.port;\n\n      Ok(())\n    }\n  }\n\n  fn get_client_key_from_input(type_label: &'static str) -> Result<String> {\n    let mut client_key = String::new();\n    const MAX_RETRIES: u8 = 5;\n    let mut num_retries = 0;\n    loop {\n      println!(\"\\nEnter your {}: \", type_label);\n      stdin().read_line(&mut client_key)?;\n      client_key = client_key.trim().to_string();\n      match ClientConfig::validate_client_key(&client_key) {\n        Ok(_) => return Ok(client_key),\n        Err(error_string) => {\n          println!(\"{}\", error_string);\n          client_key.clear();\n          num_retries += 1;\n          if num_retries == MAX_RETRIES {\n            return Err(Error::from(std::io::Error::new(\n              std::io::ErrorKind::Other,\n              format!(\"Maximum retries ({}) exceeded.\", MAX_RETRIES),\n            )));\n          }\n        }\n      };\n    }\n  }\n\n  fn validate_client_key(key: &str) -> Result<()> {\n    const EXPECTED_LEN: usize = 32;\n    if key.len() != EXPECTED_LEN {\n      Err(Error::from(std::io::Error::new(\n        std::io::ErrorKind::InvalidInput,\n        format!(\"invalid length: {} (must be {})\", key.len(), EXPECTED_LEN,),\n      )))\n    } else if !key.chars().all(|c| c.is_digit(16)) {\n      Err(Error::from(std::io::Error::new(\n        std::io::ErrorKind::InvalidInput,\n        \"invalid character found (must be hex digits)\",\n      )))\n    } else {\n      Ok(())\n    }\n  }\n}\n"
  },
  {
    "path": "src/event/events.rs",
    "content": "use crate::event::Key;\nuse crossterm::event;\nuse std::{sync::mpsc, thread, time::Duration};\n\n#[derive(Debug, Clone, Copy)]\n/// Configuration for event handling.\npub struct EventConfig {\n  /// The key that is used to exit the application.\n  pub exit_key: Key,\n  /// The tick rate at which the application will sent an tick event.\n  pub tick_rate: Duration,\n}\n\nimpl Default for EventConfig {\n  fn default() -> EventConfig {\n    EventConfig {\n      exit_key: Key::Ctrl('c'),\n      tick_rate: Duration::from_millis(250),\n    }\n  }\n}\n\n/// An occurred event.\npub enum Event<I> {\n  /// An input event occurred.\n  Input(I),\n  /// An tick event occurred.\n  Tick,\n}\n\n/// A small event handler that wrap crossterm input and tick event. Each event\n/// type is handled in its own thread and returned to a common `Receiver`\npub struct Events {\n  rx: mpsc::Receiver<Event<Key>>,\n  // Need to be kept around to prevent disposing the sender side.\n  _tx: mpsc::Sender<Event<Key>>,\n}\n\nimpl Events {\n  /// Constructs an new instance of `Events` with the default config.\n  pub fn new(tick_rate: u64) -> Events {\n    Events::with_config(EventConfig {\n      tick_rate: Duration::from_millis(tick_rate),\n      ..Default::default()\n    })\n  }\n\n  /// Constructs an new instance of `Events` from given config.\n  pub fn with_config(config: EventConfig) -> Events {\n    let (tx, rx) = mpsc::channel();\n\n    let event_tx = tx.clone();\n    thread::spawn(move || {\n      loop {\n        // poll for tick rate duration, if no event, sent tick event.\n        if event::poll(config.tick_rate).unwrap() {\n          if let event::Event::Key(key) = event::read().unwrap() {\n            let key = Key::from(key);\n\n            event_tx.send(Event::Input(key)).unwrap();\n          }\n        }\n\n        event_tx.send(Event::Tick).unwrap();\n      }\n    });\n\n    Events { rx, _tx: tx }\n  }\n\n  /// Attempts to read an event.\n  /// This function will block the current thread.\n  pub fn next(&self) -> Result<Event<Key>, mpsc::RecvError> {\n    self.rx.recv()\n  }\n}\n"
  },
  {
    "path": "src/event/key.rs",
    "content": "use crossterm::event;\nuse std::fmt;\n\n/// Represents an key.\n#[derive(PartialEq, Eq, Clone, Copy, Hash, Debug)]\npub enum Key {\n  /// Both Enter (or Return) and numpad Enter\n  Enter,\n  /// Tabulation key\n  Tab,\n  /// Backspace key\n  Backspace,\n  /// Escape key\n  Esc,\n\n  /// Left arrow\n  Left,\n  /// Right arrow\n  Right,\n  /// Up arrow\n  Up,\n  /// Down arrow\n  Down,\n\n  /// Insert key\n  Ins,\n  /// Delete key\n  Delete,\n  /// Home key\n  Home,\n  /// End key\n  End,\n  /// Page Up key\n  PageUp,\n  /// Page Down key\n  PageDown,\n\n  /// F0 key\n  F0,\n  /// F1 key\n  F1,\n  /// F2 key\n  F2,\n  /// F3 key\n  F3,\n  /// F4 key\n  F4,\n  /// F5 key\n  F5,\n  /// F6 key\n  F6,\n  /// F7 key\n  F7,\n  /// F8 key\n  F8,\n  /// F9 key\n  F9,\n  /// F10 key\n  F10,\n  /// F11 key\n  F11,\n  /// F12 key\n  F12,\n  Char(char),\n  Ctrl(char),\n  Alt(char),\n  Unknown,\n}\n\nimpl Key {\n  /// Returns the function key corresponding to the given number\n  ///\n  /// 1 -> F1, etc...\n  ///\n  /// # Panics\n  ///\n  /// If `n == 0 || n > 12`\n  pub fn from_f(n: u8) -> Key {\n    match n {\n      0 => Key::F0,\n      1 => Key::F1,\n      2 => Key::F2,\n      3 => Key::F3,\n      4 => Key::F4,\n      5 => Key::F5,\n      6 => Key::F6,\n      7 => Key::F7,\n      8 => Key::F8,\n      9 => Key::F9,\n      10 => Key::F10,\n      11 => Key::F11,\n      12 => Key::F12,\n      _ => panic!(\"unknown function key: F{}\", n),\n    }\n  }\n}\n\nimpl fmt::Display for Key {\n  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n    match *self {\n      Key::Alt(' ') => write!(f, \"<Alt+Space>\"),\n      Key::Ctrl(' ') => write!(f, \"<Ctrl+Space>\"),\n      Key::Char(' ') => write!(f, \"<Space>\"),\n      Key::Alt(c) => write!(f, \"<Alt+{}>\", c),\n      Key::Ctrl(c) => write!(f, \"<Ctrl+{}>\", c),\n      Key::Char(c) => write!(f, \"{}\", c),\n      Key::Left | Key::Right | Key::Up | Key::Down => write!(f, \"<{:?} Arrow Key>\", self),\n      Key::Enter\n      | Key::Tab\n      | Key::Backspace\n      | Key::Esc\n      | Key::Ins\n      | Key::Delete\n      | Key::Home\n      | Key::End\n      | Key::PageUp\n      | Key::PageDown => write!(f, \"<{:?}>\", self),\n      _ => write!(f, \"{:?}\", self),\n    }\n  }\n}\n\nimpl From<event::KeyEvent> for Key {\n  fn from(key_event: event::KeyEvent) -> Self {\n    match key_event {\n      event::KeyEvent {\n        code: event::KeyCode::Esc,\n        ..\n      } => Key::Esc,\n      event::KeyEvent {\n        code: event::KeyCode::Backspace,\n        ..\n      } => Key::Backspace,\n      event::KeyEvent {\n        code: event::KeyCode::Left,\n        ..\n      } => Key::Left,\n      event::KeyEvent {\n        code: event::KeyCode::Right,\n        ..\n      } => Key::Right,\n      event::KeyEvent {\n        code: event::KeyCode::Up,\n        ..\n      } => Key::Up,\n      event::KeyEvent {\n        code: event::KeyCode::Down,\n        ..\n      } => Key::Down,\n      event::KeyEvent {\n        code: event::KeyCode::Home,\n        ..\n      } => Key::Home,\n      event::KeyEvent {\n        code: event::KeyCode::End,\n        ..\n      } => Key::End,\n      event::KeyEvent {\n        code: event::KeyCode::PageUp,\n        ..\n      } => Key::PageUp,\n      event::KeyEvent {\n        code: event::KeyCode::PageDown,\n        ..\n      } => Key::PageDown,\n      event::KeyEvent {\n        code: event::KeyCode::Delete,\n        ..\n      } => Key::Delete,\n      event::KeyEvent {\n        code: event::KeyCode::Insert,\n        ..\n      } => Key::Ins,\n      event::KeyEvent {\n        code: event::KeyCode::F(n),\n        ..\n      } => Key::from_f(n),\n      event::KeyEvent {\n        code: event::KeyCode::Enter,\n        ..\n      } => Key::Enter,\n      event::KeyEvent {\n        code: event::KeyCode::Tab,\n        ..\n      } => Key::Tab,\n\n      // First check for char + modifier\n      event::KeyEvent {\n        code: event::KeyCode::Char(c),\n        modifiers: event::KeyModifiers::ALT,\n      } => Key::Alt(c),\n      event::KeyEvent {\n        code: event::KeyCode::Char(c),\n        modifiers: event::KeyModifiers::CONTROL,\n      } => Key::Ctrl(c),\n\n      event::KeyEvent {\n        code: event::KeyCode::Char(c),\n        ..\n      } => Key::Char(c),\n\n      _ => Key::Unknown,\n    }\n  }\n}\n"
  },
  {
    "path": "src/event/mod.rs",
    "content": "mod events;\nmod key;\n\npub use self::{\n  events::{Event, Events},\n  key::Key,\n};\n"
  },
  {
    "path": "src/handlers/album_list.rs",
    "content": "use super::common_key_events;\nuse crate::{\n  app::{ActiveBlock, AlbumTableContext, App, RouteId, SelectedFullAlbum},\n  event::Key,\n};\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),\n    k if common_key_events::down_event(k) => {\n      if let Some(albums) = &mut app.library.saved_albums.get_results(None) {\n        let next_index =\n          common_key_events::on_down_press_handler(&albums.items, Some(app.album_list_index));\n        app.album_list_index = next_index;\n      }\n    }\n    k if common_key_events::up_event(k) => {\n      if let Some(albums) = &mut app.library.saved_albums.get_results(None) {\n        let next_index =\n          common_key_events::on_up_press_handler(&albums.items, Some(app.album_list_index));\n        app.album_list_index = next_index;\n      }\n    }\n    k if common_key_events::high_event(k) => {\n      if let Some(_albums) = app.library.saved_albums.get_results(None) {\n        let next_index = common_key_events::on_high_press_handler();\n        app.album_list_index = next_index;\n      }\n    }\n    k if common_key_events::middle_event(k) => {\n      if let Some(albums) = app.library.saved_albums.get_results(None) {\n        let next_index = common_key_events::on_middle_press_handler(&albums.items);\n        app.album_list_index = next_index;\n      }\n    }\n    k if common_key_events::low_event(k) => {\n      if let Some(albums) = app.library.saved_albums.get_results(None) {\n        let next_index = common_key_events::on_low_press_handler(&albums.items);\n        app.album_list_index = next_index;\n      }\n    }\n    Key::Enter => {\n      if let Some(albums) = app.library.saved_albums.get_results(None) {\n        if let Some(selected_album) = albums.items.get(app.album_list_index) {\n          app.selected_album_full = Some(SelectedFullAlbum {\n            album: selected_album.album.clone(),\n            selected_index: 0,\n          });\n          app.album_table_context = AlbumTableContext::Full;\n          app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks);\n        };\n      }\n    }\n    k if k == app.user_config.keys.next_page => app.get_current_user_saved_albums_next(),\n    k if k == app.user_config.keys.previous_page => app.get_current_user_saved_albums_previous(),\n    Key::Char('D') => app.current_user_saved_album_delete(ActiveBlock::AlbumList),\n    _ => {}\n  };\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n\n  #[test]\n  fn on_left_press() {\n    let mut app = App::default();\n    app.set_current_route_state(\n      Some(ActiveBlock::AlbumTracks),\n      Some(ActiveBlock::AlbumTracks),\n    );\n\n    handler(Key::Left, &mut app);\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.active_block, ActiveBlock::Empty);\n    assert_eq!(current_route.hovered_block, ActiveBlock::Library);\n  }\n\n  #[test]\n  fn on_esc() {\n    let mut app = App::default();\n\n    handler(Key::Esc, &mut app);\n\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.active_block, ActiveBlock::Empty);\n  }\n}\n"
  },
  {
    "path": "src/handlers/album_tracks.rs",
    "content": "use super::common_key_events;\nuse crate::{\n  app::{AlbumTableContext, App, RecommendationsContext},\n  event::Key,\n  network::IoEvent,\n};\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),\n    k if common_key_events::down_event(k) => match app.album_table_context {\n      AlbumTableContext::Full => {\n        if let Some(selected_album) = &app.selected_album_full {\n          let next_index = common_key_events::on_down_press_handler(\n            &selected_album.album.tracks.items,\n            Some(app.saved_album_tracks_index),\n          );\n          app.saved_album_tracks_index = next_index;\n        };\n      }\n      AlbumTableContext::Simplified => {\n        if let Some(selected_album_simplified) = &mut app.selected_album_simplified {\n          let next_index = common_key_events::on_down_press_handler(\n            &selected_album_simplified.tracks.items,\n            Some(selected_album_simplified.selected_index),\n          );\n          selected_album_simplified.selected_index = next_index;\n        }\n      }\n    },\n    k if common_key_events::up_event(k) => match app.album_table_context {\n      AlbumTableContext::Full => {\n        if let Some(selected_album) = &app.selected_album_full {\n          let next_index = common_key_events::on_up_press_handler(\n            &selected_album.album.tracks.items,\n            Some(app.saved_album_tracks_index),\n          );\n          app.saved_album_tracks_index = next_index;\n        };\n      }\n      AlbumTableContext::Simplified => {\n        if let Some(selected_album_simplified) = &mut app.selected_album_simplified {\n          let next_index = common_key_events::on_up_press_handler(\n            &selected_album_simplified.tracks.items,\n            Some(selected_album_simplified.selected_index),\n          );\n          selected_album_simplified.selected_index = next_index;\n        }\n      }\n    },\n    k if common_key_events::high_event(k) => handle_high_event(app),\n    k if common_key_events::middle_event(k) => handle_middle_event(app),\n    k if common_key_events::low_event(k) => handle_low_event(app),\n    Key::Char('s') => handle_save_event(app),\n    Key::Char('w') => handle_save_album_event(app),\n    Key::Enter => match app.album_table_context {\n      AlbumTableContext::Full => {\n        if let Some(selected_album) = app.selected_album_full.clone() {\n          app.dispatch(IoEvent::StartPlayback(\n            Some(selected_album.album.uri),\n            None,\n            Some(app.saved_album_tracks_index),\n          ));\n        };\n      }\n      AlbumTableContext::Simplified => {\n        if let Some(selected_album_simplified) = &app.selected_album_simplified.clone() {\n          app.dispatch(IoEvent::StartPlayback(\n            selected_album_simplified.album.uri.clone(),\n            None,\n            Some(selected_album_simplified.selected_index),\n          ));\n        };\n      }\n    },\n    //recommended playlist based on selected track\n    Key::Char('r') => {\n      handle_recommended_tracks(app);\n    }\n    _ if key == app.user_config.keys.add_item_to_queue => match app.album_table_context {\n      AlbumTableContext::Full => {\n        if let Some(selected_album) = app.selected_album_full.clone() {\n          if let Some(track) = selected_album\n            .album\n            .tracks\n            .items\n            .get(app.saved_album_tracks_index)\n          {\n            app.dispatch(IoEvent::AddItemToQueue(track.uri.clone()));\n          }\n        };\n      }\n      AlbumTableContext::Simplified => {\n        if let Some(selected_album_simplified) = &app.selected_album_simplified.clone() {\n          if let Some(track) = selected_album_simplified\n            .tracks\n            .items\n            .get(selected_album_simplified.selected_index)\n          {\n            app.dispatch(IoEvent::AddItemToQueue(track.uri.clone()));\n          }\n        };\n      }\n    },\n    _ => {}\n  };\n}\n\nfn handle_high_event(app: &mut App) {\n  match app.album_table_context {\n    AlbumTableContext::Full => {\n      let next_index = common_key_events::on_high_press_handler();\n      app.saved_album_tracks_index = next_index;\n    }\n    AlbumTableContext::Simplified => {\n      if let Some(selected_album_simplified) = &mut app.selected_album_simplified {\n        let next_index = common_key_events::on_high_press_handler();\n        selected_album_simplified.selected_index = next_index;\n      }\n    }\n  }\n}\n\nfn handle_middle_event(app: &mut App) {\n  match app.album_table_context {\n    AlbumTableContext::Full => {\n      if let Some(selected_album) = &app.selected_album_full {\n        let next_index =\n          common_key_events::on_middle_press_handler(&selected_album.album.tracks.items);\n        app.saved_album_tracks_index = next_index;\n      };\n    }\n    AlbumTableContext::Simplified => {\n      if let Some(selected_album_simplified) = &mut app.selected_album_simplified {\n        let next_index =\n          common_key_events::on_middle_press_handler(&selected_album_simplified.tracks.items);\n        selected_album_simplified.selected_index = next_index;\n      }\n    }\n  }\n}\n\nfn handle_low_event(app: &mut App) {\n  match app.album_table_context {\n    AlbumTableContext::Full => {\n      if let Some(selected_album) = &app.selected_album_full {\n        let next_index =\n          common_key_events::on_low_press_handler(&selected_album.album.tracks.items);\n        app.saved_album_tracks_index = next_index;\n      };\n    }\n    AlbumTableContext::Simplified => {\n      if let Some(selected_album_simplified) = &mut app.selected_album_simplified {\n        let next_index =\n          common_key_events::on_low_press_handler(&selected_album_simplified.tracks.items);\n        selected_album_simplified.selected_index = next_index;\n      }\n    }\n  }\n}\n\nfn handle_recommended_tracks(app: &mut App) {\n  match app.album_table_context {\n    AlbumTableContext::Full => {\n      if let Some(albums) = &app.library.clone().saved_albums.get_results(None) {\n        if let Some(selected_album) = albums.items.get(app.album_list_index) {\n          if let Some(track) = &selected_album\n            .album\n            .tracks\n            .items\n            .get(app.saved_album_tracks_index)\n          {\n            if let Some(id) = &track.id {\n              app.recommendations_context = Some(RecommendationsContext::Song);\n              app.recommendations_seed = track.name.clone();\n              app.get_recommendations_for_track_id(id.to_string());\n            }\n          }\n        }\n      }\n    }\n    AlbumTableContext::Simplified => {\n      if let Some(selected_album_simplified) = &app.selected_album_simplified.clone() {\n        if let Some(track) = &selected_album_simplified\n          .tracks\n          .items\n          .get(selected_album_simplified.selected_index)\n        {\n          if let Some(id) = &track.id {\n            app.recommendations_context = Some(RecommendationsContext::Song);\n            app.recommendations_seed = track.name.clone();\n            app.get_recommendations_for_track_id(id.to_string());\n          }\n        }\n      };\n    }\n  }\n}\n\nfn handle_save_event(app: &mut App) {\n  match app.album_table_context {\n    AlbumTableContext::Full => {\n      if let Some(selected_album) = app.selected_album_full.clone() {\n        if let Some(selected_track) = selected_album\n          .album\n          .tracks\n          .items\n          .get(app.saved_album_tracks_index)\n        {\n          if let Some(track_id) = &selected_track.id {\n            app.dispatch(IoEvent::ToggleSaveTrack(track_id.to_string()));\n          };\n        };\n      };\n    }\n    AlbumTableContext::Simplified => {\n      if let Some(selected_album_simplified) = app.selected_album_simplified.clone() {\n        if let Some(selected_track) = selected_album_simplified\n          .tracks\n          .items\n          .get(selected_album_simplified.selected_index)\n        {\n          if let Some(track_id) = &selected_track.id {\n            app.dispatch(IoEvent::ToggleSaveTrack(track_id.to_string()));\n          };\n        };\n      };\n    }\n  }\n}\n\nfn handle_save_album_event(app: &mut App) {\n  match app.album_table_context {\n    AlbumTableContext::Full => {\n      if let Some(selected_album) = app.selected_album_full.clone() {\n        let album_id = &selected_album.album.id;\n        app.dispatch(IoEvent::CurrentUserSavedAlbumAdd(album_id.to_string()));\n      };\n    }\n    AlbumTableContext::Simplified => {\n      if let Some(selected_album_simplified) = app.selected_album_simplified.clone() {\n        if let Some(album_id) = selected_album_simplified.album.id {\n          app.dispatch(IoEvent::CurrentUserSavedAlbumAdd(album_id));\n        };\n      };\n    }\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use crate::app::ActiveBlock;\n\n  #[test]\n  fn on_left_press() {\n    let mut app = App::default();\n    app.set_current_route_state(\n      Some(ActiveBlock::AlbumTracks),\n      Some(ActiveBlock::AlbumTracks),\n    );\n\n    handler(Key::Left, &mut app);\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.active_block, ActiveBlock::Empty);\n    assert_eq!(current_route.hovered_block, ActiveBlock::Library);\n  }\n\n  #[test]\n  fn on_esc() {\n    let mut app = App::default();\n\n    handler(Key::Esc, &mut app);\n\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.active_block, ActiveBlock::Empty);\n  }\n}\n"
  },
  {
    "path": "src/handlers/analysis.rs",
    "content": "use crate::{app::App, event::Key};\n\npub fn handler(_key: Key, _app: &mut App) {}\n"
  },
  {
    "path": "src/handlers/artist.rs",
    "content": "use super::common_key_events;\nuse crate::app::{ActiveBlock, App, ArtistBlock, RecommendationsContext, TrackTableContext};\nuse crate::event::Key;\nuse crate::network::IoEvent;\n\nfn handle_down_press_on_selected_block(app: &mut App) {\n  if let Some(artist) = &mut app.artist {\n    match artist.artist_selected_block {\n      ArtistBlock::TopTracks => {\n        let next_index = common_key_events::on_down_press_handler(\n          &artist.top_tracks,\n          Some(artist.selected_top_track_index),\n        );\n        artist.selected_top_track_index = next_index;\n      }\n      ArtistBlock::Albums => {\n        let next_index = common_key_events::on_down_press_handler(\n          &artist.albums.items,\n          Some(artist.selected_album_index),\n        );\n        artist.selected_album_index = next_index;\n      }\n      ArtistBlock::RelatedArtists => {\n        let next_index = common_key_events::on_down_press_handler(\n          &artist.related_artists,\n          Some(artist.selected_related_artist_index),\n        );\n        artist.selected_related_artist_index = next_index;\n      }\n      ArtistBlock::Empty => {}\n    }\n  }\n}\n\nfn handle_down_press_on_hovered_block(app: &mut App) {\n  if let Some(artist) = &mut app.artist {\n    match artist.artist_hovered_block {\n      ArtistBlock::TopTracks => {\n        artist.artist_hovered_block = ArtistBlock::Albums;\n      }\n      ArtistBlock::Albums => {\n        artist.artist_hovered_block = ArtistBlock::RelatedArtists;\n      }\n      ArtistBlock::RelatedArtists => {\n        artist.artist_hovered_block = ArtistBlock::TopTracks;\n      }\n      ArtistBlock::Empty => {}\n    }\n  }\n}\n\nfn handle_up_press_on_selected_block(app: &mut App) {\n  if let Some(artist) = &mut app.artist {\n    match artist.artist_selected_block {\n      ArtistBlock::TopTracks => {\n        let next_index = common_key_events::on_up_press_handler(\n          &artist.top_tracks,\n          Some(artist.selected_top_track_index),\n        );\n        artist.selected_top_track_index = next_index;\n      }\n      ArtistBlock::Albums => {\n        let next_index = common_key_events::on_up_press_handler(\n          &artist.albums.items,\n          Some(artist.selected_album_index),\n        );\n        artist.selected_album_index = next_index;\n      }\n      ArtistBlock::RelatedArtists => {\n        let next_index = common_key_events::on_up_press_handler(\n          &artist.related_artists,\n          Some(artist.selected_related_artist_index),\n        );\n        artist.selected_related_artist_index = next_index;\n      }\n      ArtistBlock::Empty => {}\n    }\n  }\n}\n\nfn handle_up_press_on_hovered_block(app: &mut App) {\n  if let Some(artist) = &mut app.artist {\n    match artist.artist_hovered_block {\n      ArtistBlock::TopTracks => {\n        artist.artist_hovered_block = ArtistBlock::RelatedArtists;\n      }\n      ArtistBlock::Albums => {\n        artist.artist_hovered_block = ArtistBlock::TopTracks;\n      }\n      ArtistBlock::RelatedArtists => {\n        artist.artist_hovered_block = ArtistBlock::Albums;\n      }\n      ArtistBlock::Empty => {}\n    }\n  }\n}\n\nfn handle_high_press_on_selected_block(app: &mut App) {\n  if let Some(artist) = &mut app.artist {\n    match artist.artist_selected_block {\n      ArtistBlock::TopTracks => {\n        let next_index = common_key_events::on_high_press_handler();\n        artist.selected_top_track_index = next_index;\n      }\n      ArtistBlock::Albums => {\n        let next_index = common_key_events::on_high_press_handler();\n        artist.selected_album_index = next_index;\n      }\n      ArtistBlock::RelatedArtists => {\n        let next_index = common_key_events::on_high_press_handler();\n        artist.selected_related_artist_index = next_index;\n      }\n      ArtistBlock::Empty => {}\n    }\n  }\n}\n\nfn handle_middle_press_on_selected_block(app: &mut App) {\n  if let Some(artist) = &mut app.artist {\n    match artist.artist_selected_block {\n      ArtistBlock::TopTracks => {\n        let next_index = common_key_events::on_middle_press_handler(&artist.top_tracks);\n        artist.selected_top_track_index = next_index;\n      }\n      ArtistBlock::Albums => {\n        let next_index = common_key_events::on_middle_press_handler(&artist.albums.items);\n        artist.selected_album_index = next_index;\n      }\n      ArtistBlock::RelatedArtists => {\n        let next_index = common_key_events::on_middle_press_handler(&artist.related_artists);\n        artist.selected_related_artist_index = next_index;\n      }\n      ArtistBlock::Empty => {}\n    }\n  }\n}\n\nfn handle_low_press_on_selected_block(app: &mut App) {\n  if let Some(artist) = &mut app.artist {\n    match artist.artist_selected_block {\n      ArtistBlock::TopTracks => {\n        let next_index = common_key_events::on_low_press_handler(&artist.top_tracks);\n        artist.selected_top_track_index = next_index;\n      }\n      ArtistBlock::Albums => {\n        let next_index = common_key_events::on_low_press_handler(&artist.albums.items);\n        artist.selected_album_index = next_index;\n      }\n      ArtistBlock::RelatedArtists => {\n        let next_index = common_key_events::on_low_press_handler(&artist.related_artists);\n        artist.selected_related_artist_index = next_index;\n      }\n      ArtistBlock::Empty => {}\n    }\n  }\n}\n\nfn handle_recommend_event_on_selected_block(app: &mut App) {\n  //recommendations.\n  if let Some(artist) = &mut app.artist.clone() {\n    match artist.artist_selected_block {\n      ArtistBlock::TopTracks => {\n        let selected_index = artist.selected_top_track_index;\n        if let Some(track) = artist.top_tracks.get(selected_index) {\n          let track_id_list: Option<Vec<String>> = track.id.as_ref().map(|id| vec![id.to_string()]);\n          app.recommendations_context = Some(RecommendationsContext::Song);\n          app.recommendations_seed = track.name.clone();\n          app.get_recommendations_for_seed(None, track_id_list, Some(track.clone()));\n        }\n      }\n      ArtistBlock::RelatedArtists => {\n        let selected_index = artist.selected_related_artist_index;\n        let artist_id = &artist.related_artists[selected_index].id;\n        let artist_name = &artist.related_artists[selected_index].name;\n        let artist_id_list: Option<Vec<String>> = Some(vec![artist_id.clone()]);\n\n        app.recommendations_context = Some(RecommendationsContext::Artist);\n        app.recommendations_seed = artist_name.clone();\n        app.get_recommendations_for_seed(artist_id_list, None, None);\n      }\n      _ => {}\n    }\n  }\n}\n\nfn handle_enter_event_on_selected_block(app: &mut App) {\n  if let Some(artist) = &mut app.artist.clone() {\n    match artist.artist_selected_block {\n      ArtistBlock::TopTracks => {\n        let selected_index = artist.selected_top_track_index;\n        let top_tracks = artist\n          .top_tracks\n          .iter()\n          .map(|track| track.uri.to_owned())\n          .collect();\n        app.dispatch(IoEvent::StartPlayback(\n          None,\n          Some(top_tracks),\n          Some(selected_index),\n        ));\n      }\n      ArtistBlock::Albums => {\n        if let Some(selected_album) = artist\n          .albums\n          .items\n          .get(artist.selected_album_index)\n          .cloned()\n        {\n          app.track_table.context = Some(TrackTableContext::AlbumSearch);\n          app.dispatch(IoEvent::GetAlbumTracks(Box::new(selected_album)));\n        }\n      }\n      ArtistBlock::RelatedArtists => {\n        let selected_index = artist.selected_related_artist_index;\n        let artist_id = artist.related_artists[selected_index].id.clone();\n        let artist_name = artist.related_artists[selected_index].name.clone();\n        app.get_artist(artist_id, artist_name);\n      }\n      ArtistBlock::Empty => {}\n    }\n  }\n}\n\nfn handle_enter_event_on_hovered_block(app: &mut App) {\n  if let Some(artist) = &mut app.artist {\n    match artist.artist_hovered_block {\n      ArtistBlock::TopTracks => artist.artist_selected_block = ArtistBlock::TopTracks,\n      ArtistBlock::Albums => artist.artist_selected_block = ArtistBlock::Albums,\n      ArtistBlock::RelatedArtists => artist.artist_selected_block = ArtistBlock::RelatedArtists,\n      ArtistBlock::Empty => {}\n    }\n  }\n}\n\npub fn handler(key: Key, app: &mut App) {\n  if let Some(artist) = &mut app.artist {\n    match key {\n      Key::Esc => {\n        artist.artist_selected_block = ArtistBlock::Empty;\n      }\n      k if common_key_events::down_event(k) => {\n        if artist.artist_selected_block != ArtistBlock::Empty {\n          handle_down_press_on_selected_block(app);\n        } else {\n          handle_down_press_on_hovered_block(app);\n        }\n      }\n      k if common_key_events::up_event(k) => {\n        if artist.artist_selected_block != ArtistBlock::Empty {\n          handle_up_press_on_selected_block(app);\n        } else {\n          handle_up_press_on_hovered_block(app);\n        }\n      }\n      k if common_key_events::left_event(k) => {\n        artist.artist_selected_block = ArtistBlock::Empty;\n        match artist.artist_hovered_block {\n          ArtistBlock::TopTracks => common_key_events::handle_left_event(app),\n          ArtistBlock::Albums => {\n            artist.artist_hovered_block = ArtistBlock::TopTracks;\n          }\n          ArtistBlock::RelatedArtists => {\n            artist.artist_hovered_block = ArtistBlock::Albums;\n          }\n          ArtistBlock::Empty => {}\n        }\n      }\n      k if common_key_events::right_event(k) => {\n        artist.artist_selected_block = ArtistBlock::Empty;\n        handle_down_press_on_hovered_block(app);\n      }\n      k if common_key_events::high_event(k) => {\n        if artist.artist_selected_block != ArtistBlock::Empty {\n          handle_high_press_on_selected_block(app);\n        }\n      }\n      k if common_key_events::middle_event(k) => {\n        if artist.artist_selected_block != ArtistBlock::Empty {\n          handle_middle_press_on_selected_block(app);\n        }\n      }\n      k if common_key_events::low_event(k) => {\n        if artist.artist_selected_block != ArtistBlock::Empty {\n          handle_low_press_on_selected_block(app);\n        }\n      }\n      Key::Enter => {\n        if artist.artist_selected_block != ArtistBlock::Empty {\n          handle_enter_event_on_selected_block(app);\n        } else {\n          handle_enter_event_on_hovered_block(app);\n        }\n      }\n      Key::Char('r') => {\n        if artist.artist_selected_block != ArtistBlock::Empty {\n          handle_recommend_event_on_selected_block(app);\n        }\n      }\n      Key::Char('w') => match artist.artist_selected_block {\n        ArtistBlock::Albums => app.current_user_saved_album_add(ActiveBlock::ArtistBlock),\n        ArtistBlock::RelatedArtists => app.user_follow_artists(ActiveBlock::ArtistBlock),\n        _ => (),\n      },\n      Key::Char('D') => match artist.artist_selected_block {\n        ArtistBlock::Albums => app.current_user_saved_album_delete(ActiveBlock::ArtistBlock),\n        ArtistBlock::RelatedArtists => app.user_unfollow_artists(ActiveBlock::ArtistBlock),\n        _ => (),\n      },\n      _ if key == app.user_config.keys.add_item_to_queue => {\n        if let ArtistBlock::TopTracks = artist.artist_selected_block {\n          if let Some(track) = artist.top_tracks.get(artist.selected_top_track_index) {\n            let uri = track.uri.clone();\n            app.dispatch(IoEvent::AddItemToQueue(uri));\n          };\n        }\n      }\n      _ => {}\n    };\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use crate::app::ActiveBlock;\n\n  #[test]\n  fn on_esc() {\n    let mut app = App::default();\n\n    handler(Key::Esc, &mut app);\n\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.active_block, ActiveBlock::Empty);\n  }\n}\n"
  },
  {
    "path": "src/handlers/artist_albums.rs",
    "content": "use super::common_key_events;\nuse crate::{\n    app::{App, TrackTableContext},\n    event::Key,\n};\n\npub fn handler(key: Key, app: &mut App) {\n    match key {\n        k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),\n        k if common_key_events::down_event(k) => {\n            if let Some(artist_albums) = &mut app.artist_albums {\n                let next_index = common_key_events::on_down_press_handler(\n                    &artist_albums.albums.items,\n                    Some(artist_albums.selected_index),\n                );\n                artist_albums.selected_index = next_index;\n            }\n        }\n        k if common_key_events::up_event(k) => {\n            if let Some(artist_albums) = &mut app.artist_albums {\n                let next_index = common_key_events::on_up_press_handler(\n                    &artist_albums.albums.items,\n                    Some(artist_albums.selected_index),\n                );\n                artist_albums.selected_index = next_index;\n            }\n        }\n        Key::Enter => {\n            if let Some(artist_albums) = &mut app.artist_albums {\n                if let Some(selected_album) = artist_albums\n                    .albums\n                    .items\n                    .get(artist_albums.selected_index)\n                    .cloned()\n                {\n                    app.track_table.context = Some(TrackTableContext::AlbumSearch);\n                    app.get_album_tracks(selected_album);\n                }\n            };\n        }\n        _ => {}\n    };\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::app::ActiveBlock;\n\n    #[test]\n    fn on_left_press() {\n        let mut app = App::new();\n        app.set_current_route_state(\n            Some(ActiveBlock::AlbumTracks),\n            Some(ActiveBlock::AlbumTracks),\n        );\n\n        handler(Key::Left, &mut app);\n        let current_route = app.get_current_route();\n        assert_eq!(current_route.active_block, ActiveBlock::Empty);\n        assert_eq!(current_route.hovered_block, ActiveBlock::Library);\n    }\n\n    #[test]\n    fn on_esc() {\n        let mut app = App::new();\n\n        handler(Key::Esc, &mut app);\n\n        let current_route = app.get_current_route();\n        assert_eq!(current_route.active_block, ActiveBlock::Empty);\n    }\n}\n"
  },
  {
    "path": "src/handlers/artists.rs",
    "content": "use super::common_key_events;\nuse crate::{\n  app::{ActiveBlock, App, RecommendationsContext, RouteId},\n  event::Key,\n  network::IoEvent,\n};\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),\n    k if common_key_events::down_event(k) => {\n      if let Some(artists) = &mut app.library.saved_artists.get_results(None) {\n        let next_index =\n          common_key_events::on_down_press_handler(&artists.items, Some(app.artists_list_index));\n        app.artists_list_index = next_index;\n      }\n    }\n    k if common_key_events::up_event(k) => {\n      if let Some(artists) = &mut app.library.saved_artists.get_results(None) {\n        let next_index =\n          common_key_events::on_up_press_handler(&artists.items, Some(app.artists_list_index));\n        app.artists_list_index = next_index;\n      }\n    }\n    k if common_key_events::high_event(k) => {\n      if let Some(_artists) = &mut app.library.saved_artists.get_results(None) {\n        let next_index = common_key_events::on_high_press_handler();\n        app.artists_list_index = next_index;\n      }\n    }\n    k if common_key_events::middle_event(k) => {\n      if let Some(artists) = &mut app.library.saved_artists.get_results(None) {\n        let next_index = common_key_events::on_middle_press_handler(&artists.items);\n        app.artists_list_index = next_index;\n      }\n    }\n    k if common_key_events::low_event(k) => {\n      if let Some(artists) = &mut app.library.saved_artists.get_results(None) {\n        let next_index = common_key_events::on_low_press_handler(&artists.items);\n        app.artists_list_index = next_index;\n      }\n    }\n    Key::Enter => {\n      let artists = app.artists.to_owned();\n      if !artists.is_empty() {\n        let artist = &artists[app.artists_list_index];\n        app.get_artist(artist.id.clone(), artist.name.clone());\n        app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock);\n      }\n    }\n    Key::Char('D') => app.user_unfollow_artists(ActiveBlock::AlbumList),\n    Key::Char('e') => {\n      let artists = app.artists.to_owned();\n      let artist = artists.get(app.artists_list_index);\n      if let Some(artist) = artist {\n        app.dispatch(IoEvent::StartPlayback(\n          Some(artist.uri.to_owned()),\n          None,\n          None,\n        ));\n      }\n    }\n    Key::Char('r') => {\n      let artists = app.artists.to_owned();\n      let artist = artists.get(app.artists_list_index);\n      if let Some(artist) = artist {\n        let artist_name = artist.name.clone();\n        let artist_id_list: Option<Vec<String>> = Some(vec![artist.id.clone()]);\n\n        app.recommendations_context = Some(RecommendationsContext::Artist);\n        app.recommendations_seed = artist_name;\n        app.get_recommendations_for_seed(artist_id_list, None, None);\n      }\n    }\n    k if k == app.user_config.keys.next_page => app.get_current_user_saved_artists_next(),\n    k if k == app.user_config.keys.previous_page => app.get_current_user_saved_artists_previous(),\n    _ => {}\n  }\n}\n"
  },
  {
    "path": "src/handlers/basic_view.rs",
    "content": "use crate::{app::App, event::Key, network::IoEvent};\nuse rspotify::model::{context::CurrentlyPlaybackContext, PlayingItem};\n\npub fn handler(key: Key, app: &mut App) {\n  if let Key::Char('s') = key {\n    if let Some(CurrentlyPlaybackContext {\n      item: Some(item), ..\n    }) = app.current_playback_context.to_owned()\n    {\n      match item {\n        PlayingItem::Track(track) => {\n          if let Some(track_id) = track.id {\n            app.dispatch(IoEvent::ToggleSaveTrack(track_id));\n          }\n        }\n        PlayingItem::Episode(episode) => {\n          app.dispatch(IoEvent::ToggleSaveTrack(episode.id));\n        }\n      };\n    };\n  }\n}\n"
  },
  {
    "path": "src/handlers/common_key_events.rs",
    "content": "use super::super::app::{ActiveBlock, App, RouteId};\nuse crate::event::Key;\n\npub fn down_event(key: Key) -> bool {\n  matches!(key, Key::Down | Key::Char('j') | Key::Ctrl('n'))\n}\n\npub fn up_event(key: Key) -> bool {\n  matches!(key, Key::Up | Key::Char('k') | Key::Ctrl('p'))\n}\n\npub fn left_event(key: Key) -> bool {\n  matches!(key, Key::Left | Key::Char('h') | Key::Ctrl('b'))\n}\n\npub fn right_event(key: Key) -> bool {\n  matches!(key, Key::Right | Key::Char('l') | Key::Ctrl('f'))\n}\n\npub fn high_event(key: Key) -> bool {\n  matches!(key, Key::Char('H'))\n}\n\npub fn middle_event(key: Key) -> bool {\n  matches!(key, Key::Char('M'))\n}\n\npub fn low_event(key: Key) -> bool {\n  matches!(key, Key::Char('L'))\n}\n\npub fn on_down_press_handler<T>(selection_data: &[T], selection_index: Option<usize>) -> usize {\n  match selection_index {\n    Some(selection_index) => {\n      if !selection_data.is_empty() {\n        let next_index = selection_index + 1;\n        if next_index > selection_data.len() - 1 {\n          return 0;\n        } else {\n          return next_index;\n        }\n      }\n      0\n    }\n    None => 0,\n  }\n}\n\npub fn on_up_press_handler<T>(selection_data: &[T], selection_index: Option<usize>) -> usize {\n  match selection_index {\n    Some(selection_index) => {\n      if !selection_data.is_empty() {\n        if selection_index > 0 {\n          return selection_index - 1;\n        } else {\n          return selection_data.len() - 1;\n        }\n      }\n      0\n    }\n    None => 0,\n  }\n}\n\npub fn on_high_press_handler() -> usize {\n  0\n}\n\npub fn on_middle_press_handler<T>(selection_data: &[T]) -> usize {\n  let mut index = selection_data.len() / 2;\n  if selection_data.len() % 2 == 0 {\n    index -= 1;\n  }\n  index\n}\n\npub fn on_low_press_handler<T>(selection_data: &[T]) -> usize {\n  selection_data.len() - 1\n}\n\npub fn handle_right_event(app: &mut App) {\n  match app.get_current_route().hovered_block {\n    ActiveBlock::MyPlaylists | ActiveBlock::Library => match app.get_current_route().id {\n      RouteId::AlbumTracks => {\n        app.set_current_route_state(\n          Some(ActiveBlock::AlbumTracks),\n          Some(ActiveBlock::AlbumTracks),\n        );\n      }\n      RouteId::TrackTable => {\n        app.set_current_route_state(Some(ActiveBlock::TrackTable), Some(ActiveBlock::TrackTable));\n      }\n      RouteId::Podcasts => {\n        app.set_current_route_state(Some(ActiveBlock::Podcasts), Some(ActiveBlock::Podcasts));\n      }\n      RouteId::Recommendations => {\n        app.set_current_route_state(Some(ActiveBlock::TrackTable), Some(ActiveBlock::TrackTable));\n      }\n      RouteId::AlbumList => {\n        app.set_current_route_state(Some(ActiveBlock::AlbumList), Some(ActiveBlock::AlbumList));\n      }\n      RouteId::PodcastEpisodes => {\n        app.set_current_route_state(\n          Some(ActiveBlock::EpisodeTable),\n          Some(ActiveBlock::EpisodeTable),\n        );\n      }\n      RouteId::MadeForYou => {\n        app.set_current_route_state(Some(ActiveBlock::MadeForYou), Some(ActiveBlock::MadeForYou));\n      }\n      RouteId::Artists => {\n        app.set_current_route_state(Some(ActiveBlock::Artists), Some(ActiveBlock::Artists));\n      }\n      RouteId::RecentlyPlayed => {\n        app.set_current_route_state(\n          Some(ActiveBlock::RecentlyPlayed),\n          Some(ActiveBlock::RecentlyPlayed),\n        );\n      }\n      RouteId::Search => {\n        app.set_current_route_state(\n          Some(ActiveBlock::SearchResultBlock),\n          Some(ActiveBlock::SearchResultBlock),\n        );\n      }\n      RouteId::Artist => app.set_current_route_state(\n        Some(ActiveBlock::ArtistBlock),\n        Some(ActiveBlock::ArtistBlock),\n      ),\n      RouteId::Home => {\n        app.set_current_route_state(Some(ActiveBlock::Home), Some(ActiveBlock::Home));\n      }\n      RouteId::SelectedDevice => {}\n      RouteId::Error => {}\n      RouteId::Analysis => {}\n      RouteId::BasicView => {}\n      RouteId::Dialog => {}\n    },\n    _ => {}\n  };\n}\n\npub fn handle_left_event(app: &mut App) {\n  // TODO: This should send you back to either library or playlist based on last selection\n  app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n\n  #[test]\n  fn test_on_down_press_handler() {\n    let data = vec![\"Choice 1\", \"Choice 2\", \"Choice 3\"];\n\n    let index = 0;\n    let next_index = on_down_press_handler(&data, Some(index));\n\n    assert_eq!(next_index, 1);\n\n    // Selection wrap if on last item\n    let index = data.len() - 1;\n    let next_index = on_down_press_handler(&data, Some(index));\n    assert_eq!(next_index, 0);\n  }\n\n  #[test]\n  fn test_on_up_press_handler() {\n    let data = vec![\"Choice 1\", \"Choice 2\", \"Choice 3\"];\n\n    let index = data.len() - 1;\n    let next_index = on_up_press_handler(&data, Some(index));\n\n    assert_eq!(next_index, index - 1);\n\n    // Selection wrap if on first item\n    let index = 0;\n    let next_index = on_up_press_handler(&data, Some(index));\n    assert_eq!(next_index, data.len() - 1);\n  }\n}\n"
  },
  {
    "path": "src/handlers/dialog.rs",
    "content": "use super::super::app::{ActiveBlock, App, DialogContext};\nuse crate::event::Key;\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    Key::Enter => {\n      if let Some(route) = app.pop_navigation_stack() {\n        if app.confirm {\n          if let ActiveBlock::Dialog(d) = route.active_block {\n            match d {\n              DialogContext::PlaylistWindow => handle_playlist_dialog(app),\n              DialogContext::PlaylistSearch => handle_playlist_search_dialog(app),\n            }\n          }\n        }\n      }\n    }\n    Key::Char('q') => {\n      app.pop_navigation_stack();\n    }\n    Key::Right => app.confirm = !app.confirm,\n    Key::Left => app.confirm = !app.confirm,\n    _ => {}\n  }\n}\n\nfn handle_playlist_dialog(app: &mut App) {\n  app.user_unfollow_playlist()\n}\n\nfn handle_playlist_search_dialog(app: &mut App) {\n  app.user_unfollow_playlist_search_result()\n}\n"
  },
  {
    "path": "src/handlers/empty.rs",
    "content": "use super::common_key_events;\nuse crate::{\n  app::{ActiveBlock, App},\n  event::Key,\n};\n\n// When no block is actively selected, just handle regular event\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    Key::Enter => {\n      let current_hovered = app.get_current_route().hovered_block;\n      app.set_current_route_state(Some(current_hovered), None);\n    }\n    k if common_key_events::down_event(k) => match app.get_current_route().hovered_block {\n      ActiveBlock::Library => {\n        app.set_current_route_state(None, Some(ActiveBlock::MyPlaylists));\n      }\n      ActiveBlock::ArtistBlock\n      | ActiveBlock::AlbumList\n      | ActiveBlock::AlbumTracks\n      | ActiveBlock::Artists\n      | ActiveBlock::Podcasts\n      | ActiveBlock::EpisodeTable\n      | ActiveBlock::Home\n      | ActiveBlock::MadeForYou\n      | ActiveBlock::MyPlaylists\n      | ActiveBlock::RecentlyPlayed\n      | ActiveBlock::TrackTable => {\n        app.set_current_route_state(None, Some(ActiveBlock::PlayBar));\n      }\n      _ => {}\n    },\n    k if common_key_events::up_event(k) => match app.get_current_route().hovered_block {\n      ActiveBlock::MyPlaylists => {\n        app.set_current_route_state(None, Some(ActiveBlock::Library));\n      }\n      ActiveBlock::PlayBar => {\n        app.set_current_route_state(None, Some(ActiveBlock::MyPlaylists));\n      }\n      _ => {}\n    },\n    k if common_key_events::left_event(k) => match app.get_current_route().hovered_block {\n      ActiveBlock::ArtistBlock\n      | ActiveBlock::AlbumList\n      | ActiveBlock::AlbumTracks\n      | ActiveBlock::Artists\n      | ActiveBlock::Podcasts\n      | ActiveBlock::EpisodeTable\n      | ActiveBlock::Home\n      | ActiveBlock::MadeForYou\n      | ActiveBlock::RecentlyPlayed\n      | ActiveBlock::TrackTable => {\n        app.set_current_route_state(None, Some(ActiveBlock::Library));\n      }\n      _ => {}\n    },\n    k if common_key_events::right_event(k) => common_key_events::handle_right_event(app),\n    _ => (),\n  };\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use crate::app::RouteId;\n\n  #[test]\n  fn on_enter() {\n    let mut app = App::default();\n\n    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));\n\n    handler(Key::Enter, &mut app);\n    let current_route = app.get_current_route();\n\n    assert_eq!(current_route.active_block, ActiveBlock::Library);\n    assert_eq!(current_route.hovered_block, ActiveBlock::Library);\n  }\n\n  #[test]\n  fn on_down_press() {\n    let mut app = App::default();\n\n    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));\n\n    handler(Key::Down, &mut app);\n    let current_route = app.get_current_route();\n\n    assert_eq!(current_route.active_block, ActiveBlock::Empty);\n    assert_eq!(current_route.hovered_block, ActiveBlock::MyPlaylists);\n\n    // TODO: test the other cases when they are implemented\n  }\n\n  #[test]\n  fn on_up_press() {\n    let mut app = App::default();\n\n    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::MyPlaylists));\n\n    handler(Key::Up, &mut app);\n    let current_route = app.get_current_route();\n\n    assert_eq!(current_route.active_block, ActiveBlock::Empty);\n    assert_eq!(current_route.hovered_block, ActiveBlock::Library);\n  }\n\n  #[test]\n  fn on_left_press() {\n    let mut app = App::default();\n    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::AlbumTracks));\n\n    handler(Key::Left, &mut app);\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.active_block, ActiveBlock::Empty);\n    assert_eq!(current_route.hovered_block, ActiveBlock::Library);\n\n    app.set_current_route_state(None, Some(ActiveBlock::Home));\n    handler(Key::Left, &mut app);\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.hovered_block, ActiveBlock::Library);\n\n    app.set_current_route_state(None, Some(ActiveBlock::TrackTable));\n    handler(Key::Left, &mut app);\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.hovered_block, ActiveBlock::Library);\n  }\n\n  #[test]\n  fn on_right_press() {\n    let mut app = App::default();\n\n    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));\n    app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks);\n    handler(Key::Right, &mut app);\n    let current_route = app.get_current_route();\n\n    assert_eq!(current_route.active_block, ActiveBlock::AlbumTracks);\n    assert_eq!(current_route.hovered_block, ActiveBlock::AlbumTracks);\n\n    app.push_navigation_stack(RouteId::Search, ActiveBlock::Empty);\n    app.set_current_route_state(None, Some(ActiveBlock::MyPlaylists));\n    handler(Key::Right, &mut app);\n    let current_route = app.get_current_route();\n\n    assert_eq!(current_route.active_block, ActiveBlock::SearchResultBlock);\n    assert_eq!(current_route.hovered_block, ActiveBlock::SearchResultBlock);\n\n    app.set_current_route_state(None, Some(ActiveBlock::Library));\n    app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable);\n    handler(Key::Right, &mut app);\n    let current_route = app.get_current_route();\n\n    assert_eq!(current_route.active_block, ActiveBlock::TrackTable);\n    assert_eq!(current_route.hovered_block, ActiveBlock::TrackTable);\n\n    app.set_current_route_state(None, Some(ActiveBlock::Library));\n    app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable);\n    handler(Key::Right, &mut app);\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.active_block, ActiveBlock::TrackTable);\n    assert_eq!(current_route.hovered_block, ActiveBlock::TrackTable);\n\n    app.push_navigation_stack(RouteId::Home, ActiveBlock::Home);\n    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));\n    handler(Key::Right, &mut app);\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.active_block, ActiveBlock::Home);\n    assert_eq!(current_route.hovered_block, ActiveBlock::Home);\n  }\n}\n"
  },
  {
    "path": "src/handlers/episode_table.rs",
    "content": "use super::{\n  super::app::{App, EpisodeTableContext},\n  common_key_events,\n};\nuse crate::app::ActiveBlock;\nuse crate::event::Key;\nuse crate::network::IoEvent;\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),\n    k if common_key_events::down_event(k) => {\n      if let Some(episodes) = &mut app.library.show_episodes.get_results(None) {\n        let next_index =\n          common_key_events::on_down_press_handler(&episodes.items, Some(app.episode_list_index));\n        app.episode_list_index = next_index;\n      }\n    }\n    k if common_key_events::up_event(k) => {\n      if let Some(episodes) = &mut app.library.show_episodes.get_results(None) {\n        let next_index =\n          common_key_events::on_up_press_handler(&episodes.items, Some(app.episode_list_index));\n        app.episode_list_index = next_index;\n      }\n    }\n    k if common_key_events::high_event(k) => {\n      if let Some(_episodes) = app.library.show_episodes.get_results(None) {\n        let next_index = common_key_events::on_high_press_handler();\n        app.episode_list_index = next_index;\n      }\n    }\n    k if common_key_events::middle_event(k) => {\n      if let Some(episodes) = app.library.show_episodes.get_results(None) {\n        let next_index = common_key_events::on_middle_press_handler(&episodes.items);\n        app.episode_list_index = next_index;\n      }\n    }\n    k if common_key_events::low_event(k) => {\n      if let Some(episodes) = app.library.show_episodes.get_results(None) {\n        let next_index = common_key_events::on_low_press_handler(&episodes.items);\n        app.episode_list_index = next_index;\n      }\n    }\n    Key::Enter => {\n      on_enter(app);\n    }\n    // Scroll down\n    k if k == app.user_config.keys.next_page => handle_next_event(app),\n    // Scroll up\n    k if k == app.user_config.keys.previous_page => handle_prev_event(app),\n    Key::Char('S') => toggle_sort_by_date(app),\n    Key::Char('s') => handle_follow_event(app),\n    Key::Char('D') => handle_unfollow_event(app),\n    Key::Ctrl('e') => jump_to_end(app),\n    Key::Ctrl('a') => jump_to_start(app),\n    _ => {}\n  }\n}\n\nfn jump_to_end(app: &mut App) {\n  if let Some(episodes) = app.library.show_episodes.get_results(None) {\n    let last_idx = episodes.items.len() - 1;\n    app.episode_list_index = last_idx;\n  }\n}\n\nfn on_enter(app: &mut App) {\n  if let Some(episodes) = app.library.show_episodes.get_results(None) {\n    let episode_uris = episodes\n      .items\n      .iter()\n      .map(|episode| episode.uri.to_owned())\n      .collect::<Vec<String>>();\n    app.dispatch(IoEvent::StartPlayback(\n      None,\n      Some(episode_uris),\n      Some(app.episode_list_index),\n    ));\n  }\n}\n\nfn handle_prev_event(app: &mut App) {\n  app.get_episode_table_previous();\n}\n\nfn handle_next_event(app: &mut App) {\n  match app.episode_table_context {\n    EpisodeTableContext::Full => {\n      if let Some(selected_episode) = app.selected_show_full.clone() {\n        let show_id = selected_episode.show.id;\n        app.get_episode_table_next(show_id)\n      }\n    }\n    EpisodeTableContext::Simplified => {\n      if let Some(selected_episode) = app.selected_show_simplified.clone() {\n        let show_id = selected_episode.show.id;\n        app.get_episode_table_next(show_id)\n      }\n    }\n  }\n}\n\nfn handle_follow_event(app: &mut App) {\n  app.user_follow_show(ActiveBlock::EpisodeTable);\n}\n\nfn handle_unfollow_event(app: &mut App) {\n  app.user_unfollow_show(ActiveBlock::EpisodeTable);\n}\n\nfn jump_to_start(app: &mut App) {\n  app.episode_list_index = 0;\n}\n\nfn toggle_sort_by_date(app: &mut App) {\n  //TODO: reverse whole list and not just currently visible episodes\n  let selected_id = match app.library.show_episodes.get_results(None) {\n    Some(episodes) => episodes\n      .items\n      .get(app.episode_list_index)\n      .map(|e| e.id.clone()),\n    None => None,\n  };\n\n  if let Some(episodes) = app.library.show_episodes.get_mut_results(None) {\n    episodes.items.reverse();\n  }\n\n  if let Some(id) = selected_id {\n    if let Some(episodes) = app.library.show_episodes.get_results(None) {\n      app.episode_list_index = episodes.items.iter().position(|e| e.id == id).unwrap_or(0);\n    }\n  } else {\n    app.episode_list_index = 0;\n  }\n}\n"
  },
  {
    "path": "src/handlers/error_screen.rs",
    "content": "use crate::{app::App, event::Key};\n\npub fn handler(_key: Key, _app: &mut App) {}\n"
  },
  {
    "path": "src/handlers/help_menu.rs",
    "content": "use super::common_key_events;\nuse crate::{app::App, event::Key};\n\n#[derive(PartialEq)]\nenum Direction {\n  Up,\n  Down,\n}\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::down_event(k) => {\n      move_page(Direction::Down, app);\n    }\n    k if common_key_events::up_event(k) => {\n      move_page(Direction::Up, app);\n    }\n    Key::Ctrl('d') => {\n      move_page(Direction::Down, app);\n    }\n    Key::Ctrl('u') => {\n      move_page(Direction::Up, app);\n    }\n    _ => {}\n  };\n}\n\nfn move_page(direction: Direction, app: &mut App) {\n  if direction == Direction::Up {\n    if app.help_menu_page > 0 {\n      app.help_menu_page -= 1;\n    }\n  } else if direction == Direction::Down {\n    app.help_menu_page += 1;\n  }\n  app.calculate_help_menu_offset();\n}\n"
  },
  {
    "path": "src/handlers/home.rs",
    "content": "use super::{super::app::App, common_key_events};\nuse crate::event::Key;\n\nconst LARGE_SCROLL: u16 = 10;\nconst SMALL_SCROLL: u16 = 1;\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),\n    k if common_key_events::down_event(k) => {\n      app.home_scroll += SMALL_SCROLL;\n    }\n    k if common_key_events::up_event(k) => {\n      if app.home_scroll > 0 {\n        app.home_scroll -= SMALL_SCROLL;\n      }\n    }\n    k if k == app.user_config.keys.next_page => {\n      app.home_scroll += LARGE_SCROLL;\n    }\n    k if k == app.user_config.keys.previous_page => {\n      if app.home_scroll > LARGE_SCROLL {\n        app.home_scroll -= LARGE_SCROLL;\n      } else {\n        app.home_scroll = 0;\n      }\n    }\n    _ => {}\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n\n  #[test]\n  fn on_small_down_press() {\n    let mut app = App::default();\n\n    handler(Key::Down, &mut app);\n    assert_eq!(app.home_scroll, SMALL_SCROLL);\n\n    handler(Key::Down, &mut app);\n    assert_eq!(app.home_scroll, SMALL_SCROLL * 2);\n  }\n\n  #[test]\n  fn on_small_up_press() {\n    let mut app = App::default();\n\n    handler(Key::Up, &mut app);\n    assert_eq!(app.home_scroll, 0);\n\n    app.home_scroll = 1;\n\n    handler(Key::Up, &mut app);\n    assert_eq!(app.home_scroll, 0);\n\n    // Check that smashing the up button doesn't go to negative scroll (which would cause a crash)\n    handler(Key::Up, &mut app);\n    handler(Key::Up, &mut app);\n    handler(Key::Up, &mut app);\n    assert_eq!(app.home_scroll, 0);\n  }\n\n  #[test]\n  fn on_large_down_press() {\n    let mut app = App::default();\n\n    handler(Key::Ctrl('d'), &mut app);\n    assert_eq!(app.home_scroll, LARGE_SCROLL);\n\n    handler(Key::Ctrl('d'), &mut app);\n    assert_eq!(app.home_scroll, LARGE_SCROLL * 2);\n  }\n\n  #[test]\n  fn on_large_up_press() {\n    let mut app = App::default();\n\n    let scroll = 37;\n    app.home_scroll = scroll;\n\n    handler(Key::Ctrl('u'), &mut app);\n    assert_eq!(app.home_scroll, scroll - LARGE_SCROLL);\n\n    handler(Key::Ctrl('u'), &mut app);\n    assert_eq!(app.home_scroll, scroll - LARGE_SCROLL * 2);\n\n    // Check that smashing the up button doesn't go to negative scroll (which would cause a crash)\n    handler(Key::Ctrl('u'), &mut app);\n    handler(Key::Ctrl('u'), &mut app);\n    handler(Key::Ctrl('u'), &mut app);\n    assert_eq!(app.home_scroll, 0);\n  }\n}\n"
  },
  {
    "path": "src/handlers/input.rs",
    "content": "extern crate unicode_width;\n\nuse super::super::app::{ActiveBlock, App, RouteId};\nuse crate::event::Key;\nuse crate::network::IoEvent;\nuse std::convert::TryInto;\nuse unicode_width::{UnicodeWidthChar, UnicodeWidthStr};\n\n// Handle event when the search input block is active\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    Key::Ctrl('k') => {\n      app.input.drain(app.input_idx..app.input.len());\n    }\n    Key::Ctrl('u') => {\n      app.input.drain(..app.input_idx);\n      app.input_idx = 0;\n      app.input_cursor_position = 0;\n    }\n    Key::Ctrl('l') => {\n      app.input = vec![];\n      app.input_idx = 0;\n      app.input_cursor_position = 0;\n    }\n    Key::Ctrl('w') => {\n      if app.input_cursor_position == 0 {\n        return;\n      }\n      let word_end = match app.input[..app.input_idx].iter().rposition(|&x| x != ' ') {\n        Some(index) => index + 1,\n        None => 0,\n      };\n      let word_start = match app.input[..word_end].iter().rposition(|&x| x == ' ') {\n        Some(index) => index + 1,\n        None => 0,\n      };\n      let deleted: String = app.input[word_start..app.input_idx].iter().collect();\n      let deleted_len: u16 = UnicodeWidthStr::width(deleted.as_str()).try_into().unwrap();\n      app.input.drain(word_start..app.input_idx);\n      app.input_idx = word_start;\n      app.input_cursor_position -= deleted_len;\n    }\n    Key::End | Key::Ctrl('e') => {\n      app.input_idx = app.input.len();\n      let input_string: String = app.input.iter().collect();\n      app.input_cursor_position = UnicodeWidthStr::width(input_string.as_str())\n        .try_into()\n        .unwrap();\n    }\n    Key::Home | Key::Ctrl('a') => {\n      app.input_idx = 0;\n      app.input_cursor_position = 0;\n    }\n    Key::Left | Key::Ctrl('b') => {\n      if !app.input.is_empty() && app.input_idx > 0 {\n        let last_c = app.input[app.input_idx - 1];\n        app.input_idx -= 1;\n        app.input_cursor_position -= compute_character_width(last_c);\n      }\n    }\n    Key::Right | Key::Ctrl('f') => {\n      if app.input_idx < app.input.len() {\n        let next_c = app.input[app.input_idx];\n        app.input_idx += 1;\n        app.input_cursor_position += compute_character_width(next_c);\n      }\n    }\n    Key::Esc => {\n      app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));\n    }\n    Key::Enter => {\n      let input_str: String = app.input.iter().collect();\n\n      process_input(app, input_str);\n    }\n    Key::Char(c) => {\n      app.input.insert(app.input_idx, c);\n      app.input_idx += 1;\n      app.input_cursor_position += compute_character_width(c);\n    }\n    Key::Backspace | Key::Ctrl('h') => {\n      if !app.input.is_empty() && app.input_idx > 0 {\n        let last_c = app.input.remove(app.input_idx - 1);\n        app.input_idx -= 1;\n        app.input_cursor_position -= compute_character_width(last_c);\n      }\n    }\n    Key::Delete | Key::Ctrl('d') => {\n      if !app.input.is_empty() && app.input_idx < app.input.len() {\n        app.input.remove(app.input_idx);\n      }\n    }\n    _ => {}\n  }\n}\n\nfn process_input(app: &mut App, input: String) {\n  // Don't do anything if there is no input\n  if input.is_empty() {\n    return;\n  }\n\n  // On searching for a track, clear the playlist selection\n  app.selected_playlist_index = Some(0);\n\n  if attempt_process_uri(app, &input, \"https://open.spotify.com/\", \"/\")\n    || attempt_process_uri(app, &input, \"spotify:\", \":\")\n  {\n    return;\n  }\n\n  // Default fallback behavior: treat the input as a raw search phrase.\n  app.dispatch(IoEvent::GetSearchResults(input, app.get_user_country()));\n  app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock);\n}\n\nfn spotify_resource_id(base: &str, uri: &str, sep: &str, resource_type: &str) -> (String, bool) {\n  let uri_prefix = format!(\"{}{}{}\", base, resource_type, sep);\n  let id_string_with_query_params = uri.trim_start_matches(&uri_prefix);\n  let query_idx = id_string_with_query_params\n    .find('?')\n    .unwrap_or_else(|| id_string_with_query_params.len());\n  let id_string = id_string_with_query_params[0..query_idx].to_string();\n  // If the lengths aren't equal, we must have found a match.\n  let matched = id_string_with_query_params.len() != uri.len() && id_string.len() != uri.len();\n  (id_string, matched)\n}\n\n// Returns true if the input was successfully processed as a Spotify URI.\nfn attempt_process_uri(app: &mut App, input: &str, base: &str, sep: &str) -> bool {\n  let (album_id, matched) = spotify_resource_id(base, input, sep, \"album\");\n  if matched {\n    app.dispatch(IoEvent::GetAlbum(album_id));\n    return true;\n  }\n\n  let (artist_id, matched) = spotify_resource_id(base, input, sep, \"artist\");\n  if matched {\n    app.get_artist(artist_id, \"\".to_string());\n    app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock);\n    return true;\n  }\n\n  let (track_id, matched) = spotify_resource_id(base, input, sep, \"track\");\n  if matched {\n    app.dispatch(IoEvent::GetAlbumForTrack(track_id));\n    return true;\n  }\n\n  let (playlist_id, matched) = spotify_resource_id(base, input, sep, \"playlist\");\n  if matched {\n    app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, 0));\n    return true;\n  }\n\n  let (show_id, matched) = spotify_resource_id(base, input, sep, \"show\");\n  if matched {\n    app.dispatch(IoEvent::GetShow(show_id));\n    return true;\n  }\n\n  false\n}\n\nfn compute_character_width(character: char) -> u16 {\n  UnicodeWidthChar::width(character)\n    .unwrap()\n    .try_into()\n    .unwrap()\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n\n  fn str_to_vec_char(s: &str) -> Vec<char> {\n    String::from(s).chars().collect()\n  }\n\n  #[test]\n  fn test_compute_character_width_with_multiple_characters() {\n    assert_eq!(1, compute_character_width('a'));\n    assert_eq!(1, compute_character_width('ß'));\n    assert_eq!(1, compute_character_width('ç'));\n  }\n\n  #[test]\n  fn test_input_handler_clear_input_on_ctrl_l() {\n    let mut app = App::default();\n\n    app.input = str_to_vec_char(\"My text\");\n\n    handler(Key::Ctrl('l'), &mut app);\n\n    assert_eq!(app.input, str_to_vec_char(\"\"));\n  }\n\n  #[test]\n  fn test_input_handler_ctrl_u() {\n    let mut app = App::default();\n\n    app.input = str_to_vec_char(\"My text\");\n\n    handler(Key::Ctrl('u'), &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"My text\"));\n\n    app.input_cursor_position = 3;\n    app.input_idx = 3;\n    handler(Key::Ctrl('u'), &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"text\"));\n  }\n\n  #[test]\n  fn test_input_handler_ctrl_k() {\n    let mut app = App::default();\n\n    app.input = str_to_vec_char(\"My text\");\n\n    handler(Key::Ctrl('k'), &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"\"));\n\n    app.input = str_to_vec_char(\"My text\");\n    app.input_cursor_position = 2;\n    app.input_idx = 2;\n    handler(Key::Ctrl('k'), &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"My\"));\n\n    handler(Key::Ctrl('k'), &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"My\"));\n  }\n\n  #[test]\n  fn test_input_handler_ctrl_w() {\n    let mut app = App::default();\n\n    app.input = str_to_vec_char(\"My text\");\n\n    handler(Key::Ctrl('w'), &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"My text\"));\n\n    app.input_cursor_position = 3;\n    app.input_idx = 3;\n    handler(Key::Ctrl('w'), &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"text\"));\n    assert_eq!(app.input_cursor_position, 0);\n    assert_eq!(app.input_idx, 0);\n\n    app.input = str_to_vec_char(\"    \");\n    app.input_cursor_position = 3;\n    app.input_idx = 3;\n    handler(Key::Ctrl('w'), &mut app);\n    assert_eq!(app.input, str_to_vec_char(\" \"));\n    assert_eq!(app.input_cursor_position, 0);\n    assert_eq!(app.input_idx, 0);\n    app.input_cursor_position = 1;\n    app.input_idx = 1;\n    handler(Key::Ctrl('w'), &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"\"));\n    assert_eq!(app.input_cursor_position, 0);\n    assert_eq!(app.input_idx, 0);\n\n    app.input = str_to_vec_char(\"Hello there  \");\n    app.input_cursor_position = 13;\n    app.input_idx = 13;\n    handler(Key::Ctrl('w'), &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"Hello \"));\n    assert_eq!(app.input_cursor_position, 6);\n    assert_eq!(app.input_idx, 6);\n  }\n\n  #[test]\n  fn test_input_handler_esc_back_to_playlist() {\n    let mut app = App::default();\n\n    app.set_current_route_state(Some(ActiveBlock::MyPlaylists), None);\n    handler(Key::Esc, &mut app);\n\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.active_block, ActiveBlock::Empty);\n  }\n\n  #[test]\n  fn test_input_handler_on_enter_text() {\n    let mut app = App::default();\n\n    app.input = str_to_vec_char(\"My tex\");\n    app.input_cursor_position = app.input.len().try_into().unwrap();\n    app.input_idx = app.input.len();\n\n    handler(Key::Char('t'), &mut app);\n\n    assert_eq!(app.input, str_to_vec_char(\"My text\"));\n  }\n\n  #[test]\n  fn test_input_handler_backspace() {\n    let mut app = App::default();\n\n    app.input = str_to_vec_char(\"My text\");\n    app.input_cursor_position = app.input.len().try_into().unwrap();\n    app.input_idx = app.input.len();\n\n    handler(Key::Backspace, &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"My tex\"));\n\n    // Test that backspace deletes from the cursor position\n    app.input_idx = 2;\n    app.input_cursor_position = 2;\n\n    handler(Key::Backspace, &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"M tex\"));\n\n    app.input_idx = 1;\n    app.input_cursor_position = 1;\n\n    handler(Key::Ctrl('h'), &mut app);\n    assert_eq!(app.input, str_to_vec_char(\" tex\"));\n  }\n\n  #[test]\n  fn test_input_handler_delete() {\n    let mut app = App::default();\n\n    app.input = str_to_vec_char(\"My text\");\n    app.input_idx = 3;\n    app.input_cursor_position = 3;\n\n    handler(Key::Delete, &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"My ext\"));\n\n    app.input = str_to_vec_char(\"ラスト\");\n    app.input_idx = 1;\n    app.input_cursor_position = 1;\n\n    handler(Key::Delete, &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"ラト\"));\n\n    app.input = str_to_vec_char(\"Rust\");\n    app.input_idx = 2;\n    app.input_cursor_position = 2;\n\n    handler(Key::Ctrl('d'), &mut app);\n    assert_eq!(app.input, str_to_vec_char(\"Rut\"));\n  }\n\n  #[test]\n  fn test_input_handler_left_event() {\n    let mut app = App::default();\n\n    app.input = str_to_vec_char(\"My text\");\n    let input_len = app.input.len().try_into().unwrap();\n    app.input_idx = app.input.len();\n    app.input_cursor_position = input_len;\n\n    handler(Key::Left, &mut app);\n    assert_eq!(app.input_cursor_position, input_len - 1);\n    handler(Key::Left, &mut app);\n    assert_eq!(app.input_cursor_position, input_len - 2);\n    handler(Key::Left, &mut app);\n    assert_eq!(app.input_cursor_position, input_len - 3);\n    handler(Key::Ctrl('b'), &mut app);\n    assert_eq!(app.input_cursor_position, input_len - 4);\n    handler(Key::Ctrl('b'), &mut app);\n    assert_eq!(app.input_cursor_position, input_len - 5);\n\n    // Pretend to smash the left event to test the we have no out-of-bounds crash\n    for _ in 0..20 {\n      handler(Key::Left, &mut app);\n    }\n\n    assert_eq!(app.input_cursor_position, 0);\n  }\n\n  #[test]\n  fn test_input_handler_on_enter_text_non_english_char() {\n    let mut app = App::default();\n\n    app.input = str_to_vec_char(\"ыа\");\n    app.input_cursor_position = app.input.len().try_into().unwrap();\n    app.input_idx = app.input.len();\n\n    handler(Key::Char('ы'), &mut app);\n\n    assert_eq!(app.input, str_to_vec_char(\"ыаы\"));\n  }\n\n  #[test]\n  fn test_input_handler_on_enter_text_wide_char() {\n    let mut app = App::default();\n\n    app.input = str_to_vec_char(\"你\");\n    app.input_cursor_position = 2; // 你 is 2 char wide\n    app.input_idx = 1; // 1 char\n\n    handler(Key::Char('好'), &mut app);\n\n    assert_eq!(app.input, str_to_vec_char(\"你好\"));\n    assert_eq!(app.input_idx, 2);\n    assert_eq!(app.input_cursor_position, 4);\n  }\n\n  mod test_uri_parsing {\n    use super::*;\n\n    const URI_BASE: &str = \"spotify:\";\n    const URL_BASE: &str = \"https://open.spotify.com/\";\n\n    fn check_uri_parse(expected_id: &str, parsed: (String, bool)) {\n      assert_eq!(parsed.1, true);\n      assert_eq!(parsed.0, expected_id);\n    }\n\n    fn run_test_for_id_and_resource_type(id: &str, resource_type: &str) {\n      check_uri_parse(\n        id,\n        spotify_resource_id(\n          URI_BASE,\n          &format!(\"spotify:{}:{}\", resource_type, id),\n          \":\",\n          resource_type,\n        ),\n      );\n      check_uri_parse(\n        id,\n        spotify_resource_id(\n          URL_BASE,\n          &format!(\"https://open.spotify.com/{}/{}\", resource_type, id),\n          \"/\",\n          resource_type,\n        ),\n      )\n    }\n\n    #[test]\n    fn artist() {\n      let expected_artist_id = \"2ye2Wgw4gimLv2eAKyk1NB\";\n      run_test_for_id_and_resource_type(expected_artist_id, \"artist\");\n    }\n\n    #[test]\n    fn album() {\n      let expected_album_id = \"5gzLOflH95LkKYE6XSXE9k\";\n      run_test_for_id_and_resource_type(expected_album_id, \"album\");\n    }\n\n    #[test]\n    fn playlist() {\n      let expected_playlist_id = \"1cJ6lPBYj2fscs0kqBHsVV\";\n      run_test_for_id_and_resource_type(expected_playlist_id, \"playlist\");\n    }\n\n    #[test]\n    fn show() {\n      let expected_show_id = \"3aNsrV6lkzmcU1w8u8kA7N\";\n      run_test_for_id_and_resource_type(expected_show_id, \"show\");\n    }\n\n    #[test]\n    fn track() {\n      let expected_track_id = \"10igKaIKsSB6ZnWxPxPvKO\";\n      run_test_for_id_and_resource_type(expected_track_id, \"track\");\n    }\n\n    #[test]\n    fn invalid_format_doesnt_match() {\n      let swapped = \"show:spotify:3aNsrV6lkzmcU1w8u8kA7N\";\n      let totally_wrong = \"hehe-haha-3aNsrV6lkzmcU1w8u8kA7N\";\n      let random = \"random string\";\n      let (_, matched) = spotify_resource_id(URI_BASE, swapped, \":\", \"track\");\n      assert_eq!(matched, false);\n      let (_, matched) = spotify_resource_id(URI_BASE, totally_wrong, \":\", \"track\");\n      assert_eq!(matched, false);\n      let (_, matched) = spotify_resource_id(URL_BASE, totally_wrong, \"/\", \"track\");\n      assert_eq!(matched, false);\n      let (_, matched) = spotify_resource_id(URL_BASE, random, \"/\", \"track\");\n      assert_eq!(matched, false);\n    }\n\n    #[test]\n    fn parse_with_query_parameters() {\n      // If this test ever fails due to some change to the parsing logic, it is likely a sign we\n      // should just integrate the url crate instead of trying to do things ourselves.\n      let playlist_url_with_query =\n        \"https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV?si=OdwuJsbsSeuUAOadehng3A\";\n      let playlist_url = \"https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV\";\n      let expected_id = \"1cJ6lPBYj2fscs0kqBHsVV\";\n\n      let (actual_id, matched) = spotify_resource_id(URL_BASE, playlist_url, \"/\", \"playlist\");\n      assert_eq!(matched, true);\n      assert_eq!(actual_id, expected_id);\n\n      let (actual_id, matched) =\n        spotify_resource_id(URL_BASE, playlist_url_with_query, \"/\", \"playlist\");\n      assert_eq!(matched, true);\n      assert_eq!(actual_id, expected_id);\n    }\n\n    #[test]\n    fn mismatched_resource_types_do_not_match() {\n      let playlist_url =\n        \"https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV?si=OdwuJsbsSeuUAOadehng3A\";\n      let (_, matched) = spotify_resource_id(URL_BASE, playlist_url, \"/\", \"album\");\n      assert_eq!(matched, false);\n    }\n  }\n}\n"
  },
  {
    "path": "src/handlers/library.rs",
    "content": "use super::{\n  super::app::{ActiveBlock, App, RouteId, LIBRARY_OPTIONS},\n  common_key_events,\n};\nuse crate::event::Key;\nuse crate::network::IoEvent;\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::right_event(k) => common_key_events::handle_right_event(app),\n    k if common_key_events::down_event(k) => {\n      let next_index = common_key_events::on_down_press_handler(\n        &LIBRARY_OPTIONS,\n        Some(app.library.selected_index),\n      );\n      app.library.selected_index = next_index;\n    }\n    k if common_key_events::up_event(k) => {\n      let next_index =\n        common_key_events::on_up_press_handler(&LIBRARY_OPTIONS, Some(app.library.selected_index));\n      app.library.selected_index = next_index;\n    }\n    k if common_key_events::high_event(k) => {\n      let next_index = common_key_events::on_high_press_handler();\n      app.library.selected_index = next_index;\n    }\n    k if common_key_events::middle_event(k) => {\n      let next_index = common_key_events::on_middle_press_handler(&LIBRARY_OPTIONS);\n      app.library.selected_index = next_index;\n    }\n    k if common_key_events::low_event(k) => {\n      let next_index = common_key_events::on_low_press_handler(&LIBRARY_OPTIONS);\n      app.library.selected_index = next_index\n    }\n    // `library` should probably be an array of structs with enums rather than just using indexes\n    // like this\n    Key::Enter => match app.library.selected_index {\n      // Made For You,\n      0 => {\n        app.get_made_for_you();\n        app.push_navigation_stack(RouteId::MadeForYou, ActiveBlock::MadeForYou);\n      }\n      // Recently Played,\n      1 => {\n        app.dispatch(IoEvent::GetRecentlyPlayed);\n        app.push_navigation_stack(RouteId::RecentlyPlayed, ActiveBlock::RecentlyPlayed);\n      }\n      // Liked Songs,\n      2 => {\n        app.dispatch(IoEvent::GetCurrentSavedTracks(None));\n        app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable);\n      }\n      // Albums,\n      3 => {\n        app.dispatch(IoEvent::GetCurrentUserSavedAlbums(None));\n        app.push_navigation_stack(RouteId::AlbumList, ActiveBlock::AlbumList);\n      }\n      //  Artists,\n      4 => {\n        app.dispatch(IoEvent::GetFollowedArtists(None));\n        app.push_navigation_stack(RouteId::Artists, ActiveBlock::Artists);\n      }\n      // Podcasts,\n      5 => {\n        app.dispatch(IoEvent::GetCurrentUserSavedShows(None));\n        app.push_navigation_stack(RouteId::Podcasts, ActiveBlock::Podcasts);\n      }\n      // This is required because Rust can't tell if this pattern in exhaustive\n      _ => {}\n    },\n    _ => (),\n  };\n}\n"
  },
  {
    "path": "src/handlers/made_for_you.rs",
    "content": "use super::{\n  super::app::{App, TrackTableContext},\n  common_key_events,\n};\nuse crate::event::Key;\nuse crate::network::IoEvent;\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),\n    k if common_key_events::up_event(k) => {\n      if let Some(playlists) = &mut app.library.made_for_you_playlists.get_results(None) {\n        let next_index =\n          common_key_events::on_up_press_handler(&playlists.items, Some(app.made_for_you_index));\n        app.made_for_you_index = next_index;\n      }\n    }\n    k if common_key_events::down_event(k) => {\n      if let Some(playlists) = &mut app.library.made_for_you_playlists.get_results(None) {\n        let next_index =\n          common_key_events::on_down_press_handler(&playlists.items, Some(app.made_for_you_index));\n        app.made_for_you_index = next_index;\n      }\n    }\n    k if common_key_events::high_event(k) => {\n      if let Some(_playlists) = &mut app.library.made_for_you_playlists.get_results(None) {\n        let next_index = common_key_events::on_high_press_handler();\n        app.made_for_you_index = next_index;\n      }\n    }\n    k if common_key_events::middle_event(k) => {\n      if let Some(playlists) = &mut app.library.made_for_you_playlists.get_results(None) {\n        let next_index = common_key_events::on_middle_press_handler(&playlists.items);\n        app.made_for_you_index = next_index;\n      }\n    }\n    k if common_key_events::low_event(k) => {\n      if let Some(playlists) = &mut app.library.made_for_you_playlists.get_results(None) {\n        let next_index = common_key_events::on_low_press_handler(&playlists.items);\n        app.made_for_you_index = next_index;\n      }\n    }\n    Key::Enter => {\n      if let (Some(playlists), selected_playlist_index) = (\n        &app.library.made_for_you_playlists.get_results(Some(0)),\n        &app.made_for_you_index,\n      ) {\n        app.track_table.context = Some(TrackTableContext::MadeForYou);\n        app.playlist_offset = 0;\n        if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) {\n          app.made_for_you_offset = 0;\n          let playlist_id = selected_playlist.id.to_owned();\n          app.dispatch(IoEvent::GetMadeForYouPlaylistTracks(\n            playlist_id,\n            app.made_for_you_offset,\n          ));\n        }\n      };\n    }\n    _ => {}\n  }\n}\n"
  },
  {
    "path": "src/handlers/mod.rs",
    "content": "mod album_list;\nmod album_tracks;\nmod analysis;\nmod artist;\nmod artists;\nmod basic_view;\nmod common_key_events;\nmod dialog;\nmod empty;\nmod episode_table;\nmod error_screen;\nmod help_menu;\nmod home;\nmod input;\nmod library;\nmod made_for_you;\nmod playbar;\nmod playlist;\nmod podcasts;\nmod recently_played;\nmod search_results;\nmod select_device;\nmod track_table;\n\nuse super::app::{ActiveBlock, App, ArtistBlock, RouteId, SearchResultBlock};\nuse crate::event::Key;\nuse crate::network::IoEvent;\nuse rspotify::model::{context::CurrentlyPlaybackContext, PlayingItem};\n\npub use input::handler as input_handler;\n\npub fn handle_app(key: Key, app: &mut App) {\n  // First handle any global event and then move to block event\n  match key {\n    Key::Esc => {\n      handle_escape(app);\n    }\n    _ if key == app.user_config.keys.jump_to_album => {\n      handle_jump_to_album(app);\n    }\n    _ if key == app.user_config.keys.jump_to_artist_album => {\n      handle_jump_to_artist_album(app);\n    }\n    _ if key == app.user_config.keys.jump_to_context => {\n      handle_jump_to_context(app);\n    }\n    _ if key == app.user_config.keys.manage_devices => {\n      app.dispatch(IoEvent::GetDevices);\n    }\n    _ if key == app.user_config.keys.decrease_volume => {\n      app.decrease_volume();\n    }\n    _ if key == app.user_config.keys.increase_volume => {\n      app.increase_volume();\n    }\n    // Press space to toggle playback\n    _ if key == app.user_config.keys.toggle_playback => {\n      app.toggle_playback();\n    }\n    _ if key == app.user_config.keys.seek_backwards => {\n      app.seek_backwards();\n    }\n    _ if key == app.user_config.keys.seek_forwards => {\n      app.seek_forwards();\n    }\n    _ if key == app.user_config.keys.next_track => {\n      app.dispatch(IoEvent::NextTrack);\n    }\n    _ if key == app.user_config.keys.previous_track => {\n      app.previous_track();\n    }\n    _ if key == app.user_config.keys.help => {\n      app.set_current_route_state(Some(ActiveBlock::HelpMenu), None);\n    }\n\n    _ if key == app.user_config.keys.shuffle => {\n      app.shuffle();\n    }\n    _ if key == app.user_config.keys.repeat => {\n      app.repeat();\n    }\n    _ if key == app.user_config.keys.search => {\n      app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input));\n    }\n    _ if key == app.user_config.keys.copy_song_url => {\n      app.copy_song_url();\n    }\n    _ if key == app.user_config.keys.copy_album_url => {\n      app.copy_album_url();\n    }\n    _ if key == app.user_config.keys.audio_analysis => {\n      app.get_audio_analysis();\n    }\n    _ if key == app.user_config.keys.basic_view => {\n      app.push_navigation_stack(RouteId::BasicView, ActiveBlock::BasicView);\n    }\n    _ => handle_block_events(key, app),\n  }\n}\n\n// Handle event for the current active block\nfn handle_block_events(key: Key, app: &mut App) {\n  let current_route = app.get_current_route();\n  match current_route.active_block {\n    ActiveBlock::Analysis => {\n      analysis::handler(key, app);\n    }\n    ActiveBlock::ArtistBlock => {\n      artist::handler(key, app);\n    }\n    ActiveBlock::Input => {\n      input::handler(key, app);\n    }\n    ActiveBlock::MyPlaylists => {\n      playlist::handler(key, app);\n    }\n    ActiveBlock::TrackTable => {\n      track_table::handler(key, app);\n    }\n    ActiveBlock::EpisodeTable => {\n      episode_table::handler(key, app);\n    }\n    ActiveBlock::HelpMenu => {\n      help_menu::handler(key, app);\n    }\n    ActiveBlock::Error => {\n      error_screen::handler(key, app);\n    }\n    ActiveBlock::SelectDevice => {\n      select_device::handler(key, app);\n    }\n    ActiveBlock::SearchResultBlock => {\n      search_results::handler(key, app);\n    }\n    ActiveBlock::Home => {\n      home::handler(key, app);\n    }\n    ActiveBlock::AlbumList => {\n      album_list::handler(key, app);\n    }\n    ActiveBlock::AlbumTracks => {\n      album_tracks::handler(key, app);\n    }\n    ActiveBlock::Library => {\n      library::handler(key, app);\n    }\n    ActiveBlock::Empty => {\n      empty::handler(key, app);\n    }\n    ActiveBlock::RecentlyPlayed => {\n      recently_played::handler(key, app);\n    }\n    ActiveBlock::Artists => {\n      artists::handler(key, app);\n    }\n    ActiveBlock::MadeForYou => {\n      made_for_you::handler(key, app);\n    }\n    ActiveBlock::Podcasts => {\n      podcasts::handler(key, app);\n    }\n    ActiveBlock::PlayBar => {\n      playbar::handler(key, app);\n    }\n    ActiveBlock::BasicView => {\n      basic_view::handler(key, app);\n    }\n    ActiveBlock::Dialog(_) => {\n      dialog::handler(key, app);\n    }\n  }\n}\n\nfn handle_escape(app: &mut App) {\n  match app.get_current_route().active_block {\n    ActiveBlock::SearchResultBlock => {\n      app.search_results.selected_block = SearchResultBlock::Empty;\n    }\n    ActiveBlock::ArtistBlock => {\n      if let Some(artist) = &mut app.artist {\n        artist.artist_selected_block = ArtistBlock::Empty;\n      }\n    }\n    ActiveBlock::Error => {\n      app.pop_navigation_stack();\n    }\n    ActiveBlock::Dialog(_) => {\n      app.pop_navigation_stack();\n    }\n    // These are global views that have no active/inactive distinction so do nothing\n    ActiveBlock::SelectDevice | ActiveBlock::Analysis => {}\n    _ => {\n      app.set_current_route_state(Some(ActiveBlock::Empty), None);\n    }\n  }\n}\n\nfn handle_jump_to_context(app: &mut App) {\n  if let Some(current_playback_context) = &app.current_playback_context {\n    if let Some(play_context) = current_playback_context.context.clone() {\n      match play_context._type {\n        rspotify::senum::Type::Album => handle_jump_to_album(app),\n        rspotify::senum::Type::Artist => handle_jump_to_artist_album(app),\n        rspotify::senum::Type::Playlist => {\n          app.dispatch(IoEvent::GetPlaylistTracks(play_context.uri, 0))\n        }\n        _ => {}\n      }\n    }\n  }\n}\n\nfn handle_jump_to_album(app: &mut App) {\n  if let Some(CurrentlyPlaybackContext {\n    item: Some(item), ..\n  }) = app.current_playback_context.to_owned()\n  {\n    match item {\n      PlayingItem::Track(track) => {\n        app.dispatch(IoEvent::GetAlbumTracks(Box::new(track.album)));\n      }\n      PlayingItem::Episode(episode) => {\n        app.dispatch(IoEvent::GetShowEpisodes(Box::new(episode.show)));\n      }\n    };\n  }\n}\n\n// NOTE: this only finds the first artist of the song and jumps to their albums\nfn handle_jump_to_artist_album(app: &mut App) {\n  if let Some(CurrentlyPlaybackContext {\n    item: Some(item), ..\n  }) = app.current_playback_context.to_owned()\n  {\n    match item {\n      PlayingItem::Track(track) => {\n        if let Some(artist) = track.artists.first() {\n          if let Some(artist_id) = artist.id.clone() {\n            app.get_artist(artist_id, artist.name.clone());\n            app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock);\n          }\n        }\n      }\n      PlayingItem::Episode(_episode) => {\n        // Do nothing for episode (yet!)\n      }\n    }\n  };\n}\n"
  },
  {
    "path": "src/handlers/playbar.rs",
    "content": "use super::{\n  super::app::{ActiveBlock, App},\n  common_key_events,\n};\nuse crate::event::Key;\nuse crate::network::IoEvent;\nuse rspotify::model::{context::CurrentlyPlaybackContext, PlayingItem};\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::up_event(k) => {\n      app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::MyPlaylists));\n    }\n    Key::Char('s') => {\n      if let Some(CurrentlyPlaybackContext {\n        item: Some(item), ..\n      }) = app.current_playback_context.to_owned()\n      {\n        match item {\n          PlayingItem::Track(track) => {\n            if let Some(track_id) = track.id {\n              app.dispatch(IoEvent::ToggleSaveTrack(track_id));\n            }\n          }\n          PlayingItem::Episode(episode) => {\n            app.dispatch(IoEvent::ToggleSaveTrack(episode.id));\n          }\n        };\n      };\n    }\n    _ => {}\n  };\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n\n  #[test]\n  fn on_left_press() {\n    let mut app = App::default();\n    app.set_current_route_state(Some(ActiveBlock::PlayBar), Some(ActiveBlock::PlayBar));\n\n    handler(Key::Up, &mut app);\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.active_block, ActiveBlock::Empty);\n    assert_eq!(current_route.hovered_block, ActiveBlock::MyPlaylists);\n  }\n}\n"
  },
  {
    "path": "src/handlers/playlist.rs",
    "content": "use super::{\n  super::app::{App, DialogContext, TrackTableContext},\n  common_key_events,\n};\nuse crate::app::{ActiveBlock, RouteId};\nuse crate::event::Key;\nuse crate::network::IoEvent;\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::right_event(k) => common_key_events::handle_right_event(app),\n    k if common_key_events::down_event(k) => {\n      match &app.playlists {\n        Some(p) => {\n          if let Some(selected_playlist_index) = app.selected_playlist_index {\n            let next_index =\n              common_key_events::on_down_press_handler(&p.items, Some(selected_playlist_index));\n            app.selected_playlist_index = Some(next_index);\n          }\n        }\n        None => {}\n      };\n    }\n    k if common_key_events::up_event(k) => {\n      match &app.playlists {\n        Some(p) => {\n          let next_index =\n            common_key_events::on_up_press_handler(&p.items, app.selected_playlist_index);\n          app.selected_playlist_index = Some(next_index);\n        }\n        None => {}\n      };\n    }\n    k if common_key_events::high_event(k) => {\n      match &app.playlists {\n        Some(_p) => {\n          let next_index = common_key_events::on_high_press_handler();\n          app.selected_playlist_index = Some(next_index);\n        }\n        None => {}\n      };\n    }\n    k if common_key_events::middle_event(k) => {\n      match &app.playlists {\n        Some(p) => {\n          let next_index = common_key_events::on_middle_press_handler(&p.items);\n          app.selected_playlist_index = Some(next_index);\n        }\n        None => {}\n      };\n    }\n    k if common_key_events::low_event(k) => {\n      match &app.playlists {\n        Some(p) => {\n          let next_index = common_key_events::on_low_press_handler(&p.items);\n          app.selected_playlist_index = Some(next_index);\n        }\n        None => {}\n      };\n    }\n    Key::Enter => {\n      if let (Some(playlists), Some(selected_playlist_index)) =\n        (&app.playlists, &app.selected_playlist_index)\n      {\n        app.active_playlist_index = Some(selected_playlist_index.to_owned());\n        app.track_table.context = Some(TrackTableContext::MyPlaylists);\n        app.playlist_offset = 0;\n        if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) {\n          let playlist_id = selected_playlist.id.to_owned();\n          app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, app.playlist_offset));\n        }\n      };\n    }\n    Key::Char('D') => {\n      if let (Some(playlists), Some(selected_index)) = (&app.playlists, app.selected_playlist_index)\n      {\n        let selected_playlist = &playlists.items[selected_index].name;\n        app.dialog = Some(selected_playlist.clone());\n        app.confirm = false;\n\n        app.push_navigation_stack(\n          RouteId::Dialog,\n          ActiveBlock::Dialog(DialogContext::PlaylistWindow),\n        );\n      }\n    }\n    _ => {}\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  #[test]\n  fn test() {}\n}\n"
  },
  {
    "path": "src/handlers/podcasts.rs",
    "content": "use super::common_key_events;\nuse crate::{\n  app::{ActiveBlock, App},\n  event::Key,\n  network::IoEvent,\n};\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),\n    k if common_key_events::down_event(k) => {\n      if let Some(shows) = &mut app.library.saved_shows.get_results(None) {\n        let next_index =\n          common_key_events::on_down_press_handler(&shows.items, Some(app.shows_list_index));\n        app.shows_list_index = next_index;\n      }\n    }\n    k if common_key_events::up_event(k) => {\n      if let Some(shows) = &mut app.library.saved_shows.get_results(None) {\n        let next_index =\n          common_key_events::on_up_press_handler(&shows.items, Some(app.shows_list_index));\n        app.shows_list_index = next_index;\n      }\n    }\n    k if common_key_events::high_event(k) => {\n      if let Some(_shows) = app.library.saved_shows.get_results(None) {\n        let next_index = common_key_events::on_high_press_handler();\n        app.shows_list_index = next_index;\n      }\n    }\n    k if common_key_events::middle_event(k) => {\n      if let Some(shows) = app.library.saved_shows.get_results(None) {\n        let next_index = common_key_events::on_middle_press_handler(&shows.items);\n        app.shows_list_index = next_index;\n      }\n    }\n    k if common_key_events::low_event(k) => {\n      if let Some(shows) = app.library.saved_shows.get_results(None) {\n        let next_index = common_key_events::on_low_press_handler(&shows.items);\n        app.shows_list_index = next_index;\n      }\n    }\n    Key::Enter => {\n      if let Some(shows) = app.library.saved_shows.get_results(None) {\n        if let Some(selected_show) = shows.items.get(app.shows_list_index).cloned() {\n          app.dispatch(IoEvent::GetShowEpisodes(Box::new(selected_show.show)));\n        };\n      }\n    }\n    k if k == app.user_config.keys.next_page => app.get_current_user_saved_shows_next(),\n    k if k == app.user_config.keys.previous_page => app.get_current_user_saved_shows_previous(),\n    Key::Char('D') => app.user_unfollow_show(ActiveBlock::Podcasts),\n    _ => {}\n  }\n}\n"
  },
  {
    "path": "src/handlers/recently_played.rs",
    "content": "use super::{super::app::App, common_key_events};\nuse crate::{app::RecommendationsContext, event::Key, network::IoEvent};\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),\n    k if common_key_events::down_event(k) => {\n      if let Some(recently_played_result) = &app.recently_played.result {\n        let next_index = common_key_events::on_down_press_handler(\n          &recently_played_result.items,\n          Some(app.recently_played.index),\n        );\n        app.recently_played.index = next_index;\n      }\n    }\n    k if common_key_events::up_event(k) => {\n      if let Some(recently_played_result) = &app.recently_played.result {\n        let next_index = common_key_events::on_up_press_handler(\n          &recently_played_result.items,\n          Some(app.recently_played.index),\n        );\n        app.recently_played.index = next_index;\n      }\n    }\n    k if common_key_events::high_event(k) => {\n      if let Some(_recently_played_result) = &app.recently_played.result {\n        let next_index = common_key_events::on_high_press_handler();\n        app.recently_played.index = next_index;\n      }\n    }\n    k if common_key_events::middle_event(k) => {\n      if let Some(recently_played_result) = &app.recently_played.result {\n        let next_index = common_key_events::on_middle_press_handler(&recently_played_result.items);\n        app.recently_played.index = next_index;\n      }\n    }\n    k if common_key_events::low_event(k) => {\n      if let Some(recently_played_result) = &app.recently_played.result {\n        let next_index = common_key_events::on_low_press_handler(&recently_played_result.items);\n        app.recently_played.index = next_index;\n      }\n    }\n    Key::Char('s') => {\n      if let Some(recently_played_result) = &app.recently_played.result.clone() {\n        if let Some(selected_track) = recently_played_result.items.get(app.recently_played.index) {\n          if let Some(track_id) = &selected_track.track.id {\n            app.dispatch(IoEvent::ToggleSaveTrack(track_id.to_string()));\n          };\n        };\n      };\n    }\n    Key::Enter => {\n      if let Some(recently_played_result) = &app.recently_played.result.clone() {\n        let track_uris: Vec<String> = recently_played_result\n          .items\n          .iter()\n          .map(|item| item.track.uri.to_owned())\n          .collect();\n\n        app.dispatch(IoEvent::StartPlayback(\n          None,\n          Some(track_uris),\n          Some(app.recently_played.index),\n        ));\n      };\n    }\n    Key::Char('r') => {\n      if let Some(recently_played_result) = &app.recently_played.result.clone() {\n        let selected_track_history_item =\n          recently_played_result.items.get(app.recently_played.index);\n\n        if let Some(item) = selected_track_history_item {\n          if let Some(id) = &item.track.id {\n            app.recommendations_context = Some(RecommendationsContext::Song);\n            app.recommendations_seed = item.track.name.clone();\n            app.get_recommendations_for_track_id(id.to_string());\n          }\n        }\n      }\n    }\n    _ if key == app.user_config.keys.add_item_to_queue => {\n      if let Some(recently_played_result) = &app.recently_played.result.clone() {\n        if let Some(history) = recently_played_result.items.get(app.recently_played.index) {\n          app.dispatch(IoEvent::AddItemToQueue(history.track.uri.clone()))\n        }\n      };\n    }\n    _ => {}\n  };\n}\n\n#[cfg(test)]\nmod tests {\n  use super::{super::super::app::ActiveBlock, *};\n\n  #[test]\n  fn on_left_press() {\n    let mut app = App::default();\n    app.set_current_route_state(\n      Some(ActiveBlock::AlbumTracks),\n      Some(ActiveBlock::AlbumTracks),\n    );\n\n    handler(Key::Left, &mut app);\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.active_block, ActiveBlock::Empty);\n    assert_eq!(current_route.hovered_block, ActiveBlock::Library);\n  }\n\n  #[test]\n  fn on_esc() {\n    let mut app = App::default();\n\n    handler(Key::Esc, &mut app);\n\n    let current_route = app.get_current_route();\n    assert_eq!(current_route.active_block, ActiveBlock::Empty);\n  }\n}\n"
  },
  {
    "path": "src/handlers/search_results.rs",
    "content": "use super::{\n  super::app::{\n    ActiveBlock, App, DialogContext, RecommendationsContext, RouteId, SearchResultBlock,\n    TrackTableContext,\n  },\n  common_key_events,\n};\nuse crate::event::Key;\nuse crate::network::IoEvent;\n\nfn handle_down_press_on_selected_block(app: &mut App) {\n  // Start selecting within the selected block\n  match app.search_results.selected_block {\n    SearchResultBlock::AlbumSearch => {\n      if let Some(result) = &app.search_results.albums {\n        let next_index = common_key_events::on_down_press_handler(\n          &result.items,\n          app.search_results.selected_album_index,\n        );\n        app.search_results.selected_album_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::SongSearch => {\n      if let Some(result) = &app.search_results.tracks {\n        let next_index = common_key_events::on_down_press_handler(\n          &result.items,\n          app.search_results.selected_tracks_index,\n        );\n        app.search_results.selected_tracks_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::ArtistSearch => {\n      if let Some(result) = &app.search_results.artists {\n        let next_index = common_key_events::on_down_press_handler(\n          &result.items,\n          app.search_results.selected_artists_index,\n        );\n        app.search_results.selected_artists_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::PlaylistSearch => {\n      if let Some(result) = &app.search_results.playlists {\n        let next_index = common_key_events::on_down_press_handler(\n          &result.items,\n          app.search_results.selected_playlists_index,\n        );\n        app.search_results.selected_playlists_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::ShowSearch => {\n      if let Some(result) = &app.search_results.shows {\n        let next_index = common_key_events::on_down_press_handler(\n          &result.items,\n          app.search_results.selected_shows_index,\n        );\n        app.search_results.selected_shows_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::Empty => {}\n  }\n}\n\nfn handle_down_press_on_hovered_block(app: &mut App) {\n  match app.search_results.hovered_block {\n    SearchResultBlock::AlbumSearch => {\n      app.search_results.hovered_block = SearchResultBlock::ShowSearch;\n    }\n    SearchResultBlock::SongSearch => {\n      app.search_results.hovered_block = SearchResultBlock::AlbumSearch;\n    }\n    SearchResultBlock::ArtistSearch => {\n      app.search_results.hovered_block = SearchResultBlock::PlaylistSearch;\n    }\n    SearchResultBlock::PlaylistSearch => {\n      app.search_results.hovered_block = SearchResultBlock::ShowSearch;\n    }\n    SearchResultBlock::ShowSearch => {\n      app.search_results.hovered_block = SearchResultBlock::SongSearch;\n    }\n    SearchResultBlock::Empty => {}\n  }\n}\n\nfn handle_up_press_on_selected_block(app: &mut App) {\n  // Start selecting within the selected block\n  match app.search_results.selected_block {\n    SearchResultBlock::AlbumSearch => {\n      if let Some(result) = &app.search_results.albums {\n        let next_index = common_key_events::on_up_press_handler(\n          &result.items,\n          app.search_results.selected_album_index,\n        );\n        app.search_results.selected_album_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::SongSearch => {\n      if let Some(result) = &app.search_results.tracks {\n        let next_index = common_key_events::on_up_press_handler(\n          &result.items,\n          app.search_results.selected_tracks_index,\n        );\n        app.search_results.selected_tracks_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::ArtistSearch => {\n      if let Some(result) = &app.search_results.artists {\n        let next_index = common_key_events::on_up_press_handler(\n          &result.items,\n          app.search_results.selected_artists_index,\n        );\n        app.search_results.selected_artists_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::PlaylistSearch => {\n      if let Some(result) = &app.search_results.playlists {\n        let next_index = common_key_events::on_up_press_handler(\n          &result.items,\n          app.search_results.selected_playlists_index,\n        );\n        app.search_results.selected_playlists_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::ShowSearch => {\n      if let Some(result) = &app.search_results.shows {\n        let next_index = common_key_events::on_up_press_handler(\n          &result.items,\n          app.search_results.selected_shows_index,\n        );\n        app.search_results.selected_shows_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::Empty => {}\n  }\n}\n\nfn handle_up_press_on_hovered_block(app: &mut App) {\n  match app.search_results.hovered_block {\n    SearchResultBlock::AlbumSearch => {\n      app.search_results.hovered_block = SearchResultBlock::SongSearch;\n    }\n    SearchResultBlock::SongSearch => {\n      app.search_results.hovered_block = SearchResultBlock::ShowSearch;\n    }\n    SearchResultBlock::ArtistSearch => {\n      app.search_results.hovered_block = SearchResultBlock::ShowSearch;\n    }\n    SearchResultBlock::PlaylistSearch => {\n      app.search_results.hovered_block = SearchResultBlock::ArtistSearch;\n    }\n    SearchResultBlock::ShowSearch => {\n      app.search_results.hovered_block = SearchResultBlock::AlbumSearch;\n    }\n    SearchResultBlock::Empty => {}\n  }\n}\n\nfn handle_high_press_on_selected_block(app: &mut App) {\n  match app.search_results.selected_block {\n    SearchResultBlock::AlbumSearch => {\n      if let Some(_result) = &app.search_results.albums {\n        let next_index = common_key_events::on_high_press_handler();\n        app.search_results.selected_album_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::SongSearch => {\n      if let Some(_result) = &app.search_results.tracks {\n        let next_index = common_key_events::on_high_press_handler();\n        app.search_results.selected_tracks_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::ArtistSearch => {\n      if let Some(_result) = &app.search_results.artists {\n        let next_index = common_key_events::on_high_press_handler();\n        app.search_results.selected_artists_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::PlaylistSearch => {\n      if let Some(_result) = &app.search_results.playlists {\n        let next_index = common_key_events::on_high_press_handler();\n        app.search_results.selected_playlists_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::ShowSearch => {\n      if let Some(_result) = &app.search_results.shows {\n        let next_index = common_key_events::on_high_press_handler();\n        app.search_results.selected_shows_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::Empty => {}\n  }\n}\n\nfn handle_middle_press_on_selected_block(app: &mut App) {\n  match app.search_results.selected_block {\n    SearchResultBlock::AlbumSearch => {\n      if let Some(result) = &app.search_results.albums {\n        let next_index = common_key_events::on_middle_press_handler(&result.items);\n        app.search_results.selected_album_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::SongSearch => {\n      if let Some(result) = &app.search_results.tracks {\n        let next_index = common_key_events::on_middle_press_handler(&result.items);\n        app.search_results.selected_tracks_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::ArtistSearch => {\n      if let Some(result) = &app.search_results.artists {\n        let next_index = common_key_events::on_middle_press_handler(&result.items);\n        app.search_results.selected_artists_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::PlaylistSearch => {\n      if let Some(result) = &app.search_results.playlists {\n        let next_index = common_key_events::on_middle_press_handler(&result.items);\n        app.search_results.selected_playlists_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::ShowSearch => {\n      if let Some(result) = &app.search_results.shows {\n        let next_index = common_key_events::on_middle_press_handler(&result.items);\n        app.search_results.selected_shows_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::Empty => {}\n  }\n}\n\nfn handle_low_press_on_selected_block(app: &mut App) {\n  match app.search_results.selected_block {\n    SearchResultBlock::AlbumSearch => {\n      if let Some(result) = &app.search_results.albums {\n        let next_index = common_key_events::on_low_press_handler(&result.items);\n        app.search_results.selected_album_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::SongSearch => {\n      if let Some(result) = &app.search_results.tracks {\n        let next_index = common_key_events::on_low_press_handler(&result.items);\n        app.search_results.selected_tracks_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::ArtistSearch => {\n      if let Some(result) = &app.search_results.artists {\n        let next_index = common_key_events::on_low_press_handler(&result.items);\n        app.search_results.selected_artists_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::PlaylistSearch => {\n      if let Some(result) = &app.search_results.playlists {\n        let next_index = common_key_events::on_low_press_handler(&result.items);\n        app.search_results.selected_playlists_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::ShowSearch => {\n      if let Some(result) = &app.search_results.shows {\n        let next_index = common_key_events::on_low_press_handler(&result.items);\n        app.search_results.selected_shows_index = Some(next_index);\n      }\n    }\n    SearchResultBlock::Empty => {}\n  }\n}\n\nfn handle_add_item_to_queue(app: &mut App) {\n  match &app.search_results.selected_block {\n    SearchResultBlock::SongSearch => {\n      if let (Some(index), Some(tracks)) = (\n        app.search_results.selected_tracks_index,\n        &app.search_results.tracks,\n      ) {\n        if let Some(track) = tracks.items.get(index) {\n          let uri = track.uri.clone();\n          app.dispatch(IoEvent::AddItemToQueue(uri));\n        }\n      }\n    }\n    SearchResultBlock::ArtistSearch => {}\n    SearchResultBlock::PlaylistSearch => {}\n    SearchResultBlock::AlbumSearch => {}\n    SearchResultBlock::ShowSearch => {}\n    SearchResultBlock::Empty => {}\n  };\n}\n\nfn handle_enter_event_on_selected_block(app: &mut App) {\n  match &app.search_results.selected_block {\n    SearchResultBlock::AlbumSearch => {\n      if let (Some(index), Some(albums_result)) = (\n        &app.search_results.selected_album_index,\n        &app.search_results.albums,\n      ) {\n        if let Some(album) = albums_result.items.get(index.to_owned()).cloned() {\n          app.track_table.context = Some(TrackTableContext::AlbumSearch);\n          app.dispatch(IoEvent::GetAlbumTracks(Box::new(album)));\n        };\n      }\n    }\n    SearchResultBlock::SongSearch => {\n      let index = app.search_results.selected_tracks_index;\n      let tracks = app.search_results.tracks.clone();\n      let track_uris = tracks.map(|tracks| {\n        tracks\n          .items\n          .into_iter()\n          .map(|track| track.uri)\n          .collect::<Vec<String>>()\n      });\n      app.dispatch(IoEvent::StartPlayback(None, track_uris, index));\n    }\n    SearchResultBlock::ArtistSearch => {\n      if let Some(index) = &app.search_results.selected_artists_index {\n        if let Some(result) = app.search_results.artists.clone() {\n          if let Some(artist) = result.items.get(index.to_owned()) {\n            app.get_artist(artist.id.clone(), artist.name.clone());\n            app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock);\n          };\n        };\n      };\n    }\n    SearchResultBlock::PlaylistSearch => {\n      if let (Some(index), Some(playlists_result)) = (\n        app.search_results.selected_playlists_index,\n        &app.search_results.playlists,\n      ) {\n        if let Some(playlist) = playlists_result.items.get(index) {\n          // Go to playlist tracks table\n          app.track_table.context = Some(TrackTableContext::PlaylistSearch);\n          let playlist_id = playlist.id.to_owned();\n          app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, app.playlist_offset));\n        };\n      }\n    }\n    SearchResultBlock::ShowSearch => {\n      if let (Some(index), Some(shows_result)) = (\n        app.search_results.selected_shows_index,\n        &app.search_results.shows,\n      ) {\n        if let Some(show) = shows_result.items.get(index).cloned() {\n          // Go to show tracks table\n          app.dispatch(IoEvent::GetShowEpisodes(Box::new(show)));\n        };\n      }\n    }\n    SearchResultBlock::Empty => {}\n  };\n}\n\nfn handle_enter_event_on_hovered_block(app: &mut App) {\n  match app.search_results.hovered_block {\n    SearchResultBlock::AlbumSearch => {\n      let next_index = app.search_results.selected_album_index.unwrap_or(0);\n\n      app.search_results.selected_album_index = Some(next_index);\n      app.search_results.selected_block = SearchResultBlock::AlbumSearch;\n    }\n    SearchResultBlock::SongSearch => {\n      let next_index = app.search_results.selected_tracks_index.unwrap_or(0);\n\n      app.search_results.selected_tracks_index = Some(next_index);\n      app.search_results.selected_block = SearchResultBlock::SongSearch;\n    }\n    SearchResultBlock::ArtistSearch => {\n      let next_index = app.search_results.selected_artists_index.unwrap_or(0);\n\n      app.search_results.selected_artists_index = Some(next_index);\n      app.search_results.selected_block = SearchResultBlock::ArtistSearch;\n    }\n    SearchResultBlock::PlaylistSearch => {\n      let next_index = app.search_results.selected_playlists_index.unwrap_or(0);\n\n      app.search_results.selected_playlists_index = Some(next_index);\n      app.search_results.selected_block = SearchResultBlock::PlaylistSearch;\n    }\n    SearchResultBlock::ShowSearch => {\n      let next_index = app.search_results.selected_shows_index.unwrap_or(0);\n\n      app.search_results.selected_shows_index = Some(next_index);\n      app.search_results.selected_block = SearchResultBlock::ShowSearch;\n    }\n    SearchResultBlock::Empty => {}\n  };\n}\n\nfn handle_recommended_tracks(app: &mut App) {\n  match app.search_results.selected_block {\n    SearchResultBlock::AlbumSearch => {}\n    SearchResultBlock::SongSearch => {\n      if let Some(index) = &app.search_results.selected_tracks_index {\n        if let Some(result) = app.search_results.tracks.clone() {\n          if let Some(track) = result.items.get(index.to_owned()) {\n            let track_id_list: Option<Vec<String>> =\n              track.id.as_ref().map(|id| vec![id.to_string()]);\n\n            app.recommendations_context = Some(RecommendationsContext::Song);\n            app.recommendations_seed = track.name.clone();\n            app.get_recommendations_for_seed(None, track_id_list, Some(track.clone()));\n          };\n        };\n      };\n    }\n    SearchResultBlock::ArtistSearch => {\n      if let Some(index) = &app.search_results.selected_artists_index {\n        if let Some(result) = app.search_results.artists.clone() {\n          if let Some(artist) = result.items.get(index.to_owned()) {\n            let artist_id_list: Option<Vec<String>> = Some(vec![artist.id.clone()]);\n            app.recommendations_context = Some(RecommendationsContext::Artist);\n            app.recommendations_seed = artist.name.clone();\n            app.get_recommendations_for_seed(artist_id_list, None, None);\n          };\n        };\n      };\n    }\n    SearchResultBlock::PlaylistSearch => {}\n    SearchResultBlock::ShowSearch => {}\n    SearchResultBlock::Empty => {}\n  }\n}\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    Key::Esc => {\n      app.search_results.selected_block = SearchResultBlock::Empty;\n    }\n    k if common_key_events::down_event(k) => {\n      if app.search_results.selected_block != SearchResultBlock::Empty {\n        handle_down_press_on_selected_block(app);\n      } else {\n        handle_down_press_on_hovered_block(app);\n      }\n    }\n    k if common_key_events::up_event(k) => {\n      if app.search_results.selected_block != SearchResultBlock::Empty {\n        handle_up_press_on_selected_block(app);\n      } else {\n        handle_up_press_on_hovered_block(app);\n      }\n    }\n    k if common_key_events::left_event(k) => {\n      app.search_results.selected_block = SearchResultBlock::Empty;\n      match app.search_results.hovered_block {\n        SearchResultBlock::AlbumSearch => {\n          common_key_events::handle_left_event(app);\n        }\n        SearchResultBlock::SongSearch => {\n          common_key_events::handle_left_event(app);\n        }\n        SearchResultBlock::ArtistSearch => {\n          app.search_results.hovered_block = SearchResultBlock::SongSearch;\n        }\n        SearchResultBlock::PlaylistSearch => {\n          app.search_results.hovered_block = SearchResultBlock::AlbumSearch;\n        }\n        SearchResultBlock::ShowSearch => {\n          common_key_events::handle_left_event(app);\n        }\n        SearchResultBlock::Empty => {}\n      }\n    }\n    k if common_key_events::right_event(k) => {\n      app.search_results.selected_block = SearchResultBlock::Empty;\n      match app.search_results.hovered_block {\n        SearchResultBlock::AlbumSearch => {\n          app.search_results.hovered_block = SearchResultBlock::PlaylistSearch;\n        }\n        SearchResultBlock::SongSearch => {\n          app.search_results.hovered_block = SearchResultBlock::ArtistSearch;\n        }\n        SearchResultBlock::ArtistSearch => {\n          app.search_results.hovered_block = SearchResultBlock::SongSearch;\n        }\n        SearchResultBlock::PlaylistSearch => {\n          app.search_results.hovered_block = SearchResultBlock::AlbumSearch;\n        }\n        SearchResultBlock::ShowSearch => {}\n        SearchResultBlock::Empty => {}\n      }\n    }\n    k if common_key_events::high_event(k) => {\n      if app.search_results.selected_block != SearchResultBlock::Empty {\n        handle_high_press_on_selected_block(app);\n      }\n    }\n    k if common_key_events::middle_event(k) => {\n      if app.search_results.selected_block != SearchResultBlock::Empty {\n        handle_middle_press_on_selected_block(app);\n      }\n    }\n    k if common_key_events::low_event(k) => {\n      if app.search_results.selected_block != SearchResultBlock::Empty {\n        handle_low_press_on_selected_block(app)\n      }\n    }\n    // Handle pressing enter when block is selected to start playing track\n    Key::Enter => match app.search_results.selected_block {\n      SearchResultBlock::Empty => handle_enter_event_on_hovered_block(app),\n      SearchResultBlock::PlaylistSearch => {\n        app.playlist_offset = 0;\n        handle_enter_event_on_selected_block(app);\n      }\n      _ => handle_enter_event_on_selected_block(app),\n    },\n    Key::Char('w') => match app.search_results.selected_block {\n      SearchResultBlock::AlbumSearch => {\n        app.current_user_saved_album_add(ActiveBlock::SearchResultBlock)\n      }\n      SearchResultBlock::SongSearch => {}\n      SearchResultBlock::ArtistSearch => app.user_follow_artists(ActiveBlock::SearchResultBlock),\n      SearchResultBlock::PlaylistSearch => {\n        app.user_follow_playlist();\n      }\n      SearchResultBlock::ShowSearch => app.user_follow_show(ActiveBlock::SearchResultBlock),\n      SearchResultBlock::Empty => {}\n    },\n    Key::Char('D') => match app.search_results.selected_block {\n      SearchResultBlock::AlbumSearch => {\n        app.current_user_saved_album_delete(ActiveBlock::SearchResultBlock)\n      }\n      SearchResultBlock::SongSearch => {}\n      SearchResultBlock::ArtistSearch => app.user_unfollow_artists(ActiveBlock::SearchResultBlock),\n      SearchResultBlock::PlaylistSearch => {\n        if let (Some(playlists), Some(selected_index)) = (\n          &app.search_results.playlists,\n          app.search_results.selected_playlists_index,\n        ) {\n          let selected_playlist = &playlists.items[selected_index].name;\n          app.dialog = Some(selected_playlist.clone());\n          app.confirm = false;\n\n          app.push_navigation_stack(\n            RouteId::Dialog,\n            ActiveBlock::Dialog(DialogContext::PlaylistSearch),\n          );\n        }\n      }\n      SearchResultBlock::ShowSearch => app.user_unfollow_show(ActiveBlock::SearchResultBlock),\n      SearchResultBlock::Empty => {}\n    },\n    Key::Char('r') => handle_recommended_tracks(app),\n    _ if key == app.user_config.keys.add_item_to_queue => handle_add_item_to_queue(app),\n    // Add `s` to \"see more\" on each option\n    _ => {}\n  }\n}\n"
  },
  {
    "path": "src/handlers/select_device.rs",
    "content": "use super::{\n  super::app::{ActiveBlock, App},\n  common_key_events,\n};\nuse crate::event::Key;\nuse crate::network::IoEvent;\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    Key::Esc => {\n      app.set_current_route_state(Some(ActiveBlock::Library), None);\n    }\n    k if common_key_events::down_event(k) => {\n      match &app.devices {\n        Some(p) => {\n          if let Some(selected_device_index) = app.selected_device_index {\n            let next_index =\n              common_key_events::on_down_press_handler(&p.devices, Some(selected_device_index));\n            app.selected_device_index = Some(next_index);\n          }\n        }\n        None => {}\n      };\n    }\n    k if common_key_events::up_event(k) => {\n      match &app.devices {\n        Some(p) => {\n          if let Some(selected_device_index) = app.selected_device_index {\n            let next_index =\n              common_key_events::on_up_press_handler(&p.devices, Some(selected_device_index));\n            app.selected_device_index = Some(next_index);\n          }\n        }\n        None => {}\n      };\n    }\n    k if common_key_events::high_event(k) => {\n      match &app.devices {\n        Some(_p) => {\n          if let Some(_selected_device_index) = app.selected_device_index {\n            let next_index = common_key_events::on_high_press_handler();\n            app.selected_device_index = Some(next_index);\n          }\n        }\n        None => {}\n      };\n    }\n    k if common_key_events::middle_event(k) => {\n      match &app.devices {\n        Some(p) => {\n          if let Some(_selected_device_index) = app.selected_device_index {\n            let next_index = common_key_events::on_middle_press_handler(&p.devices);\n            app.selected_device_index = Some(next_index);\n          }\n        }\n        None => {}\n      };\n    }\n    k if common_key_events::low_event(k) => {\n      match &app.devices {\n        Some(p) => {\n          if let Some(_selected_device_index) = app.selected_device_index {\n            let next_index = common_key_events::on_low_press_handler(&p.devices);\n            app.selected_device_index = Some(next_index);\n          }\n        }\n        None => {}\n      };\n    }\n    Key::Enter => {\n      if let (Some(devices), Some(index)) = (app.devices.clone(), app.selected_device_index) {\n        if let Some(device) = &devices.devices.get(index) {\n          app.dispatch(IoEvent::TransferPlaybackToDevice(device.id.clone()));\n        }\n      };\n    }\n    _ => {}\n  }\n}\n"
  },
  {
    "path": "src/handlers/track_table.rs",
    "content": "use super::{\n  super::app::{App, RecommendationsContext, TrackTable, TrackTableContext},\n  common_key_events,\n};\nuse crate::event::Key;\nuse crate::network::IoEvent;\nuse rand::{thread_rng, Rng};\nuse serde_json::from_value;\n\npub fn handler(key: Key, app: &mut App) {\n  match key {\n    k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),\n    k if common_key_events::down_event(k) => {\n      let next_index = common_key_events::on_down_press_handler(\n        &app.track_table.tracks,\n        Some(app.track_table.selected_index),\n      );\n      app.track_table.selected_index = next_index;\n    }\n    k if common_key_events::up_event(k) => {\n      let next_index = common_key_events::on_up_press_handler(\n        &app.track_table.tracks,\n        Some(app.track_table.selected_index),\n      );\n      app.track_table.selected_index = next_index;\n    }\n    k if common_key_events::high_event(k) => {\n      let next_index = common_key_events::on_high_press_handler();\n      app.track_table.selected_index = next_index;\n    }\n    k if common_key_events::middle_event(k) => {\n      let next_index = common_key_events::on_middle_press_handler(&app.track_table.tracks);\n      app.track_table.selected_index = next_index;\n    }\n    k if common_key_events::low_event(k) => {\n      let next_index = common_key_events::on_low_press_handler(&app.track_table.tracks);\n      app.track_table.selected_index = next_index;\n    }\n    Key::Enter => {\n      on_enter(app);\n    }\n    // Scroll down\n    k if k == app.user_config.keys.next_page => {\n      match &app.track_table.context {\n        Some(context) => match context {\n          TrackTableContext::MyPlaylists => {\n            if let (Some(playlists), Some(selected_playlist_index)) =\n              (&app.playlists, &app.selected_playlist_index)\n            {\n              if let Some(selected_playlist) =\n                playlists.items.get(selected_playlist_index.to_owned())\n              {\n                if let Some(playlist_tracks) = &app.playlist_tracks {\n                  if app.playlist_offset + app.large_search_limit < playlist_tracks.total {\n                    app.playlist_offset += app.large_search_limit;\n                    let playlist_id = selected_playlist.id.to_owned();\n                    app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, app.playlist_offset));\n                  }\n                }\n              }\n            };\n          }\n          TrackTableContext::RecommendedTracks => {}\n          TrackTableContext::SavedTracks => {\n            app.get_current_user_saved_tracks_next();\n          }\n          TrackTableContext::AlbumSearch => {}\n          TrackTableContext::PlaylistSearch => {}\n          TrackTableContext::MadeForYou => {\n            let (playlists, selected_playlist_index) =\n              (&app.library.made_for_you_playlists, &app.made_for_you_index);\n\n            if let Some(selected_playlist) = playlists\n              .get_results(Some(0))\n              .unwrap()\n              .items\n              .get(selected_playlist_index.to_owned())\n            {\n              if let Some(playlist_tracks) = &app.made_for_you_tracks {\n                if app.made_for_you_offset + app.large_search_limit < playlist_tracks.total {\n                  app.made_for_you_offset += app.large_search_limit;\n                  let playlist_id = selected_playlist.id.to_owned();\n                  app.dispatch(IoEvent::GetMadeForYouPlaylistTracks(\n                    playlist_id,\n                    app.made_for_you_offset,\n                  ));\n                }\n              }\n            }\n          }\n        },\n        None => {}\n      };\n    }\n    // Scroll up\n    k if k == app.user_config.keys.previous_page => {\n      match &app.track_table.context {\n        Some(context) => match context {\n          TrackTableContext::MyPlaylists => {\n            if let (Some(playlists), Some(selected_playlist_index)) =\n              (&app.playlists, &app.selected_playlist_index)\n            {\n              if app.playlist_offset >= app.large_search_limit {\n                app.playlist_offset -= app.large_search_limit;\n              };\n              if let Some(selected_playlist) =\n                playlists.items.get(selected_playlist_index.to_owned())\n              {\n                let playlist_id = selected_playlist.id.to_owned();\n                app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, app.playlist_offset));\n              }\n            };\n          }\n          TrackTableContext::RecommendedTracks => {}\n          TrackTableContext::SavedTracks => {\n            app.get_current_user_saved_tracks_previous();\n          }\n          TrackTableContext::AlbumSearch => {}\n          TrackTableContext::PlaylistSearch => {}\n          TrackTableContext::MadeForYou => {\n            let (playlists, selected_playlist_index) = (\n              &app\n                .library\n                .made_for_you_playlists\n                .get_results(Some(0))\n                .unwrap(),\n              app.made_for_you_index,\n            );\n            if app.made_for_you_offset >= app.large_search_limit {\n              app.made_for_you_offset -= app.large_search_limit;\n            }\n            if let Some(selected_playlist) = playlists.items.get(selected_playlist_index) {\n              let playlist_id = selected_playlist.id.to_owned();\n              app.dispatch(IoEvent::GetMadeForYouPlaylistTracks(\n                playlist_id,\n                app.made_for_you_offset,\n              ));\n            }\n          }\n        },\n        None => {}\n      };\n    }\n    Key::Char('s') => handle_save_track_event(app),\n    Key::Char('S') => play_random_song(app),\n    k if k == app.user_config.keys.jump_to_end => jump_to_end(app),\n    k if k == app.user_config.keys.jump_to_start => jump_to_start(app),\n    //recommended song radio\n    Key::Char('r') => {\n      handle_recommended_tracks(app);\n    }\n    _ if key == app.user_config.keys.add_item_to_queue => on_queue(app),\n    _ => {}\n  }\n}\n\nfn play_random_song(app: &mut App) {\n  if let Some(context) = &app.track_table.context {\n    match context {\n      TrackTableContext::MyPlaylists => {\n        let (context_uri, track_json) = match (&app.selected_playlist_index, &app.playlists) {\n          (Some(selected_playlist_index), Some(playlists)) => {\n            if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned())\n            {\n              (\n                Some(selected_playlist.uri.to_owned()),\n                selected_playlist.tracks.get(\"total\"),\n              )\n            } else {\n              (None, None)\n            }\n          }\n          _ => (None, None),\n        };\n\n        if let Some(val) = track_json {\n          let num_tracks: usize = from_value(val.clone()).unwrap();\n          app.dispatch(IoEvent::StartPlayback(\n            context_uri,\n            None,\n            Some(thread_rng().gen_range(0..num_tracks)),\n          ));\n        }\n      }\n      TrackTableContext::RecommendedTracks => {}\n      TrackTableContext::SavedTracks => {\n        if let Some(saved_tracks) = &app.library.saved_tracks.get_results(None) {\n          let track_uris: Vec<String> = saved_tracks\n            .items\n            .iter()\n            .map(|item| item.track.uri.to_owned())\n            .collect();\n          let rand_idx = thread_rng().gen_range(0..track_uris.len());\n          app.dispatch(IoEvent::StartPlayback(\n            None,\n            Some(track_uris),\n            Some(rand_idx),\n          ))\n        }\n      }\n      TrackTableContext::AlbumSearch => {}\n      TrackTableContext::PlaylistSearch => {\n        let (context_uri, playlist_track_json) = match (\n          &app.search_results.selected_playlists_index,\n          &app.search_results.playlists,\n        ) {\n          (Some(selected_playlist_index), Some(playlist_result)) => {\n            if let Some(selected_playlist) = playlist_result\n              .items\n              .get(selected_playlist_index.to_owned())\n            {\n              (\n                Some(selected_playlist.uri.to_owned()),\n                selected_playlist.tracks.get(\"total\"),\n              )\n            } else {\n              (None, None)\n            }\n          }\n          _ => (None, None),\n        };\n        if let Some(val) = playlist_track_json {\n          let num_tracks: usize = from_value(val.clone()).unwrap();\n          app.dispatch(IoEvent::StartPlayback(\n            context_uri,\n            None,\n            Some(thread_rng().gen_range(0..num_tracks)),\n          ))\n        }\n      }\n      TrackTableContext::MadeForYou => {\n        if let Some(playlist) = &app\n          .library\n          .made_for_you_playlists\n          .get_results(Some(0))\n          .and_then(|playlist| playlist.items.get(app.made_for_you_index))\n        {\n          if let Some(num_tracks) = &playlist\n            .tracks\n            .get(\"total\")\n            .and_then(|total| -> Option<usize> { from_value(total.clone()).ok() })\n          {\n            let uri = Some(playlist.uri.clone());\n            app.dispatch(IoEvent::StartPlayback(\n              uri,\n              None,\n              Some(thread_rng().gen_range(0..*num_tracks)),\n            ))\n          };\n        };\n      }\n    }\n  };\n}\n\nfn handle_save_track_event(app: &mut App) {\n  let (selected_index, tracks) = (&app.track_table.selected_index, &app.track_table.tracks);\n  if let Some(track) = tracks.get(*selected_index) {\n    if let Some(id) = &track.id {\n      let id = id.to_string();\n      app.dispatch(IoEvent::ToggleSaveTrack(id));\n    };\n  };\n}\n\nfn handle_recommended_tracks(app: &mut App) {\n  let (selected_index, tracks) = (&app.track_table.selected_index, &app.track_table.tracks);\n  if let Some(track) = tracks.get(*selected_index) {\n    let first_track = track.clone();\n    let track_id_list = track.id.as_ref().map(|id| vec![id.to_string()]);\n\n    app.recommendations_context = Some(RecommendationsContext::Song);\n    app.recommendations_seed = first_track.name.clone();\n    app.get_recommendations_for_seed(None, track_id_list, Some(first_track));\n  };\n}\n\nfn jump_to_end(app: &mut App) {\n  match &app.track_table.context {\n    Some(context) => match context {\n      TrackTableContext::MyPlaylists => {\n        if let (Some(playlists), Some(selected_playlist_index)) =\n          (&app.playlists, &app.selected_playlist_index)\n        {\n          if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) {\n            let total_tracks = selected_playlist\n              .tracks\n              .get(\"total\")\n              .and_then(|total| total.as_u64())\n              .expect(\"playlist.tracks object should have a total field\")\n              as u32;\n\n            if app.large_search_limit < total_tracks {\n              app.playlist_offset = total_tracks - (total_tracks % app.large_search_limit);\n              let playlist_id = selected_playlist.id.to_owned();\n              app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, app.playlist_offset));\n            }\n          }\n        }\n      }\n      TrackTableContext::RecommendedTracks => {}\n      TrackTableContext::SavedTracks => {}\n      TrackTableContext::AlbumSearch => {}\n      TrackTableContext::PlaylistSearch => {}\n      TrackTableContext::MadeForYou => {}\n    },\n    None => {}\n  }\n}\n\nfn on_enter(app: &mut App) {\n  let TrackTable {\n    context,\n    selected_index,\n    tracks,\n  } = &app.track_table;\n  match &context {\n    Some(context) => match context {\n      TrackTableContext::MyPlaylists => {\n        if let Some(_track) = tracks.get(*selected_index) {\n          let context_uri = match (&app.active_playlist_index, &app.playlists) {\n            (Some(active_playlist_index), Some(playlists)) => playlists\n              .items\n              .get(active_playlist_index.to_owned())\n              .map(|selected_playlist| selected_playlist.uri.to_owned()),\n            _ => None,\n          };\n\n          app.dispatch(IoEvent::StartPlayback(\n            context_uri,\n            None,\n            Some(app.track_table.selected_index + app.playlist_offset as usize),\n          ));\n        };\n      }\n      TrackTableContext::RecommendedTracks => {\n        app.dispatch(IoEvent::StartPlayback(\n          None,\n          Some(\n            app\n              .recommended_tracks\n              .iter()\n              .map(|x| x.uri.clone())\n              .collect::<Vec<String>>(),\n          ),\n          Some(app.track_table.selected_index),\n        ));\n      }\n      TrackTableContext::SavedTracks => {\n        if let Some(saved_tracks) = &app.library.saved_tracks.get_results(None) {\n          let track_uris: Vec<String> = saved_tracks\n            .items\n            .iter()\n            .map(|item| item.track.uri.to_owned())\n            .collect();\n\n          app.dispatch(IoEvent::StartPlayback(\n            None,\n            Some(track_uris),\n            Some(app.track_table.selected_index),\n          ));\n        };\n      }\n      TrackTableContext::AlbumSearch => {}\n      TrackTableContext::PlaylistSearch => {\n        let TrackTable {\n          selected_index,\n          tracks,\n          ..\n        } = &app.track_table;\n        if let Some(_track) = tracks.get(*selected_index) {\n          let context_uri = match (\n            &app.search_results.selected_playlists_index,\n            &app.search_results.playlists,\n          ) {\n            (Some(selected_playlist_index), Some(playlist_result)) => playlist_result\n              .items\n              .get(selected_playlist_index.to_owned())\n              .map(|selected_playlist| selected_playlist.uri.to_owned()),\n            _ => None,\n          };\n\n          app.dispatch(IoEvent::StartPlayback(\n            context_uri,\n            None,\n            Some(app.track_table.selected_index),\n          ));\n        };\n      }\n      TrackTableContext::MadeForYou => {\n        if let Some(_track) = tracks.get(*selected_index) {\n          let context_uri = Some(\n            app\n              .library\n              .made_for_you_playlists\n              .get_results(Some(0))\n              .unwrap()\n              .items\n              .get(app.made_for_you_index)\n              .unwrap()\n              .uri\n              .to_owned(),\n          );\n\n          app.dispatch(IoEvent::StartPlayback(\n            context_uri,\n            None,\n            Some(app.track_table.selected_index + app.made_for_you_offset as usize),\n          ));\n        }\n      }\n    },\n    None => {}\n  };\n}\n\nfn on_queue(app: &mut App) {\n  let TrackTable {\n    context,\n    selected_index,\n    tracks,\n  } = &app.track_table;\n  match &context {\n    Some(context) => match context {\n      TrackTableContext::MyPlaylists => {\n        if let Some(track) = tracks.get(*selected_index) {\n          let uri = track.uri.clone();\n          app.dispatch(IoEvent::AddItemToQueue(uri));\n        };\n      }\n      TrackTableContext::RecommendedTracks => {\n        if let Some(full_track) = app.recommended_tracks.get(app.track_table.selected_index) {\n          let uri = full_track.uri.clone();\n          app.dispatch(IoEvent::AddItemToQueue(uri));\n        }\n      }\n      TrackTableContext::SavedTracks => {\n        if let Some(page) = app.library.saved_tracks.get_results(None) {\n          if let Some(saved_track) = page.items.get(app.track_table.selected_index) {\n            let uri = saved_track.track.uri.clone();\n            app.dispatch(IoEvent::AddItemToQueue(uri));\n          }\n        }\n      }\n      TrackTableContext::AlbumSearch => {}\n      TrackTableContext::PlaylistSearch => {\n        let TrackTable {\n          selected_index,\n          tracks,\n          ..\n        } = &app.track_table;\n        if let Some(track) = tracks.get(*selected_index) {\n          let uri = track.uri.clone();\n          app.dispatch(IoEvent::AddItemToQueue(uri));\n        };\n      }\n      TrackTableContext::MadeForYou => {\n        if let Some(track) = tracks.get(*selected_index) {\n          let uri = track.uri.clone();\n          app.dispatch(IoEvent::AddItemToQueue(uri));\n        }\n      }\n    },\n    None => {}\n  };\n}\n\nfn jump_to_start(app: &mut App) {\n  match &app.track_table.context {\n    Some(context) => match context {\n      TrackTableContext::MyPlaylists => {\n        if let (Some(playlists), Some(selected_playlist_index)) =\n          (&app.playlists, &app.selected_playlist_index)\n        {\n          if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) {\n            app.playlist_offset = 0;\n            let playlist_id = selected_playlist.id.to_owned();\n            app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, app.playlist_offset));\n          }\n        }\n      }\n      TrackTableContext::RecommendedTracks => {}\n      TrackTableContext::SavedTracks => {}\n      TrackTableContext::AlbumSearch => {}\n      TrackTableContext::PlaylistSearch => {}\n      TrackTableContext::MadeForYou => {}\n    },\n    None => {}\n  }\n}\n"
  },
  {
    "path": "src/main.rs",
    "content": "mod app;\nmod banner;\nmod cli;\nmod config;\nmod event;\nmod handlers;\nmod network;\nmod redirect_uri;\nmod ui;\nmod user_config;\n\nuse crate::app::RouteId;\nuse crate::event::Key;\nuse anyhow::{anyhow, Result};\nuse app::{ActiveBlock, App};\nuse backtrace::Backtrace;\nuse banner::BANNER;\nuse clap::{App as ClapApp, Arg, Shell};\nuse config::ClientConfig;\nuse crossterm::{\n  cursor::MoveTo,\n  event::{DisableMouseCapture, EnableMouseCapture},\n  execute,\n  style::Print,\n  terminal::{\n    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, SetTitle,\n  },\n  ExecutableCommand,\n};\nuse network::{get_spotify, IoEvent, Network};\nuse redirect_uri::redirect_uri_web_server;\nuse rspotify::{\n  oauth2::{SpotifyOAuth, TokenInfo},\n  util::{process_token, request_token},\n};\nuse std::{\n  cmp::{max, min},\n  io::{self, stdout},\n  panic::{self, PanicInfo},\n  path::PathBuf,\n  sync::Arc,\n  time::SystemTime,\n};\nuse tokio::sync::Mutex;\nuse tui::{\n  backend::{Backend, CrosstermBackend},\n  Terminal,\n};\nuse user_config::{UserConfig, UserConfigPaths};\n\nconst SCOPES: [&str; 14] = [\n  \"playlist-read-collaborative\",\n  \"playlist-read-private\",\n  \"playlist-modify-private\",\n  \"playlist-modify-public\",\n  \"user-follow-read\",\n  \"user-follow-modify\",\n  \"user-library-modify\",\n  \"user-library-read\",\n  \"user-modify-playback-state\",\n  \"user-read-currently-playing\",\n  \"user-read-playback-state\",\n  \"user-read-playback-position\",\n  \"user-read-private\",\n  \"user-read-recently-played\",\n];\n\n/// get token automatically with local webserver\npub async fn get_token_auto(spotify_oauth: &mut SpotifyOAuth, port: u16) -> Option<TokenInfo> {\n  match spotify_oauth.get_cached_token().await {\n    Some(token_info) => Some(token_info),\n    None => match redirect_uri_web_server(spotify_oauth, port) {\n      Ok(mut url) => process_token(spotify_oauth, &mut url).await,\n      Err(()) => {\n        println!(\"Starting webserver failed. Continuing with manual authentication\");\n        request_token(spotify_oauth);\n        println!(\"Enter the URL you were redirected to: \");\n        let mut input = String::new();\n        match io::stdin().read_line(&mut input) {\n          Ok(_) => process_token(spotify_oauth, &mut input).await,\n          Err(_) => None,\n        }\n      }\n    },\n  }\n}\n\nfn close_application() -> Result<()> {\n  disable_raw_mode()?;\n  let mut stdout = io::stdout();\n  execute!(stdout, LeaveAlternateScreen, DisableMouseCapture)?;\n  Ok(())\n}\n\nfn panic_hook(info: &PanicInfo<'_>) {\n  if cfg!(debug_assertions) {\n    let location = info.location().unwrap();\n\n    let msg = match info.payload().downcast_ref::<&'static str>() {\n      Some(s) => *s,\n      None => match info.payload().downcast_ref::<String>() {\n        Some(s) => &s[..],\n        None => \"Box<Any>\",\n      },\n    };\n\n    let stacktrace: String = format!(\"{:?}\", Backtrace::new()).replace('\\n', \"\\n\\r\");\n\n    disable_raw_mode().unwrap();\n    execute!(\n      io::stdout(),\n      LeaveAlternateScreen,\n      Print(format!(\n        \"thread '<unnamed>' panicked at '{}', {}\\n\\r{}\",\n        msg, location, stacktrace\n      )),\n      DisableMouseCapture\n    )\n    .unwrap();\n  }\n}\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n  panic::set_hook(Box::new(|info| {\n    panic_hook(info);\n  }));\n\n  let mut clap_app = ClapApp::new(env!(\"CARGO_PKG_NAME\"))\n    .version(env!(\"CARGO_PKG_VERSION\"))\n    .author(env!(\"CARGO_PKG_AUTHORS\"))\n    .about(env!(\"CARGO_PKG_DESCRIPTION\"))\n    .usage(\"Press `?` while running the app to see keybindings\")\n    .before_help(BANNER)\n    .after_help(\n      \"Your spotify Client ID and Client Secret are stored in $HOME/.config/spotify-tui/client.yml\",\n    )\n    .arg(\n      Arg::with_name(\"tick-rate\")\n        .short(\"t\")\n        .long(\"tick-rate\")\n        .help(\"Set the tick rate (milliseconds): the lower the number the higher the FPS.\")\n        .long_help(\n          \"Specify the tick rate in milliseconds: the lower the number the \\\nhigher the FPS. It can be nicer to have a lower value when you want to use the audio analysis view \\\nof the app. Beware that this comes at a CPU cost!\",\n        )\n        .takes_value(true),\n    )\n    .arg(\n      Arg::with_name(\"config\")\n        .short(\"c\")\n        .long(\"config\")\n        .help(\"Specify configuration file path.\")\n        .takes_value(true),\n    )\n    .arg(\n      Arg::with_name(\"completions\")\n        .long(\"completions\")\n        .help(\"Generates completions for your preferred shell\")\n        .takes_value(true)\n        .possible_values(&[\"bash\", \"zsh\", \"fish\", \"power-shell\", \"elvish\"])\n        .value_name(\"SHELL\"),\n    )\n    // Control spotify from the command line\n    .subcommand(cli::playback_subcommand())\n    .subcommand(cli::play_subcommand())\n    .subcommand(cli::list_subcommand())\n    .subcommand(cli::search_subcommand());\n\n  let matches = clap_app.clone().get_matches();\n\n  // Shell completions don't need any spotify work\n  if let Some(s) = matches.value_of(\"completions\") {\n    let shell = match s {\n      \"fish\" => Shell::Fish,\n      \"bash\" => Shell::Bash,\n      \"zsh\" => Shell::Zsh,\n      \"power-shell\" => Shell::PowerShell,\n      \"elvish\" => Shell::Elvish,\n      _ => return Err(anyhow!(\"no completions avaible for '{}'\", s)),\n    };\n    clap_app.gen_completions_to(\"spt\", shell, &mut io::stdout());\n    return Ok(());\n  }\n\n  let mut user_config = UserConfig::new();\n  if let Some(config_file_path) = matches.value_of(\"config\") {\n    let config_file_path = PathBuf::from(config_file_path);\n    let path = UserConfigPaths { config_file_path };\n    user_config.path_to_config.replace(path);\n  }\n  user_config.load_config()?;\n\n  if let Some(tick_rate) = matches\n    .value_of(\"tick-rate\")\n    .and_then(|tick_rate| tick_rate.parse().ok())\n  {\n    if tick_rate >= 1000 {\n      panic!(\"Tick rate must be below 1000\");\n    } else {\n      user_config.behavior.tick_rate_milliseconds = tick_rate;\n    }\n  }\n\n  let mut client_config = ClientConfig::new();\n  client_config.load_config()?;\n\n  let config_paths = client_config.get_or_build_paths()?;\n\n  // Start authorization with spotify\n  let mut oauth = SpotifyOAuth::default()\n    .client_id(&client_config.client_id)\n    .client_secret(&client_config.client_secret)\n    .redirect_uri(&client_config.get_redirect_uri())\n    .cache_path(config_paths.token_cache_path)\n    .scope(&SCOPES.join(\" \"))\n    .build();\n\n  let config_port = client_config.get_port();\n  match get_token_auto(&mut oauth, config_port).await {\n    Some(token_info) => {\n      let (sync_io_tx, sync_io_rx) = std::sync::mpsc::channel::<IoEvent>();\n\n      let (spotify, token_expiry) = get_spotify(token_info);\n\n      // Initialise app state\n      let app = Arc::new(Mutex::new(App::new(\n        sync_io_tx,\n        user_config.clone(),\n        token_expiry,\n      )));\n\n      // Work with the cli (not really async)\n      if let Some(cmd) = matches.subcommand_name() {\n        // Save, because we checked if the subcommand is present at runtime\n        let m = matches.subcommand_matches(cmd).unwrap();\n        let network = Network::new(oauth, spotify, client_config, &app);\n        println!(\n          \"{}\",\n          cli::handle_matches(m, cmd.to_string(), network, user_config).await?\n        );\n      // Launch the UI (async)\n      } else {\n        let cloned_app = Arc::clone(&app);\n        std::thread::spawn(move || {\n          let mut network = Network::new(oauth, spotify, client_config, &app);\n          start_tokio(sync_io_rx, &mut network);\n        });\n        // The UI must run in the \"main\" thread\n        start_ui(user_config, &cloned_app).await?;\n      }\n    }\n    None => println!(\"\\nSpotify auth failed\"),\n  }\n\n  Ok(())\n}\n\n#[tokio::main]\nasync fn start_tokio<'a>(io_rx: std::sync::mpsc::Receiver<IoEvent>, network: &mut Network) {\n  while let Ok(io_event) = io_rx.recv() {\n    network.handle_network_event(io_event).await;\n  }\n}\n\nasync fn start_ui(user_config: UserConfig, app: &Arc<Mutex<App>>) -> Result<()> {\n  // Terminal initialization\n  let mut stdout = stdout();\n  execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;\n  enable_raw_mode()?;\n\n  let mut backend = CrosstermBackend::new(stdout);\n\n  if user_config.behavior.set_window_title {\n    backend.execute(SetTitle(\"spt - Spotify TUI\"))?;\n  }\n\n  let mut terminal = Terminal::new(backend)?;\n  terminal.hide_cursor()?;\n\n  let events = event::Events::new(user_config.behavior.tick_rate_milliseconds);\n\n  // play music on, if not send them to the device selection view\n\n  let mut is_first_render = true;\n\n  loop {\n    let mut app = app.lock().await;\n    // Get the size of the screen on each loop to account for resize event\n    if let Ok(size) = terminal.backend().size() {\n      // Reset the help menu is the terminal was resized\n      if is_first_render || app.size != size {\n        app.help_menu_max_lines = 0;\n        app.help_menu_offset = 0;\n        app.help_menu_page = 0;\n\n        app.size = size;\n\n        // Based on the size of the terminal, adjust the search limit.\n        let potential_limit = max((app.size.height as i32) - 13, 0) as u32;\n        let max_limit = min(potential_limit, 50);\n        let large_search_limit = min((f32::from(size.height) / 1.4) as u32, max_limit);\n        let small_search_limit = min((f32::from(size.height) / 2.85) as u32, max_limit / 2);\n\n        app.dispatch(IoEvent::UpdateSearchLimits(\n          large_search_limit,\n          small_search_limit,\n        ));\n\n        // Based on the size of the terminal, adjust how many lines are\n        // displayed in the help menu\n        if app.size.height > 8 {\n          app.help_menu_max_lines = (app.size.height as u32) - 8;\n        } else {\n          app.help_menu_max_lines = 0;\n        }\n      }\n    };\n\n    let current_route = app.get_current_route();\n    terminal.draw(|mut f| match current_route.active_block {\n      ActiveBlock::HelpMenu => {\n        ui::draw_help_menu(&mut f, &app);\n      }\n      ActiveBlock::Error => {\n        ui::draw_error_screen(&mut f, &app);\n      }\n      ActiveBlock::SelectDevice => {\n        ui::draw_device_list(&mut f, &app);\n      }\n      ActiveBlock::Analysis => {\n        ui::audio_analysis::draw(&mut f, &app);\n      }\n      ActiveBlock::BasicView => {\n        ui::draw_basic_view(&mut f, &app);\n      }\n      _ => {\n        ui::draw_main_layout(&mut f, &app);\n      }\n    })?;\n\n    if current_route.active_block == ActiveBlock::Input {\n      terminal.show_cursor()?;\n    } else {\n      terminal.hide_cursor()?;\n    }\n\n    let cursor_offset = if app.size.height > ui::util::SMALL_TERMINAL_HEIGHT {\n      2\n    } else {\n      1\n    };\n\n    // Put the cursor back inside the input box\n    terminal.backend_mut().execute(MoveTo(\n      cursor_offset + app.input_cursor_position,\n      cursor_offset,\n    ))?;\n\n    // Handle authentication refresh\n    if SystemTime::now() > app.spotify_token_expiry {\n      app.dispatch(IoEvent::RefreshAuthentication);\n    }\n\n    match events.next()? {\n      event::Event::Input(key) => {\n        if key == Key::Ctrl('c') {\n          break;\n        }\n\n        let current_active_block = app.get_current_route().active_block;\n\n        // To avoid swallowing the global key presses `q` and `-` make a special\n        // case for the input handler\n        if current_active_block == ActiveBlock::Input {\n          handlers::input_handler(key, &mut app);\n        } else if key == app.user_config.keys.back {\n          if app.get_current_route().active_block != ActiveBlock::Input {\n            // Go back through navigation stack when not in search input mode and exit the app if there are no more places to back to\n\n            let pop_result = match app.pop_navigation_stack() {\n              Some(ref x) if x.id == RouteId::Search => app.pop_navigation_stack(),\n              Some(x) => Some(x),\n              None => None,\n            };\n            if pop_result.is_none() {\n              break; // Exit application\n            }\n          }\n        } else {\n          handlers::handle_app(key, &mut app);\n        }\n      }\n      event::Event::Tick => {\n        app.update_on_tick();\n      }\n    }\n\n    // Delay spotify request until first render, will have the effect of improving\n    // startup speed\n    if is_first_render {\n      app.dispatch(IoEvent::GetPlaylists);\n      app.dispatch(IoEvent::GetUser);\n      app.dispatch(IoEvent::GetCurrentPlayback);\n      app.help_docs_size = ui::help::get_help_docs(&app.user_config.keys).len() as u32;\n\n      is_first_render = false;\n    }\n  }\n\n  terminal.show_cursor()?;\n  close_application()?;\n\n  Ok(())\n}\n"
  },
  {
    "path": "src/network.rs",
    "content": "use crate::app::{\n  ActiveBlock, AlbumTableContext, App, Artist, ArtistBlock, EpisodeTableContext, RouteId,\n  ScrollableResultPages, SelectedAlbum, SelectedFullAlbum, SelectedFullShow, SelectedShow,\n  TrackTableContext,\n};\nuse crate::config::ClientConfig;\nuse anyhow::anyhow;\nuse rspotify::{\n  client::Spotify,\n  model::{\n    album::SimplifiedAlbum,\n    artist::FullArtist,\n    offset::for_position,\n    page::Page,\n    playlist::{PlaylistTrack, SimplifiedPlaylist},\n    recommend::Recommendations,\n    search::SearchResult,\n    show::SimplifiedShow,\n    track::FullTrack,\n    PlayingItem,\n  },\n  oauth2::{SpotifyClientCredentials, SpotifyOAuth, TokenInfo},\n  senum::{AdditionalType, Country, RepeatState, SearchType},\n  util::get_token,\n};\nuse serde_json::{map::Map, Value};\nuse std::{\n  sync::Arc,\n  time::{Duration, Instant, SystemTime},\n};\nuse tokio::sync::Mutex;\nuse tokio::try_join;\n\n#[derive(Debug)]\npub enum IoEvent {\n  GetCurrentPlayback,\n  RefreshAuthentication,\n  GetPlaylists,\n  GetDevices,\n  GetSearchResults(String, Option<Country>),\n  SetTracksToTable(Vec<FullTrack>),\n  GetMadeForYouPlaylistTracks(String, u32),\n  GetPlaylistTracks(String, u32),\n  GetCurrentSavedTracks(Option<u32>),\n  StartPlayback(Option<String>, Option<Vec<String>>, Option<usize>),\n  UpdateSearchLimits(u32, u32),\n  Seek(u32),\n  NextTrack,\n  PreviousTrack,\n  Shuffle(bool),\n  Repeat(RepeatState),\n  PausePlayback,\n  ChangeVolume(u8),\n  GetArtist(String, String, Option<Country>),\n  GetAlbumTracks(Box<SimplifiedAlbum>),\n  GetRecommendationsForSeed(\n    Option<Vec<String>>,\n    Option<Vec<String>>,\n    Box<Option<FullTrack>>,\n    Option<Country>,\n  ),\n  GetCurrentUserSavedAlbums(Option<u32>),\n  CurrentUserSavedAlbumsContains(Vec<String>),\n  CurrentUserSavedAlbumDelete(String),\n  CurrentUserSavedAlbumAdd(String),\n  UserUnfollowArtists(Vec<String>),\n  UserFollowArtists(Vec<String>),\n  UserFollowPlaylist(String, String, Option<bool>),\n  UserUnfollowPlaylist(String, String),\n  MadeForYouSearchAndAdd(String, Option<Country>),\n  GetAudioAnalysis(String),\n  GetUser,\n  ToggleSaveTrack(String),\n  GetRecommendationsForTrackId(String, Option<Country>),\n  GetRecentlyPlayed,\n  GetFollowedArtists(Option<String>),\n  SetArtistsToTable(Vec<FullArtist>),\n  UserArtistFollowCheck(Vec<String>),\n  GetAlbum(String),\n  TransferPlaybackToDevice(String),\n  GetAlbumForTrack(String),\n  CurrentUserSavedTracksContains(Vec<String>),\n  GetCurrentUserSavedShows(Option<u32>),\n  CurrentUserSavedShowsContains(Vec<String>),\n  CurrentUserSavedShowDelete(String),\n  CurrentUserSavedShowAdd(String),\n  GetShowEpisodes(Box<SimplifiedShow>),\n  GetShow(String),\n  GetCurrentShowEpisodes(String, Option<u32>),\n  AddItemToQueue(String),\n}\n\npub fn get_spotify(token_info: TokenInfo) -> (Spotify, SystemTime) {\n  let token_expiry = {\n    if let Some(expires_at) = token_info.expires_at {\n      SystemTime::UNIX_EPOCH\n        + Duration::from_secs(expires_at as u64)\n        // Set 10 seconds early\n        - Duration::from_secs(10)\n    } else {\n      SystemTime::now()\n    }\n  };\n\n  let client_credential = SpotifyClientCredentials::default()\n    .token_info(token_info)\n    .build();\n\n  let spotify = Spotify::default()\n    .client_credentials_manager(client_credential)\n    .build();\n\n  (spotify, token_expiry)\n}\n\n#[derive(Clone)]\npub struct Network<'a> {\n  oauth: SpotifyOAuth,\n  pub spotify: Spotify,\n  large_search_limit: u32,\n  small_search_limit: u32,\n  pub client_config: ClientConfig,\n  pub app: &'a Arc<Mutex<App>>,\n}\n\nimpl<'a> Network<'a> {\n  pub fn new(\n    oauth: SpotifyOAuth,\n    spotify: Spotify,\n    client_config: ClientConfig,\n    app: &'a Arc<Mutex<App>>,\n  ) -> Self {\n    Network {\n      oauth,\n      spotify,\n      large_search_limit: 20,\n      small_search_limit: 4,\n      client_config,\n      app,\n    }\n  }\n\n  #[allow(clippy::cognitive_complexity)]\n  pub async fn handle_network_event(&mut self, io_event: IoEvent) {\n    match io_event {\n      IoEvent::RefreshAuthentication => {\n        self.refresh_authentication().await;\n      }\n      IoEvent::GetPlaylists => {\n        self.get_current_user_playlists().await;\n      }\n      IoEvent::GetUser => {\n        self.get_user().await;\n      }\n      IoEvent::GetDevices => {\n        self.get_devices().await;\n      }\n      IoEvent::GetCurrentPlayback => {\n        self.get_current_playback().await;\n      }\n      IoEvent::SetTracksToTable(full_tracks) => {\n        self.set_tracks_to_table(full_tracks).await;\n      }\n      IoEvent::GetSearchResults(search_term, country) => {\n        self.get_search_results(search_term, country).await;\n      }\n      IoEvent::GetMadeForYouPlaylistTracks(playlist_id, made_for_you_offset) => {\n        self\n          .get_made_for_you_playlist_tracks(playlist_id, made_for_you_offset)\n          .await;\n      }\n      IoEvent::GetPlaylistTracks(playlist_id, playlist_offset) => {\n        self.get_playlist_tracks(playlist_id, playlist_offset).await;\n      }\n      IoEvent::GetCurrentSavedTracks(offset) => {\n        self.get_current_user_saved_tracks(offset).await;\n      }\n      IoEvent::StartPlayback(context_uri, uris, offset) => {\n        self.start_playback(context_uri, uris, offset).await;\n      }\n      IoEvent::UpdateSearchLimits(large_search_limit, small_search_limit) => {\n        self.large_search_limit = large_search_limit;\n        self.small_search_limit = small_search_limit;\n      }\n      IoEvent::Seek(position_ms) => {\n        self.seek(position_ms).await;\n      }\n      IoEvent::NextTrack => {\n        self.next_track().await;\n      }\n      IoEvent::PreviousTrack => {\n        self.previous_track().await;\n      }\n      IoEvent::Repeat(repeat_state) => {\n        self.repeat(repeat_state).await;\n      }\n      IoEvent::PausePlayback => {\n        self.pause_playback().await;\n      }\n      IoEvent::ChangeVolume(volume) => {\n        self.change_volume(volume).await;\n      }\n      IoEvent::GetArtist(artist_id, input_artist_name, country) => {\n        self.get_artist(artist_id, input_artist_name, country).await;\n      }\n      IoEvent::GetAlbumTracks(album) => {\n        self.get_album_tracks(album).await;\n      }\n      IoEvent::GetRecommendationsForSeed(seed_artists, seed_tracks, first_track, country) => {\n        self\n          .get_recommendations_for_seed(seed_artists, seed_tracks, first_track, country)\n          .await;\n      }\n      IoEvent::GetCurrentUserSavedAlbums(offset) => {\n        self.get_current_user_saved_albums(offset).await;\n      }\n      IoEvent::CurrentUserSavedAlbumsContains(album_ids) => {\n        self.current_user_saved_albums_contains(album_ids).await;\n      }\n      IoEvent::CurrentUserSavedAlbumDelete(album_id) => {\n        self.current_user_saved_album_delete(album_id).await;\n      }\n      IoEvent::CurrentUserSavedAlbumAdd(album_id) => {\n        self.current_user_saved_album_add(album_id).await;\n      }\n      IoEvent::UserUnfollowArtists(artist_ids) => {\n        self.user_unfollow_artists(artist_ids).await;\n      }\n      IoEvent::UserFollowArtists(artist_ids) => {\n        self.user_follow_artists(artist_ids).await;\n      }\n      IoEvent::UserFollowPlaylist(playlist_owner_id, playlist_id, is_public) => {\n        self\n          .user_follow_playlist(playlist_owner_id, playlist_id, is_public)\n          .await;\n      }\n      IoEvent::UserUnfollowPlaylist(user_id, playlist_id) => {\n        self.user_unfollow_playlist(user_id, playlist_id).await;\n      }\n      IoEvent::MadeForYouSearchAndAdd(search_term, country) => {\n        self.made_for_you_search_and_add(search_term, country).await;\n      }\n      IoEvent::GetAudioAnalysis(uri) => {\n        self.get_audio_analysis(uri).await;\n      }\n      IoEvent::ToggleSaveTrack(track_id) => {\n        self.toggle_save_track(track_id).await;\n      }\n      IoEvent::GetRecommendationsForTrackId(track_id, country) => {\n        self\n          .get_recommendations_for_track_id(track_id, country)\n          .await;\n      }\n      IoEvent::GetRecentlyPlayed => {\n        self.get_recently_played().await;\n      }\n      IoEvent::GetFollowedArtists(after) => {\n        self.get_followed_artists(after).await;\n      }\n      IoEvent::SetArtistsToTable(full_artists) => {\n        self.set_artists_to_table(full_artists).await;\n      }\n      IoEvent::UserArtistFollowCheck(artist_ids) => {\n        self.user_artist_check_follow(artist_ids).await;\n      }\n      IoEvent::GetAlbum(album_id) => {\n        self.get_album(album_id).await;\n      }\n      IoEvent::TransferPlaybackToDevice(device_id) => {\n        self.transfert_playback_to_device(device_id).await;\n      }\n      IoEvent::GetAlbumForTrack(track_id) => {\n        self.get_album_for_track(track_id).await;\n      }\n      IoEvent::Shuffle(shuffle_state) => {\n        self.shuffle(shuffle_state).await;\n      }\n      IoEvent::CurrentUserSavedTracksContains(track_ids) => {\n        self.current_user_saved_tracks_contains(track_ids).await;\n      }\n      IoEvent::GetCurrentUserSavedShows(offset) => {\n        self.get_current_user_saved_shows(offset).await;\n      }\n      IoEvent::CurrentUserSavedShowsContains(show_ids) => {\n        self.current_user_saved_shows_contains(show_ids).await;\n      }\n      IoEvent::CurrentUserSavedShowDelete(show_id) => {\n        self.current_user_saved_shows_delete(show_id).await;\n      }\n      IoEvent::CurrentUserSavedShowAdd(show_id) => {\n        self.current_user_saved_shows_add(show_id).await;\n      }\n      IoEvent::GetShowEpisodes(show) => {\n        self.get_show_episodes(show).await;\n      }\n      IoEvent::GetShow(show_id) => {\n        self.get_show(show_id).await;\n      }\n      IoEvent::GetCurrentShowEpisodes(show_id, offset) => {\n        self.get_current_show_episodes(show_id, offset).await;\n      }\n      IoEvent::AddItemToQueue(item) => {\n        self.add_item_to_queue(item).await;\n      }\n    };\n\n    let mut app = self.app.lock().await;\n    app.is_loading = false;\n  }\n\n  async fn handle_error(&mut self, e: anyhow::Error) {\n    let mut app = self.app.lock().await;\n    app.handle_error(e);\n  }\n\n  async fn get_user(&mut self) {\n    match self.spotify.current_user().await {\n      Ok(user) => {\n        let mut app = self.app.lock().await;\n        app.user = Some(user);\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn get_devices(&mut self) {\n    if let Ok(result) = self.spotify.device().await {\n      let mut app = self.app.lock().await;\n      app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice);\n      if !result.devices.is_empty() {\n        app.devices = Some(result);\n        // Select the first device in the list\n        app.selected_device_index = Some(0);\n      }\n    }\n  }\n\n  async fn get_current_playback(&mut self) {\n    let context = self\n      .spotify\n      .current_playback(\n        None,\n        Some(vec![AdditionalType::Episode, AdditionalType::Track]),\n      )\n      .await;\n\n    match context {\n      Ok(Some(c)) => {\n        let mut app = self.app.lock().await;\n        app.current_playback_context = Some(c.clone());\n        app.instant_since_last_current_playback_poll = Instant::now();\n\n        if let Some(item) = c.item {\n          match item {\n            PlayingItem::Track(track) => {\n              if let Some(track_id) = track.id {\n                app.dispatch(IoEvent::CurrentUserSavedTracksContains(vec![track_id]));\n              };\n            }\n            PlayingItem::Episode(_episode) => { /*should map this to following the podcast show*/ }\n          }\n        };\n      }\n      Ok(None) => {\n        let mut app = self.app.lock().await;\n        app.instant_since_last_current_playback_poll = Instant::now();\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n\n    let mut app = self.app.lock().await;\n    app.seek_ms.take();\n    app.is_fetching_current_playback = false;\n  }\n\n  async fn current_user_saved_tracks_contains(&mut self, ids: Vec<String>) {\n    match self.spotify.current_user_saved_tracks_contains(&ids).await {\n      Ok(is_saved_vec) => {\n        let mut app = self.app.lock().await;\n        for (i, id) in ids.iter().enumerate() {\n          if let Some(is_liked) = is_saved_vec.get(i) {\n            if *is_liked {\n              app.liked_song_ids_set.insert(id.to_string());\n            } else {\n              // The song is not liked, so check if it should be removed\n              if app.liked_song_ids_set.contains(id) {\n                app.liked_song_ids_set.remove(id);\n              }\n            }\n          };\n        }\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn get_playlist_tracks(&mut self, playlist_id: String, playlist_offset: u32) {\n    if let Ok(playlist_tracks) = self\n      .spotify\n      .user_playlist_tracks(\n        \"spotify\",\n        &playlist_id,\n        None,\n        Some(self.large_search_limit),\n        Some(playlist_offset),\n        None,\n      )\n      .await\n    {\n      self.set_playlist_tracks_to_table(&playlist_tracks).await;\n\n      let mut app = self.app.lock().await;\n      app.playlist_tracks = Some(playlist_tracks);\n      app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable);\n    };\n  }\n\n  async fn set_playlist_tracks_to_table(&mut self, playlist_track_page: &Page<PlaylistTrack>) {\n    self\n      .set_tracks_to_table(\n        playlist_track_page\n          .items\n          .clone()\n          .into_iter()\n          .filter_map(|item| item.track)\n          .collect::<Vec<FullTrack>>(),\n      )\n      .await;\n  }\n\n  async fn set_tracks_to_table(&mut self, tracks: Vec<FullTrack>) {\n    let mut app = self.app.lock().await;\n    app.track_table.tracks = tracks.clone();\n\n    // Send this event round (don't block here)\n    app.dispatch(IoEvent::CurrentUserSavedTracksContains(\n      tracks\n        .into_iter()\n        .filter_map(|item| item.id)\n        .collect::<Vec<String>>(),\n    ));\n  }\n\n  async fn set_artists_to_table(&mut self, artists: Vec<FullArtist>) {\n    let mut app = self.app.lock().await;\n    app.artists = artists;\n  }\n\n  async fn get_made_for_you_playlist_tracks(\n    &mut self,\n    playlist_id: String,\n    made_for_you_offset: u32,\n  ) {\n    if let Ok(made_for_you_tracks) = self\n      .spotify\n      .user_playlist_tracks(\n        \"spotify\",\n        &playlist_id,\n        None,\n        Some(self.large_search_limit),\n        Some(made_for_you_offset),\n        None,\n      )\n      .await\n    {\n      self\n        .set_playlist_tracks_to_table(&made_for_you_tracks)\n        .await;\n\n      let mut app = self.app.lock().await;\n      app.made_for_you_tracks = Some(made_for_you_tracks);\n      if app.get_current_route().id != RouteId::TrackTable {\n        app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable);\n      }\n    }\n  }\n\n  async fn get_current_user_saved_shows(&mut self, offset: Option<u32>) {\n    match self\n      .spotify\n      .get_saved_show(self.large_search_limit, offset)\n      .await\n    {\n      Ok(saved_shows) => {\n        // not to show a blank page\n        if !saved_shows.items.is_empty() {\n          let mut app = self.app.lock().await;\n          app.library.saved_shows.add_pages(saved_shows);\n        }\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn current_user_saved_shows_contains(&mut self, show_ids: Vec<String>) {\n    if let Ok(are_followed) = self\n      .spotify\n      .check_users_saved_shows(show_ids.to_owned())\n      .await\n    {\n      let mut app = self.app.lock().await;\n      show_ids.iter().enumerate().for_each(|(i, id)| {\n        if are_followed[i] {\n          app.saved_show_ids_set.insert(id.to_owned());\n        } else {\n          app.saved_show_ids_set.remove(id);\n        }\n      })\n    }\n  }\n\n  async fn get_show_episodes(&mut self, show: Box<SimplifiedShow>) {\n    match self\n      .spotify\n      .get_shows_episodes(show.id.clone(), self.large_search_limit, 0, None)\n      .await\n    {\n      Ok(episodes) => {\n        if !episodes.items.is_empty() {\n          let mut app = self.app.lock().await;\n          app.library.show_episodes = ScrollableResultPages::new();\n          app.library.show_episodes.add_pages(episodes);\n\n          app.selected_show_simplified = Some(SelectedShow { show: *show });\n\n          app.episode_table_context = EpisodeTableContext::Simplified;\n\n          app.push_navigation_stack(RouteId::PodcastEpisodes, ActiveBlock::EpisodeTable);\n        }\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn get_show(&mut self, show_id: String) {\n    match self.spotify.get_a_show(show_id, None).await {\n      Ok(show) => {\n        let selected_show = SelectedFullShow { show };\n\n        let mut app = self.app.lock().await;\n\n        app.selected_show_full = Some(selected_show);\n\n        app.episode_table_context = EpisodeTableContext::Full;\n        app.push_navigation_stack(RouteId::PodcastEpisodes, ActiveBlock::EpisodeTable);\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn get_current_show_episodes(&mut self, show_id: String, offset: Option<u32>) {\n    match self\n      .spotify\n      .get_shows_episodes(show_id, self.large_search_limit, offset, None)\n      .await\n    {\n      Ok(episodes) => {\n        if !episodes.items.is_empty() {\n          let mut app = self.app.lock().await;\n          app.library.show_episodes.add_pages(episodes);\n        }\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn get_search_results(&mut self, search_term: String, country: Option<Country>) {\n    let search_track = self.spotify.search(\n      &search_term,\n      SearchType::Track,\n      self.small_search_limit,\n      0,\n      country,\n      None,\n    );\n\n    let search_artist = self.spotify.search(\n      &search_term,\n      SearchType::Artist,\n      self.small_search_limit,\n      0,\n      country,\n      None,\n    );\n\n    let search_album = self.spotify.search(\n      &search_term,\n      SearchType::Album,\n      self.small_search_limit,\n      0,\n      country,\n      None,\n    );\n\n    let search_playlist = self.spotify.search(\n      &search_term,\n      SearchType::Playlist,\n      self.small_search_limit,\n      0,\n      country,\n      None,\n    );\n\n    let search_show = self.spotify.search(\n      &search_term,\n      SearchType::Show,\n      self.small_search_limit,\n      0,\n      country,\n      None,\n    );\n\n    // Run the futures concurrently\n    match try_join!(\n      search_track,\n      search_artist,\n      search_album,\n      search_playlist,\n      search_show\n    ) {\n      Ok((\n        SearchResult::Tracks(track_results),\n        SearchResult::Artists(artist_results),\n        SearchResult::Albums(album_results),\n        SearchResult::Playlists(playlist_results),\n        SearchResult::Shows(show_results),\n      )) => {\n        let mut app = self.app.lock().await;\n\n        let artist_ids = album_results\n          .items\n          .iter()\n          .filter_map(|item| item.id.to_owned())\n          .collect();\n\n        // Check if these artists are followed\n        app.dispatch(IoEvent::UserArtistFollowCheck(artist_ids));\n\n        let album_ids = album_results\n          .items\n          .iter()\n          .filter_map(|album| album.id.to_owned())\n          .collect();\n\n        // Check if these albums are saved\n        app.dispatch(IoEvent::CurrentUserSavedAlbumsContains(album_ids));\n\n        let show_ids = show_results\n          .items\n          .iter()\n          .map(|show| show.id.to_owned())\n          .collect();\n\n        // check if these shows are saved\n        app.dispatch(IoEvent::CurrentUserSavedShowsContains(show_ids));\n\n        app.search_results.tracks = Some(track_results);\n        app.search_results.artists = Some(artist_results);\n        app.search_results.albums = Some(album_results);\n        app.search_results.playlists = Some(playlist_results);\n        app.search_results.shows = Some(show_results);\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n      _ => {}\n    };\n  }\n\n  async fn get_current_user_saved_tracks(&mut self, offset: Option<u32>) {\n    match self\n      .spotify\n      .current_user_saved_tracks(self.large_search_limit, offset)\n      .await\n    {\n      Ok(saved_tracks) => {\n        let mut app = self.app.lock().await;\n        app.track_table.tracks = saved_tracks\n          .items\n          .clone()\n          .into_iter()\n          .map(|item| item.track)\n          .collect::<Vec<FullTrack>>();\n\n        saved_tracks.items.iter().for_each(|item| {\n          if let Some(track_id) = &item.track.id {\n            app.liked_song_ids_set.insert(track_id.to_string());\n          }\n        });\n\n        app.library.saved_tracks.add_pages(saved_tracks);\n        app.track_table.context = Some(TrackTableContext::SavedTracks);\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn start_playback(\n    &mut self,\n    context_uri: Option<String>,\n    uris: Option<Vec<String>>,\n    offset: Option<usize>,\n  ) {\n    let (uris, context_uri) = if context_uri.is_some() {\n      (None, context_uri)\n    } else if uris.is_some() {\n      (uris, None)\n    } else {\n      (None, None)\n    };\n\n    let offset = offset.and_then(|o| for_position(o as u32));\n\n    let result = match &self.client_config.device_id {\n      Some(device_id) => {\n        match self\n          .spotify\n          .start_playback(\n            Some(device_id.to_string()),\n            context_uri.clone(),\n            uris.clone(),\n            offset.clone(),\n            None,\n          )\n          .await\n        {\n          Ok(()) => Ok(()),\n          Err(e) => Err(anyhow!(e)),\n        }\n      }\n      None => Err(anyhow!(\"No device_id selected\")),\n    };\n\n    match result {\n      Ok(()) => {\n        let mut app = self.app.lock().await;\n        app.song_progress_ms = 0;\n        app.dispatch(IoEvent::GetCurrentPlayback);\n      }\n      Err(e) => {\n        self.handle_error(e).await;\n      }\n    }\n  }\n\n  async fn seek(&mut self, position_ms: u32) {\n    if let Some(device_id) = &self.client_config.device_id {\n      match self\n        .spotify\n        .seek_track(position_ms, Some(device_id.to_string()))\n        .await\n      {\n        Ok(()) => {\n          // Wait between seek and status query.\n          // Without it, the Spotify API may return the old progress.\n          tokio::time::delay_for(Duration::from_millis(1000)).await;\n          self.get_current_playback().await;\n        }\n        Err(e) => {\n          self.handle_error(anyhow!(e)).await;\n        }\n      };\n    }\n  }\n\n  async fn next_track(&mut self) {\n    match self\n      .spotify\n      .next_track(self.client_config.device_id.clone())\n      .await\n    {\n      Ok(()) => {\n        self.get_current_playback().await;\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    };\n  }\n\n  async fn previous_track(&mut self) {\n    match self\n      .spotify\n      .previous_track(self.client_config.device_id.clone())\n      .await\n    {\n      Ok(()) => {\n        self.get_current_playback().await;\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    };\n  }\n\n  async fn shuffle(&mut self, shuffle_state: bool) {\n    match self\n      .spotify\n      .shuffle(!shuffle_state, self.client_config.device_id.clone())\n      .await\n    {\n      Ok(()) => {\n        // Update the UI eagerly (otherwise the UI will wait until the next 5 second interval\n        // due to polling playback context)\n        let mut app = self.app.lock().await;\n        if let Some(current_playback_context) = &mut app.current_playback_context {\n          current_playback_context.shuffle_state = !shuffle_state;\n        };\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    };\n  }\n\n  async fn repeat(&mut self, repeat_state: RepeatState) {\n    let next_repeat_state = match repeat_state {\n      RepeatState::Off => RepeatState::Context,\n      RepeatState::Context => RepeatState::Track,\n      RepeatState::Track => RepeatState::Off,\n    };\n    match self\n      .spotify\n      .repeat(next_repeat_state, self.client_config.device_id.clone())\n      .await\n    {\n      Ok(()) => {\n        let mut app = self.app.lock().await;\n        if let Some(current_playback_context) = &mut app.current_playback_context {\n          current_playback_context.repeat_state = next_repeat_state;\n        };\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    };\n  }\n\n  async fn pause_playback(&mut self) {\n    match self\n      .spotify\n      .pause_playback(self.client_config.device_id.clone())\n      .await\n    {\n      Ok(()) => {\n        self.get_current_playback().await;\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    };\n  }\n\n  async fn change_volume(&mut self, volume_percent: u8) {\n    match self\n      .spotify\n      .volume(volume_percent, self.client_config.device_id.clone())\n      .await\n    {\n      Ok(()) => {\n        let mut app = self.app.lock().await;\n        if let Some(current_playback_context) = &mut app.current_playback_context {\n          current_playback_context.device.volume_percent = volume_percent.into();\n        };\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    };\n  }\n\n  async fn get_artist(\n    &mut self,\n    artist_id: String,\n    input_artist_name: String,\n    country: Option<Country>,\n  ) {\n    let albums = self.spotify.artist_albums(\n      &artist_id,\n      None,\n      country,\n      Some(self.large_search_limit),\n      Some(0),\n    );\n    let artist_name = if input_artist_name.is_empty() {\n      self\n        .spotify\n        .artist(&artist_id)\n        .await\n        .map(|full_artist| full_artist.name)\n        .unwrap_or_default()\n    } else {\n      input_artist_name\n    };\n    let top_tracks = self.spotify.artist_top_tracks(&artist_id, country);\n    let related_artist = self.spotify.artist_related_artists(&artist_id);\n\n    if let Ok((albums, top_tracks, related_artist)) = try_join!(albums, top_tracks, related_artist)\n    {\n      let mut app = self.app.lock().await;\n\n      app.dispatch(IoEvent::CurrentUserSavedAlbumsContains(\n        albums\n          .items\n          .iter()\n          .filter_map(|item| item.id.to_owned())\n          .collect(),\n      ));\n\n      app.artist = Some(Artist {\n        artist_name,\n        albums,\n        related_artists: related_artist.artists,\n        top_tracks: top_tracks.tracks,\n        selected_album_index: 0,\n        selected_related_artist_index: 0,\n        selected_top_track_index: 0,\n        artist_hovered_block: ArtistBlock::TopTracks,\n        artist_selected_block: ArtistBlock::Empty,\n      });\n    }\n  }\n\n  async fn get_album_tracks(&mut self, album: Box<SimplifiedAlbum>) {\n    if let Some(album_id) = &album.id {\n      match self\n        .spotify\n        .album_track(&album_id.clone(), self.large_search_limit, 0)\n        .await\n      {\n        Ok(tracks) => {\n          let track_ids = tracks\n            .items\n            .iter()\n            .filter_map(|item| item.id.clone())\n            .collect::<Vec<String>>();\n\n          let mut app = self.app.lock().await;\n          app.selected_album_simplified = Some(SelectedAlbum {\n            album: *album,\n            tracks,\n            selected_index: 0,\n          });\n\n          app.album_table_context = AlbumTableContext::Simplified;\n          app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks);\n          app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_ids));\n        }\n        Err(e) => {\n          self.handle_error(anyhow!(e)).await;\n        }\n      }\n    }\n  }\n\n  async fn get_recommendations_for_seed(\n    &mut self,\n    seed_artists: Option<Vec<String>>,\n    seed_tracks: Option<Vec<String>>,\n    first_track: Box<Option<FullTrack>>,\n    country: Option<Country>,\n  ) {\n    let empty_payload: Map<String, Value> = Map::new();\n\n    match self\n      .spotify\n      .recommendations(\n        seed_artists,            // artists\n        None,                    // genres\n        seed_tracks,             // tracks\n        self.large_search_limit, // adjust playlist to screen size\n        country,                 // country\n        &empty_payload,          // payload\n      )\n      .await\n    {\n      Ok(result) => {\n        if let Some(mut recommended_tracks) = self.extract_recommended_tracks(&result).await {\n          //custom first track\n          if let Some(track) = *first_track {\n            recommended_tracks.insert(0, track);\n          }\n\n          let track_ids = recommended_tracks\n            .iter()\n            .map(|x| x.uri.clone())\n            .collect::<Vec<String>>();\n\n          self.set_tracks_to_table(recommended_tracks.clone()).await;\n\n          let mut app = self.app.lock().await;\n          app.recommended_tracks = recommended_tracks;\n          app.track_table.context = Some(TrackTableContext::RecommendedTracks);\n\n          if app.get_current_route().id != RouteId::Recommendations {\n            app.push_navigation_stack(RouteId::Recommendations, ActiveBlock::TrackTable);\n          };\n\n          app.dispatch(IoEvent::StartPlayback(None, Some(track_ids), Some(0)));\n        }\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn extract_recommended_tracks(\n    &mut self,\n    recommendations: &Recommendations,\n  ) -> Option<Vec<FullTrack>> {\n    let tracks = recommendations\n      .clone()\n      .tracks\n      .into_iter()\n      .map(|item| item.uri)\n      .collect::<Vec<String>>();\n    if let Ok(result) = self\n      .spotify\n      .tracks(tracks.iter().map(|x| &x[..]).collect::<Vec<&str>>(), None)\n      .await\n    {\n      return Some(result.tracks);\n    }\n\n    None\n  }\n\n  async fn get_recommendations_for_track_id(&mut self, id: String, country: Option<Country>) {\n    if let Ok(track) = self.spotify.track(&id).await {\n      let track_id_list = track.id.as_ref().map(|id| vec![id.to_string()]);\n      self\n        .get_recommendations_for_seed(None, track_id_list, Box::new(Some(track)), country)\n        .await;\n    }\n  }\n\n  async fn toggle_save_track(&mut self, track_id: String) {\n    match self\n      .spotify\n      .current_user_saved_tracks_contains(&[track_id.clone()])\n      .await\n    {\n      Ok(saved) => {\n        if saved.first() == Some(&true) {\n          match self\n            .spotify\n            .current_user_saved_tracks_delete(&[track_id.clone()])\n            .await\n          {\n            Ok(()) => {\n              let mut app = self.app.lock().await;\n              app.liked_song_ids_set.remove(&track_id);\n            }\n            Err(e) => {\n              self.handle_error(anyhow!(e)).await;\n            }\n          }\n        } else {\n          match self\n            .spotify\n            .current_user_saved_tracks_add(&[track_id.clone()])\n            .await\n          {\n            Ok(()) => {\n              // TODO: This should ideally use the same logic as `self.current_user_saved_tracks_contains`\n              let mut app = self.app.lock().await;\n              app.liked_song_ids_set.insert(track_id);\n            }\n            Err(e) => {\n              self.handle_error(anyhow!(e)).await;\n            }\n          }\n        }\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    };\n  }\n\n  async fn get_followed_artists(&mut self, after: Option<String>) {\n    match self\n      .spotify\n      .current_user_followed_artists(self.large_search_limit, after)\n      .await\n    {\n      Ok(saved_artists) => {\n        let mut app = self.app.lock().await;\n        app.artists = saved_artists.artists.items.to_owned();\n        app.library.saved_artists.add_pages(saved_artists.artists);\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    };\n  }\n\n  async fn user_artist_check_follow(&mut self, artist_ids: Vec<String>) {\n    if let Ok(are_followed) = self.spotify.user_artist_check_follow(&artist_ids).await {\n      let mut app = self.app.lock().await;\n      artist_ids.iter().enumerate().for_each(|(i, id)| {\n        if are_followed[i] {\n          app.followed_artist_ids_set.insert(id.to_owned());\n        } else {\n          app.followed_artist_ids_set.remove(id);\n        }\n      });\n    }\n  }\n\n  async fn get_current_user_saved_albums(&mut self, offset: Option<u32>) {\n    match self\n      .spotify\n      .current_user_saved_albums(self.large_search_limit, offset)\n      .await\n    {\n      Ok(saved_albums) => {\n        // not to show a blank page\n        if !saved_albums.items.is_empty() {\n          let mut app = self.app.lock().await;\n          app.library.saved_albums.add_pages(saved_albums);\n        }\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    };\n  }\n\n  async fn current_user_saved_albums_contains(&mut self, album_ids: Vec<String>) {\n    if let Ok(are_followed) = self\n      .spotify\n      .current_user_saved_albums_contains(&album_ids)\n      .await\n    {\n      let mut app = self.app.lock().await;\n      album_ids.iter().enumerate().for_each(|(i, id)| {\n        if are_followed[i] {\n          app.saved_album_ids_set.insert(id.to_owned());\n        } else {\n          app.saved_album_ids_set.remove(id);\n        }\n      });\n    }\n  }\n\n  pub async fn current_user_saved_album_delete(&mut self, album_id: String) {\n    match self\n      .spotify\n      .current_user_saved_albums_delete(&[album_id.to_owned()])\n      .await\n    {\n      Ok(_) => {\n        self.get_current_user_saved_albums(None).await;\n        let mut app = self.app.lock().await;\n        app.saved_album_ids_set.remove(&album_id.to_owned());\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    };\n  }\n\n  async fn current_user_saved_album_add(&mut self, album_id: String) {\n    match self\n      .spotify\n      .current_user_saved_albums_add(&[album_id.to_owned()])\n      .await\n    {\n      Ok(_) => {\n        let mut app = self.app.lock().await;\n        app.saved_album_ids_set.insert(album_id.to_owned());\n      }\n      Err(e) => self.handle_error(anyhow!(e)).await,\n    }\n  }\n\n  async fn current_user_saved_shows_delete(&mut self, show_id: String) {\n    match self\n      .spotify\n      .remove_users_saved_shows(vec![show_id.to_owned()], None)\n      .await\n    {\n      Ok(_) => {\n        self.get_current_user_saved_shows(None).await;\n        let mut app = self.app.lock().await;\n        app.saved_show_ids_set.remove(&show_id.to_owned());\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn current_user_saved_shows_add(&mut self, show_id: String) {\n    match self.spotify.save_shows(vec![show_id.to_owned()]).await {\n      Ok(_) => {\n        self.get_current_user_saved_shows(None).await;\n        let mut app = self.app.lock().await;\n        app.saved_show_ids_set.insert(show_id.to_owned());\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn user_unfollow_artists(&mut self, artist_ids: Vec<String>) {\n    match self.spotify.user_unfollow_artists(&artist_ids).await {\n      Ok(_) => {\n        self.get_followed_artists(None).await;\n        let mut app = self.app.lock().await;\n        artist_ids.iter().for_each(|id| {\n          app.followed_artist_ids_set.remove(&id.to_owned());\n        });\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn user_follow_artists(&mut self, artist_ids: Vec<String>) {\n    match self.spotify.user_follow_artists(&artist_ids).await {\n      Ok(_) => {\n        self.get_followed_artists(None).await;\n        let mut app = self.app.lock().await;\n        artist_ids.iter().for_each(|id| {\n          app.followed_artist_ids_set.insert(id.to_owned());\n        });\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn user_follow_playlist(\n    &mut self,\n    playlist_owner_id: String,\n    playlist_id: String,\n    is_public: Option<bool>,\n  ) {\n    match self\n      .spotify\n      .user_playlist_follow_playlist(&playlist_owner_id, &playlist_id, is_public)\n      .await\n    {\n      Ok(_) => {\n        self.get_current_user_playlists().await;\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn user_unfollow_playlist(&mut self, user_id: String, playlist_id: String) {\n    match self\n      .spotify\n      .user_playlist_unfollow(&user_id, &playlist_id)\n      .await\n    {\n      Ok(_) => {\n        self.get_current_user_playlists().await;\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn made_for_you_search_and_add(&mut self, search_string: String, country: Option<Country>) {\n    const SPOTIFY_ID: &str = \"spotify\";\n\n    match self\n      .spotify\n      .search(\n        &search_string,\n        SearchType::Playlist,\n        self.large_search_limit,\n        0,\n        country,\n        None,\n      )\n      .await\n    {\n      Ok(SearchResult::Playlists(mut search_playlists)) => {\n        let mut filtered_playlists = search_playlists\n          .items\n          .iter()\n          .filter(|playlist| playlist.owner.id == SPOTIFY_ID && playlist.name == search_string)\n          .map(|playlist| playlist.to_owned())\n          .collect::<Vec<SimplifiedPlaylist>>();\n\n        let mut app = self.app.lock().await;\n        if !app.library.made_for_you_playlists.pages.is_empty() {\n          app\n            .library\n            .made_for_you_playlists\n            .get_mut_results(None)\n            .unwrap()\n            .items\n            .append(&mut filtered_playlists);\n        } else {\n          search_playlists.items = filtered_playlists;\n          app\n            .library\n            .made_for_you_playlists\n            .add_pages(search_playlists);\n        }\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n      _ => {}\n    }\n  }\n\n  async fn get_audio_analysis(&mut self, uri: String) {\n    match self.spotify.audio_analysis(&uri).await {\n      Ok(result) => {\n        let mut app = self.app.lock().await;\n        app.audio_analysis = Some(result);\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn get_current_user_playlists(&mut self) {\n    let playlists = self\n      .spotify\n      .current_user_playlists(self.large_search_limit, None)\n      .await;\n\n    match playlists {\n      Ok(p) => {\n        let mut app = self.app.lock().await;\n        app.playlists = Some(p);\n        // Select the first playlist\n        app.selected_playlist_index = Some(0);\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    };\n  }\n\n  async fn get_recently_played(&mut self) {\n    match self\n      .spotify\n      .current_user_recently_played(self.large_search_limit)\n      .await\n    {\n      Ok(result) => {\n        let track_ids = result\n          .items\n          .iter()\n          .filter_map(|item| item.track.id.clone())\n          .collect::<Vec<String>>();\n\n        self.current_user_saved_tracks_contains(track_ids).await;\n\n        let mut app = self.app.lock().await;\n\n        app.recently_played.result = Some(result.clone());\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn get_album(&mut self, album_id: String) {\n    match self.spotify.album(&album_id).await {\n      Ok(album) => {\n        let selected_album = SelectedFullAlbum {\n          album,\n          selected_index: 0,\n        };\n\n        let mut app = self.app.lock().await;\n\n        app.selected_album_full = Some(selected_album);\n        app.album_table_context = AlbumTableContext::Full;\n        app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks);\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn get_album_for_track(&mut self, track_id: String) {\n    match self.spotify.track(&track_id).await {\n      Ok(track) => {\n        // It is unclear when the id can ever be None, but perhaps a track can be album-less. If\n        // so, there isn't much to do here anyways, since we're looking for the parent album.\n        let album_id = match track.album.id {\n          Some(id) => id,\n          None => return,\n        };\n\n        if let Ok(album) = self.spotify.album(&album_id).await {\n          // The way we map to the UI is zero-indexed, but Spotify is 1-indexed.\n          let zero_indexed_track_number = track.track_number - 1;\n          let selected_album = SelectedFullAlbum {\n            album,\n            // Overflow should be essentially impossible here, so we prefer the cleaner 'as'.\n            selected_index: zero_indexed_track_number as usize,\n          };\n\n          let mut app = self.app.lock().await;\n\n          app.selected_album_full = Some(selected_album.clone());\n          app.saved_album_tracks_index = selected_album.selected_index;\n          app.album_table_context = AlbumTableContext::Full;\n          app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks);\n        }\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n\n  async fn transfert_playback_to_device(&mut self, device_id: String) {\n    match self.spotify.transfer_playback(&device_id, true).await {\n      Ok(()) => {\n        self.get_current_playback().await;\n      }\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n        return;\n      }\n    };\n\n    match self.client_config.set_device_id(device_id) {\n      Ok(()) => {\n        let mut app = self.app.lock().await;\n        app.pop_navigation_stack();\n      }\n      Err(e) => {\n        self.handle_error(e).await;\n      }\n    };\n  }\n\n  async fn refresh_authentication(&mut self) {\n    if let Some(new_token_info) = get_token(&mut self.oauth).await {\n      let (new_spotify, new_token_expiry) = get_spotify(new_token_info);\n      self.spotify = new_spotify;\n      let mut app = self.app.lock().await;\n      app.spotify_token_expiry = new_token_expiry;\n    } else {\n      println!(\"\\nFailed to refresh authentication token\");\n      // TODO panic!\n    }\n  }\n\n  async fn add_item_to_queue(&mut self, item: String) {\n    match self\n      .spotify\n      .add_item_to_queue(item, self.client_config.device_id.clone())\n      .await\n    {\n      Ok(()) => (),\n      Err(e) => {\n        self.handle_error(anyhow!(e)).await;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/redirect_uri.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <title>spotify-tui</title>\n    <link\n      href=\"https://fonts.googleapis.com/css?family=Roboto+Mono&display=swap\"\n      rel=\"stylesheet\"\n    />\n    <style type=\"text/css\" media=\"screen\">\n      * {\n        padding: 0;\n        margin: 0;\n      }\n\n      html {\n        color: #e6e6dc;\n        font-family: \"Roboto Mono\", monospace;\n      }\n\n      h1 {\n        font-size: 6rem;\n      }\n\n      .container {\n        height: 100vh;\n        background-color: #002635;\n        display: flex;\n        flex: 1;\n        justify-content: center;\n        align-items: center;\n      }\n\n      .lead {\n        margin-top: 1.6rem;\n        color: #77929e;\n      }\n    </style>\n  </head>\n  <body>\n    <div class=\"container\">\n      <div class=\"header\">\n        <h1>spotify-tui</h1>\n        <p class=\"lead\">Client authorized. You can return to your terminal and close this window.</p>\n      </div>\n    </div>\n  </body>\n</html>\n"
  },
  {
    "path": "src/redirect_uri.rs",
    "content": "use rspotify::{oauth2::SpotifyOAuth, util::request_token};\nuse std::{\n  io::prelude::*,\n  net::{TcpListener, TcpStream},\n};\n\npub fn redirect_uri_web_server(spotify_oauth: &mut SpotifyOAuth, port: u16) -> Result<String, ()> {\n  let listener = TcpListener::bind(format!(\"127.0.0.1:{}\", port));\n\n  match listener {\n    Ok(listener) => {\n      request_token(spotify_oauth);\n\n      for stream in listener.incoming() {\n        match stream {\n          Ok(stream) => {\n            if let Some(url) = handle_connection(stream) {\n              return Ok(url);\n            }\n          }\n          Err(e) => {\n            println!(\"Error: {}\", e);\n          }\n        };\n      }\n    }\n    Err(e) => {\n      println!(\"Error: {}\", e);\n    }\n  }\n\n  Err(())\n}\n\nfn handle_connection(mut stream: TcpStream) -> Option<String> {\n  // The request will be quite large (> 512) so just assign plenty just in case\n  let mut buffer = [0; 1000];\n  let _ = stream.read(&mut buffer).unwrap();\n\n  // convert buffer into string and 'parse' the URL\n  match String::from_utf8(buffer.to_vec()) {\n    Ok(request) => {\n      let split: Vec<&str> = request.split_whitespace().collect();\n\n      if split.len() > 1 {\n        respond_with_success(stream);\n        return Some(split[1].to_string());\n      }\n\n      respond_with_error(\"Malformed request\".to_string(), stream);\n    }\n    Err(e) => {\n      respond_with_error(format!(\"Invalid UTF-8 sequence: {}\", e), stream);\n    }\n  };\n\n  None\n}\n\nfn respond_with_success(mut stream: TcpStream) {\n  let contents = include_str!(\"redirect_uri.html\");\n\n  let response = format!(\"HTTP/1.1 200 OK\\r\\n\\r\\n{}\", contents);\n\n  stream.write_all(response.as_bytes()).unwrap();\n  stream.flush().unwrap();\n}\n\nfn respond_with_error(error_message: String, mut stream: TcpStream) {\n  println!(\"Error: {}\", error_message);\n  let response = format!(\n    \"HTTP/1.1 400 Bad Request\\r\\n\\r\\n400 - Bad Request - {}\",\n    error_message\n  );\n\n  stream.write_all(response.as_bytes()).unwrap();\n  stream.flush().unwrap();\n}\n"
  },
  {
    "path": "src/ui/audio_analysis.rs",
    "content": "use super::util;\nuse crate::app::App;\nuse tui::{\n  backend::Backend,\n  layout::{Constraint, Direction, Layout},\n  style::Style,\n  text::{Span, Spans},\n  widgets::{BarChart, Block, Borders, Paragraph},\n  Frame,\n};\nconst PITCHES: [&str; 12] = [\n  \"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\",\n];\n\npub fn draw<B>(f: &mut Frame<B>, app: &App)\nwhere\n  B: Backend,\n{\n  let margin = util::get_main_layout_margin(app);\n\n  let chunks = Layout::default()\n    .direction(Direction::Vertical)\n    .constraints([Constraint::Min(5), Constraint::Length(95)].as_ref())\n    .margin(margin)\n    .split(f.size());\n\n  let analysis_block = Block::default()\n    .title(Span::styled(\n      \"Analysis\",\n      Style::default().fg(app.user_config.theme.inactive),\n    ))\n    .borders(Borders::ALL)\n    .border_style(Style::default().fg(app.user_config.theme.inactive));\n\n  let white = Style::default().fg(app.user_config.theme.text);\n  let gray = Style::default().fg(app.user_config.theme.inactive);\n  let width = (chunks[1].width) as f32 / (1 + PITCHES.len()) as f32;\n  let tick_rate = app.user_config.behavior.tick_rate_milliseconds;\n  let bar_chart_title = &format!(\"Pitches | Tick Rate {} {}FPS\", tick_rate, 1000 / tick_rate);\n\n  let bar_chart_block = Block::default()\n    .borders(Borders::ALL)\n    .style(white)\n    .title(Span::styled(bar_chart_title, gray))\n    .border_style(gray);\n\n  let empty_analysis_block = || {\n    Paragraph::new(\"No analysis available\")\n      .block(analysis_block.clone())\n      .style(Style::default().fg(app.user_config.theme.text))\n  };\n  let empty_pitches_block = || {\n    Paragraph::new(\"No pitch information available\")\n      .block(bar_chart_block.clone())\n      .style(Style::default().fg(app.user_config.theme.text))\n  };\n\n  if let Some(analysis) = &app.audio_analysis {\n    let progress_seconds = (app.song_progress_ms as f32) / 1000.0;\n\n    let beat = analysis\n      .beats\n      .iter()\n      .find(|beat| beat.start >= progress_seconds);\n\n    let beat_offset = beat\n      .map(|beat| beat.start - progress_seconds)\n      .unwrap_or(0.0);\n    let segment = analysis\n      .segments\n      .iter()\n      .find(|segment| segment.start >= progress_seconds);\n    let section = analysis\n      .sections\n      .iter()\n      .find(|section| section.start >= progress_seconds);\n\n    if let (Some(segment), Some(section)) = (segment, section) {\n      let texts = vec![\n        Spans::from(format!(\n          \"Tempo: {} (confidence {:.0}%)\",\n          section.tempo,\n          section.tempo_confidence * 100.0\n        )),\n        Spans::from(format!(\n          \"Key: {} (confidence {:.0}%)\",\n          PITCHES.get(section.key as usize).unwrap_or(&PITCHES[0]),\n          section.key_confidence * 100.0\n        )),\n        Spans::from(format!(\n          \"Time Signature: {}/4 (confidence {:.0}%)\",\n          section.time_signature,\n          section.time_signature_confidence * 100.0\n        )),\n      ];\n      let p = Paragraph::new(texts)\n        .block(analysis_block)\n        .style(Style::default().fg(app.user_config.theme.text));\n      f.render_widget(p, chunks[0]);\n\n      let data: Vec<(&str, u64)> = segment\n        .clone()\n        .pitches\n        .iter()\n        .enumerate()\n        .map(|(index, pitch)| {\n          let display_pitch = *PITCHES.get(index).unwrap_or(&PITCHES[0]);\n          let bar_value = ((pitch * 1000.0) as u64)\n            // Add a beat offset to make the bar animate between beats\n            .checked_add((beat_offset * 3000.0) as u64)\n            .unwrap_or(0);\n\n          (display_pitch, bar_value)\n        })\n        .collect();\n\n      let analysis_bar = BarChart::default()\n        .block(bar_chart_block)\n        .data(&data)\n        .bar_width(width as u16)\n        .bar_style(Style::default().fg(app.user_config.theme.analysis_bar))\n        .value_style(\n          Style::default()\n            .fg(app.user_config.theme.analysis_bar_text)\n            .bg(app.user_config.theme.analysis_bar),\n        );\n      f.render_widget(analysis_bar, chunks[1]);\n    } else {\n      f.render_widget(empty_analysis_block(), chunks[0]);\n      f.render_widget(empty_pitches_block(), chunks[1]);\n    };\n  } else {\n    f.render_widget(empty_analysis_block(), chunks[0]);\n    f.render_widget(empty_pitches_block(), chunks[1]);\n  }\n}\n"
  },
  {
    "path": "src/ui/help.rs",
    "content": "use crate::user_config::KeyBindings;\n\npub fn get_help_docs(key_bindings: &KeyBindings) -> Vec<Vec<String>> {\n  vec![\n    vec![\n      String::from(\"Scroll down to next result page\"),\n      key_bindings.next_page.to_string(),\n      String::from(\"Pagination\"),\n    ],\n    vec![\n      String::from(\"Scroll up to previous result page\"),\n      key_bindings.previous_page.to_string(),\n      String::from(\"Pagination\"),\n    ],\n    vec![\n      String::from(\"Jump to start of playlist\"),\n      key_bindings.jump_to_start.to_string(),\n      String::from(\"Pagination\"),\n    ],\n    vec![\n      String::from(\"Jump to end of playlist\"),\n      key_bindings.jump_to_end.to_string(),\n      String::from(\"Pagination\"),\n    ],\n    vec![\n      String::from(\"Jump to currently playing album\"),\n      key_bindings.jump_to_album.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Jump to currently playing artist's album list\"),\n      key_bindings.jump_to_artist_album.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Jump to current play context\"),\n      key_bindings.jump_to_context.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Increase volume by 10%\"),\n      key_bindings.increase_volume.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Decrease volume by 10%\"),\n      key_bindings.decrease_volume.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Skip to next track\"),\n      key_bindings.next_track.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Skip to previous track\"),\n      key_bindings.previous_track.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Seek backwards 5 seconds\"),\n      key_bindings.seek_backwards.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Seek forwards 5 seconds\"),\n      key_bindings.seek_forwards.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Toggle shuffle\"),\n      key_bindings.shuffle.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Copy url to currently playing song/episode\"),\n      key_bindings.copy_song_url.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Copy url to currently playing album/show\"),\n      key_bindings.copy_album_url.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Cycle repeat mode\"),\n      key_bindings.repeat.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Move selection left\"),\n      String::from(\"h | <Left Arrow Key> | <Ctrl+b>\"),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Move selection down\"),\n      String::from(\"j | <Down Arrow Key> | <Ctrl+n>\"),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Move selection up\"),\n      String::from(\"k | <Up Arrow Key> | <Ctrl+p>\"),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Move selection right\"),\n      String::from(\"l | <Right Arrow Key> | <Ctrl+f>\"),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Move selection to top of list\"),\n      String::from(\"H\"),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Move selection to middle of list\"),\n      String::from(\"M\"),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Move selection to bottom of list\"),\n      String::from(\"L\"),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Enter input for search\"),\n      key_bindings.search.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Pause/Resume playback\"),\n      key_bindings.toggle_playback.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Enter active mode\"),\n      String::from(\"<Enter>\"),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Go to audio analysis screen\"),\n      key_bindings.audio_analysis.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Go to playbar only screen (basic view)\"),\n      key_bindings.basic_view.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Go back or exit when nowhere left to back to\"),\n      key_bindings.back.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Select device to play music on\"),\n      key_bindings.manage_devices.to_string(),\n      String::from(\"General\"),\n    ],\n    vec![\n      String::from(\"Enter hover mode\"),\n      String::from(\"<Esc>\"),\n      String::from(\"Selected block\"),\n    ],\n    vec![\n      String::from(\"Save track in list or table\"),\n      String::from(\"s\"),\n      String::from(\"Selected block\"),\n    ],\n    vec![\n      String::from(\"Start playback or enter album/artist/playlist\"),\n      key_bindings.submit.to_string(),\n      String::from(\"Selected block\"),\n    ],\n    vec![\n      String::from(\"Play recommendations for song/artist\"),\n      String::from(\"r\"),\n      String::from(\"Selected block\"),\n    ],\n    vec![\n      String::from(\"Play all tracks for artist\"),\n      String::from(\"e\"),\n      String::from(\"Library -> Artists\"),\n    ],\n    vec![\n      String::from(\"Search with input text\"),\n      String::from(\"<Enter>\"),\n      String::from(\"Search input\"),\n    ],\n    vec![\n      String::from(\"Move cursor one space left\"),\n      String::from(\"<Left Arrow Key>\"),\n      String::from(\"Search input\"),\n    ],\n    vec![\n      String::from(\"Move cursor one space right\"),\n      String::from(\"<Right Arrow Key>\"),\n      String::from(\"Search input\"),\n    ],\n    vec![\n      String::from(\"Delete entire input\"),\n      String::from(\"<Ctrl+l>\"),\n      String::from(\"Search input\"),\n    ],\n    vec![\n      String::from(\"Delete text from cursor to start of input\"),\n      String::from(\"<Ctrl+u>\"),\n      String::from(\"Search input\"),\n    ],\n    vec![\n      String::from(\"Delete text from cursor to end of input\"),\n      String::from(\"<Ctrl+k>\"),\n      String::from(\"Search input\"),\n    ],\n    vec![\n      String::from(\"Delete previous word\"),\n      String::from(\"<Ctrl+w>\"),\n      String::from(\"Search input\"),\n    ],\n    vec![\n      String::from(\"Jump to start of input\"),\n      String::from(\"<Ctrl+a>\"),\n      String::from(\"Search input\"),\n    ],\n    vec![\n      String::from(\"Jump to end of input\"),\n      String::from(\"<Ctrl+e>\"),\n      String::from(\"Search input\"),\n    ],\n    vec![\n      String::from(\"Escape from the input back to hovered block\"),\n      String::from(\"<Esc>\"),\n      String::from(\"Search input\"),\n    ],\n    vec![\n      String::from(\"Delete saved album\"),\n      String::from(\"D\"),\n      String::from(\"Library -> Albums\"),\n    ],\n    vec![\n      String::from(\"Delete saved playlist\"),\n      String::from(\"D\"),\n      String::from(\"Playlist\"),\n    ],\n    vec![\n      String::from(\"Follow an artist/playlist\"),\n      String::from(\"w\"),\n      String::from(\"Search result\"),\n    ],\n    vec![\n      String::from(\"Save (like) album to library\"),\n      String::from(\"w\"),\n      String::from(\"Search result\"),\n    ],\n    vec![\n      String::from(\"Play random song in playlist\"),\n      String::from(\"S\"),\n      String::from(\"Selected Playlist\"),\n    ],\n    vec![\n      String::from(\"Toggle sort order of podcast episodes\"),\n      String::from(\"S\"),\n      String::from(\"Selected Show\"),\n    ],\n    vec![\n      String::from(\"Add track to queue\"),\n      key_bindings.add_item_to_queue.to_string(),\n      String::from(\"Hovered over track\"),\n    ],\n  ]\n}\n"
  },
  {
    "path": "src/ui/mod.rs",
    "content": "pub mod audio_analysis;\npub mod help;\npub mod util;\nuse super::{\n  app::{\n    ActiveBlock, AlbumTableContext, App, ArtistBlock, EpisodeTableContext, RecommendationsContext,\n    RouteId, SearchResultBlock, LIBRARY_OPTIONS,\n  },\n  banner::BANNER,\n};\nuse help::get_help_docs;\nuse rspotify::model::show::ResumePoint;\nuse rspotify::model::PlayingItem;\nuse rspotify::senum::RepeatState;\nuse tui::{\n  backend::Backend,\n  layout::{Alignment, Constraint, Direction, Layout, Rect},\n  style::{Modifier, Style},\n  text::{Span, Spans, Text},\n  widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Row, Table, Wrap},\n  Frame,\n};\nuse util::{\n  create_artist_string, display_track_progress, get_artist_highlight_state, get_color,\n  get_percentage_width, get_search_results_highlight_state, get_track_progress_percentage,\n  millis_to_minutes, BASIC_VIEW_HEIGHT, SMALL_TERMINAL_WIDTH,\n};\n\npub enum TableId {\n  Album,\n  AlbumList,\n  Artist,\n  Podcast,\n  Song,\n  RecentlyPlayed,\n  MadeForYou,\n  PodcastEpisodes,\n}\n\n#[derive(PartialEq)]\npub enum ColumnId {\n  None,\n  Title,\n  Liked,\n}\n\nimpl Default for ColumnId {\n  fn default() -> Self {\n    ColumnId::None\n  }\n}\n\npub struct TableHeader<'a> {\n  id: TableId,\n  items: Vec<TableHeaderItem<'a>>,\n}\n\nimpl TableHeader<'_> {\n  pub fn get_index(&self, id: ColumnId) -> Option<usize> {\n    self.items.iter().position(|item| item.id == id)\n  }\n}\n\n#[derive(Default)]\npub struct TableHeaderItem<'a> {\n  id: ColumnId,\n  text: &'a str,\n  width: u16,\n}\n\npub struct TableItem {\n  id: String,\n  format: Vec<String>,\n}\n\npub fn draw_help_menu<B>(f: &mut Frame<B>, app: &App)\nwhere\n  B: Backend,\n{\n  let chunks = Layout::default()\n    .direction(Direction::Vertical)\n    .constraints([Constraint::Percentage(100)].as_ref())\n    .margin(2)\n    .split(f.size());\n\n  // Create a one-column table to avoid flickering due to non-determinism when\n  // resolving constraints on widths of table columns.\n  let format_row =\n    |r: Vec<String>| -> Vec<String> { vec![format!(\"{:50}{:40}{:20}\", r[0], r[1], r[2])] };\n\n  let help_menu_style = Style::default().fg(app.user_config.theme.text);\n  let header = [\"Description\", \"Event\", \"Context\"];\n  let header = format_row(header.iter().map(|s| s.to_string()).collect());\n\n  let help_docs = get_help_docs(&app.user_config.keys);\n  let help_docs = help_docs\n    .into_iter()\n    .map(format_row)\n    .collect::<Vec<Vec<String>>>();\n  let help_docs = &help_docs[app.help_menu_offset as usize..];\n\n  let rows = help_docs\n    .iter()\n    .map(|item| Row::new(item.clone()).style(help_menu_style));\n\n  let help_menu = Table::new(rows)\n    .header(Row::new(header))\n    .block(\n      Block::default()\n        .borders(Borders::ALL)\n        .style(help_menu_style)\n        .title(Span::styled(\n          \"Help (press <Esc> to go back)\",\n          help_menu_style,\n        ))\n        .border_style(help_menu_style),\n    )\n    .style(help_menu_style)\n    .widths(&[Constraint::Percentage(100)]);\n  f.render_widget(help_menu, chunks[0]);\n}\n\npub fn draw_input_and_help_box<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  // Check for the width and change the contraints accordingly\n  let chunks = Layout::default()\n    .direction(Direction::Horizontal)\n    .constraints(\n      if app.size.width >= SMALL_TERMINAL_WIDTH && !app.user_config.behavior.enforce_wide_search_bar\n      {\n        [Constraint::Percentage(65), Constraint::Percentage(35)].as_ref()\n      } else {\n        [Constraint::Percentage(90), Constraint::Percentage(10)].as_ref()\n      },\n    )\n    .split(layout_chunk);\n\n  let current_route = app.get_current_route();\n\n  let highlight_state = (\n    current_route.active_block == ActiveBlock::Input,\n    current_route.hovered_block == ActiveBlock::Input,\n  );\n\n  let input_string: String = app.input.iter().collect();\n  let lines = Text::from((&input_string).as_str());\n  let input = Paragraph::new(lines).block(\n    Block::default()\n      .borders(Borders::ALL)\n      .title(Span::styled(\n        \"Search\",\n        get_color(highlight_state, app.user_config.theme),\n      ))\n      .border_style(get_color(highlight_state, app.user_config.theme)),\n  );\n  f.render_widget(input, chunks[0]);\n\n  let show_loading = app.is_loading && app.user_config.behavior.show_loading_indicator;\n  let help_block_text = if show_loading {\n    (app.user_config.theme.hint, \"Loading...\")\n  } else {\n    (app.user_config.theme.inactive, \"Type ?\")\n  };\n\n  let block = Block::default()\n    .title(Span::styled(\"Help\", Style::default().fg(help_block_text.0)))\n    .borders(Borders::ALL)\n    .border_style(Style::default().fg(help_block_text.0));\n\n  let lines = Text::from(help_block_text.1);\n  let help = Paragraph::new(lines)\n    .block(block)\n    .style(Style::default().fg(help_block_text.0));\n  f.render_widget(help, chunks[1]);\n}\n\npub fn draw_main_layout<B>(f: &mut Frame<B>, app: &App)\nwhere\n  B: Backend,\n{\n  let margin = util::get_main_layout_margin(app);\n  // Responsive layout: new one kicks in at width 150 or higher\n  if app.size.width >= SMALL_TERMINAL_WIDTH && !app.user_config.behavior.enforce_wide_search_bar {\n    let parent_layout = Layout::default()\n      .direction(Direction::Vertical)\n      .constraints([Constraint::Min(1), Constraint::Length(6)].as_ref())\n      .margin(margin)\n      .split(f.size());\n\n    // Nested main block with potential routes\n    draw_routes(f, app, parent_layout[0]);\n\n    // Currently playing\n    draw_playbar(f, app, parent_layout[1]);\n  } else {\n    let parent_layout = Layout::default()\n      .direction(Direction::Vertical)\n      .constraints(\n        [\n          Constraint::Length(3),\n          Constraint::Min(1),\n          Constraint::Length(6),\n        ]\n        .as_ref(),\n      )\n      .margin(margin)\n      .split(f.size());\n\n    // Search input and help\n    draw_input_and_help_box(f, app, parent_layout[0]);\n\n    // Nested main block with potential routes\n    draw_routes(f, app, parent_layout[1]);\n\n    // Currently playing\n    draw_playbar(f, app, parent_layout[2]);\n  }\n\n  // Possibly draw confirm dialog\n  draw_dialog(f, app);\n}\n\npub fn draw_routes<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let chunks = Layout::default()\n    .direction(Direction::Horizontal)\n    .constraints([Constraint::Percentage(20), Constraint::Percentage(80)].as_ref())\n    .split(layout_chunk);\n\n  draw_user_block(f, app, chunks[0]);\n\n  let current_route = app.get_current_route();\n\n  match current_route.id {\n    RouteId::Search => {\n      draw_search_results(f, app, chunks[1]);\n    }\n    RouteId::TrackTable => {\n      draw_song_table(f, app, chunks[1]);\n    }\n    RouteId::AlbumTracks => {\n      draw_album_table(f, app, chunks[1]);\n    }\n    RouteId::RecentlyPlayed => {\n      draw_recently_played_table(f, app, chunks[1]);\n    }\n    RouteId::Artist => {\n      draw_artist_albums(f, app, chunks[1]);\n    }\n    RouteId::AlbumList => {\n      draw_album_list(f, app, chunks[1]);\n    }\n    RouteId::PodcastEpisodes => {\n      draw_show_episodes(f, app, chunks[1]);\n    }\n    RouteId::Home => {\n      draw_home(f, app, chunks[1]);\n    }\n    RouteId::MadeForYou => {\n      draw_made_for_you(f, app, chunks[1]);\n    }\n    RouteId::Artists => {\n      draw_artist_table(f, app, chunks[1]);\n    }\n    RouteId::Podcasts => {\n      draw_podcast_table(f, app, chunks[1]);\n    }\n    RouteId::Recommendations => {\n      draw_recommendations_table(f, app, chunks[1]);\n    }\n    RouteId::Error => {} // This is handled as a \"full screen\" route in main.rs\n    RouteId::SelectedDevice => {} // This is handled as a \"full screen\" route in main.rs\n    RouteId::Analysis => {} // This is handled as a \"full screen\" route in main.rs\n    RouteId::BasicView => {} // This is handled as a \"full screen\" route in main.rs\n    RouteId::Dialog => {} // This is handled in the draw_dialog function in mod.rs\n  };\n}\n\npub fn draw_library_block<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let current_route = app.get_current_route();\n  let highlight_state = (\n    current_route.active_block == ActiveBlock::Library,\n    current_route.hovered_block == ActiveBlock::Library,\n  );\n  draw_selectable_list(\n    f,\n    app,\n    layout_chunk,\n    \"Library\",\n    &LIBRARY_OPTIONS,\n    highlight_state,\n    Some(app.library.selected_index),\n  );\n}\n\npub fn draw_playlist_block<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let playlist_items = match &app.playlists {\n    Some(p) => p.items.iter().map(|item| item.name.to_owned()).collect(),\n    None => vec![],\n  };\n\n  let current_route = app.get_current_route();\n\n  let highlight_state = (\n    current_route.active_block == ActiveBlock::MyPlaylists,\n    current_route.hovered_block == ActiveBlock::MyPlaylists,\n  );\n\n  draw_selectable_list(\n    f,\n    app,\n    layout_chunk,\n    \"Playlists\",\n    &playlist_items,\n    highlight_state,\n    app.selected_playlist_index,\n  );\n}\n\npub fn draw_user_block<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  // Check for width to make a responsive layout\n  if app.size.width >= SMALL_TERMINAL_WIDTH && !app.user_config.behavior.enforce_wide_search_bar {\n    let chunks = Layout::default()\n      .direction(Direction::Vertical)\n      .constraints(\n        [\n          Constraint::Length(3),\n          Constraint::Percentage(30),\n          Constraint::Percentage(70),\n        ]\n        .as_ref(),\n      )\n      .split(layout_chunk);\n\n    // Search input and help\n    draw_input_and_help_box(f, app, chunks[0]);\n    draw_library_block(f, app, chunks[1]);\n    draw_playlist_block(f, app, chunks[2]);\n  } else {\n    let chunks = Layout::default()\n      .direction(Direction::Vertical)\n      .constraints([Constraint::Percentage(30), Constraint::Percentage(70)].as_ref())\n      .split(layout_chunk);\n\n    // Search input and help\n    draw_library_block(f, app, chunks[0]);\n    draw_playlist_block(f, app, chunks[1]);\n  }\n}\n\npub fn draw_search_results<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let chunks = Layout::default()\n    .direction(Direction::Vertical)\n    .constraints(\n      [\n        Constraint::Percentage(35),\n        Constraint::Percentage(35),\n        Constraint::Percentage(25),\n      ]\n      .as_ref(),\n    )\n    .split(layout_chunk);\n\n  {\n    let song_artist_block = Layout::default()\n      .direction(Direction::Horizontal)\n      .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())\n      .split(chunks[0]);\n\n    let currently_playing_id = app\n      .current_playback_context\n      .clone()\n      .and_then(|context| {\n        context.item.and_then(|item| match item {\n          PlayingItem::Track(track) => track.id,\n          PlayingItem::Episode(episode) => Some(episode.id),\n        })\n      })\n      .unwrap_or_else(|| \"\".to_string());\n\n    let songs = match &app.search_results.tracks {\n      Some(tracks) => tracks\n        .items\n        .iter()\n        .map(|item| {\n          let mut song_name = \"\".to_string();\n          let id = item.clone().id.unwrap_or_else(|| \"\".to_string());\n          if currently_playing_id == id {\n            song_name += \"▶ \"\n          }\n          if app.liked_song_ids_set.contains(&id) {\n            song_name += &app.user_config.padded_liked_icon();\n          }\n\n          song_name += &item.name;\n          song_name += &format!(\" - {}\", &create_artist_string(&item.artists));\n          song_name\n        })\n        .collect(),\n      None => vec![],\n    };\n\n    draw_selectable_list(\n      f,\n      app,\n      song_artist_block[0],\n      \"Songs\",\n      &songs,\n      get_search_results_highlight_state(app, SearchResultBlock::SongSearch),\n      app.search_results.selected_tracks_index,\n    );\n\n    let artists = match &app.search_results.artists {\n      Some(artists) => artists\n        .items\n        .iter()\n        .map(|item| {\n          let mut artist = String::new();\n          if app.followed_artist_ids_set.contains(&item.id.to_owned()) {\n            artist.push_str(&app.user_config.padded_liked_icon());\n          }\n          artist.push_str(&item.name.to_owned());\n          artist\n        })\n        .collect(),\n      None => vec![],\n    };\n\n    draw_selectable_list(\n      f,\n      app,\n      song_artist_block[1],\n      \"Artists\",\n      &artists,\n      get_search_results_highlight_state(app, SearchResultBlock::ArtistSearch),\n      app.search_results.selected_artists_index,\n    );\n  }\n\n  {\n    let albums_playlist_block = Layout::default()\n      .direction(Direction::Horizontal)\n      .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())\n      .split(chunks[1]);\n\n    let albums = match &app.search_results.albums {\n      Some(albums) => albums\n        .items\n        .iter()\n        .map(|item| {\n          let mut album_artist = String::new();\n          if let Some(album_id) = &item.id {\n            if app.saved_album_ids_set.contains(&album_id.to_owned()) {\n              album_artist.push_str(&app.user_config.padded_liked_icon());\n            }\n          }\n          album_artist.push_str(&format!(\n            \"{} - {} ({})\",\n            item.name.to_owned(),\n            create_artist_string(&item.artists),\n            item.album_type.as_deref().unwrap_or(\"unknown\")\n          ));\n          album_artist\n        })\n        .collect(),\n      None => vec![],\n    };\n\n    draw_selectable_list(\n      f,\n      app,\n      albums_playlist_block[0],\n      \"Albums\",\n      &albums,\n      get_search_results_highlight_state(app, SearchResultBlock::AlbumSearch),\n      app.search_results.selected_album_index,\n    );\n\n    let playlists = match &app.search_results.playlists {\n      Some(playlists) => playlists\n        .items\n        .iter()\n        .map(|item| item.name.to_owned())\n        .collect(),\n      None => vec![],\n    };\n    draw_selectable_list(\n      f,\n      app,\n      albums_playlist_block[1],\n      \"Playlists\",\n      &playlists,\n      get_search_results_highlight_state(app, SearchResultBlock::PlaylistSearch),\n      app.search_results.selected_playlists_index,\n    );\n  }\n\n  {\n    let podcasts_block = Layout::default()\n      .direction(Direction::Horizontal)\n      .constraints([Constraint::Percentage(100)].as_ref())\n      .split(chunks[2]);\n\n    let podcasts = match &app.search_results.shows {\n      Some(podcasts) => podcasts\n        .items\n        .iter()\n        .map(|item| {\n          let mut show_name = String::new();\n          if app.saved_show_ids_set.contains(&item.id.to_owned()) {\n            show_name.push_str(&app.user_config.padded_liked_icon());\n          }\n          show_name.push_str(&format!(\"{:} - {}\", item.name, item.publisher));\n          show_name\n        })\n        .collect(),\n      None => vec![],\n    };\n    draw_selectable_list(\n      f,\n      app,\n      podcasts_block[0],\n      \"Podcasts\",\n      &podcasts,\n      get_search_results_highlight_state(app, SearchResultBlock::ShowSearch),\n      app.search_results.selected_shows_index,\n    );\n  }\n}\n\nstruct AlbumUi {\n  selected_index: usize,\n  items: Vec<TableItem>,\n  title: String,\n}\n\npub fn draw_artist_table<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let header = TableHeader {\n    id: TableId::Artist,\n    items: vec![TableHeaderItem {\n      text: \"Artist\",\n      width: get_percentage_width(layout_chunk.width, 1.0),\n      ..Default::default()\n    }],\n  };\n\n  let current_route = app.get_current_route();\n  let highlight_state = (\n    current_route.active_block == ActiveBlock::Artists,\n    current_route.hovered_block == ActiveBlock::Artists,\n  );\n  let items = app\n    .artists\n    .iter()\n    .map(|item| TableItem {\n      id: item.id.clone(),\n      format: vec![item.name.to_owned()],\n    })\n    .collect::<Vec<TableItem>>();\n\n  draw_table(\n    f,\n    app,\n    layout_chunk,\n    (\"Artists\", &header),\n    &items,\n    app.artists_list_index,\n    highlight_state,\n  )\n}\n\npub fn draw_podcast_table<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let header = TableHeader {\n    id: TableId::Podcast,\n    items: vec![\n      TableHeaderItem {\n        text: \"Name\",\n        width: get_percentage_width(layout_chunk.width, 2.0 / 5.0),\n        ..Default::default()\n      },\n      TableHeaderItem {\n        text: \"Publisher(s)\",\n        width: get_percentage_width(layout_chunk.width, 2.0 / 5.0),\n        ..Default::default()\n      },\n    ],\n  };\n\n  let current_route = app.get_current_route();\n\n  let highlight_state = (\n    current_route.active_block == ActiveBlock::Podcasts,\n    current_route.hovered_block == ActiveBlock::Podcasts,\n  );\n\n  if let Some(saved_shows) = app.library.saved_shows.get_results(None) {\n    let items = saved_shows\n      .items\n      .iter()\n      .map(|show_page| TableItem {\n        id: show_page.show.id.to_owned(),\n        format: vec![\n          show_page.show.name.to_owned(),\n          show_page.show.publisher.to_owned(),\n        ],\n      })\n      .collect::<Vec<TableItem>>();\n\n    draw_table(\n      f,\n      app,\n      layout_chunk,\n      (\"Podcasts\", &header),\n      &items,\n      app.shows_list_index,\n      highlight_state,\n    )\n  };\n}\n\npub fn draw_album_table<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let header = TableHeader {\n    id: TableId::Album,\n    items: vec![\n      TableHeaderItem {\n        id: ColumnId::Liked,\n        text: \"\",\n        width: 2,\n      },\n      TableHeaderItem {\n        text: \"#\",\n        width: 3,\n        ..Default::default()\n      },\n      TableHeaderItem {\n        id: ColumnId::Title,\n        text: \"Title\",\n        width: get_percentage_width(layout_chunk.width, 2.0 / 5.0) - 5,\n      },\n      TableHeaderItem {\n        text: \"Artist\",\n        width: get_percentage_width(layout_chunk.width, 2.0 / 5.0),\n        ..Default::default()\n      },\n      TableHeaderItem {\n        text: \"Length\",\n        width: get_percentage_width(layout_chunk.width, 1.0 / 5.0),\n        ..Default::default()\n      },\n    ],\n  };\n\n  let current_route = app.get_current_route();\n  let highlight_state = (\n    current_route.active_block == ActiveBlock::AlbumTracks,\n    current_route.hovered_block == ActiveBlock::AlbumTracks,\n  );\n\n  let album_ui = match &app.album_table_context {\n    AlbumTableContext::Simplified => {\n      app\n        .selected_album_simplified\n        .as_ref()\n        .map(|selected_album_simplified| AlbumUi {\n          items: selected_album_simplified\n            .tracks\n            .items\n            .iter()\n            .map(|item| TableItem {\n              id: item.id.clone().unwrap_or_else(|| \"\".to_string()),\n              format: vec![\n                \"\".to_string(),\n                item.track_number.to_string(),\n                item.name.to_owned(),\n                create_artist_string(&item.artists),\n                millis_to_minutes(u128::from(item.duration_ms)),\n              ],\n            })\n            .collect::<Vec<TableItem>>(),\n          title: format!(\n            \"{} by {}\",\n            selected_album_simplified.album.name,\n            create_artist_string(&selected_album_simplified.album.artists)\n          ),\n          selected_index: selected_album_simplified.selected_index,\n        })\n    }\n    AlbumTableContext::Full => match app.selected_album_full.clone() {\n      Some(selected_album) => Some(AlbumUi {\n        items: selected_album\n          .album\n          .tracks\n          .items\n          .iter()\n          .map(|item| TableItem {\n            id: item.id.clone().unwrap_or_else(|| \"\".to_string()),\n            format: vec![\n              \"\".to_string(),\n              item.track_number.to_string(),\n              item.name.to_owned(),\n              create_artist_string(&item.artists),\n              millis_to_minutes(u128::from(item.duration_ms)),\n            ],\n          })\n          .collect::<Vec<TableItem>>(),\n        title: format!(\n          \"{} by {}\",\n          selected_album.album.name,\n          create_artist_string(&selected_album.album.artists)\n        ),\n        selected_index: app.saved_album_tracks_index,\n      }),\n      None => None,\n    },\n  };\n\n  if let Some(album_ui) = album_ui {\n    draw_table(\n      f,\n      app,\n      layout_chunk,\n      (&album_ui.title, &header),\n      &album_ui.items,\n      album_ui.selected_index,\n      highlight_state,\n    );\n  };\n}\n\npub fn draw_recommendations_table<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let header = TableHeader {\n    id: TableId::Song,\n    items: vec![\n      TableHeaderItem {\n        id: ColumnId::Liked,\n        text: \"\",\n        width: 2,\n      },\n      TableHeaderItem {\n        id: ColumnId::Title,\n        text: \"Title\",\n        width: get_percentage_width(layout_chunk.width, 0.3),\n      },\n      TableHeaderItem {\n        text: \"Artist\",\n        width: get_percentage_width(layout_chunk.width, 0.3),\n        ..Default::default()\n      },\n      TableHeaderItem {\n        text: \"Album\",\n        width: get_percentage_width(layout_chunk.width, 0.3),\n        ..Default::default()\n      },\n      TableHeaderItem {\n        text: \"Length\",\n        width: get_percentage_width(layout_chunk.width, 0.1),\n        ..Default::default()\n      },\n    ],\n  };\n\n  let current_route = app.get_current_route();\n  let highlight_state = (\n    current_route.active_block == ActiveBlock::TrackTable,\n    current_route.hovered_block == ActiveBlock::TrackTable,\n  );\n\n  let items = app\n    .track_table\n    .tracks\n    .iter()\n    .map(|item| TableItem {\n      id: item.id.clone().unwrap_or_else(|| \"\".to_string()),\n      format: vec![\n        \"\".to_string(),\n        item.name.to_owned(),\n        create_artist_string(&item.artists),\n        item.album.name.to_owned(),\n        millis_to_minutes(u128::from(item.duration_ms)),\n      ],\n    })\n    .collect::<Vec<TableItem>>();\n  // match RecommendedContext\n  let recommendations_ui = match &app.recommendations_context {\n    Some(RecommendationsContext::Song) => format!(\n      \"Recommendations based on Song \\'{}\\'\",\n      &app.recommendations_seed\n    ),\n    Some(RecommendationsContext::Artist) => format!(\n      \"Recommendations based on Artist \\'{}\\'\",\n      &app.recommendations_seed\n    ),\n    None => \"Recommendations\".to_string(),\n  };\n  draw_table(\n    f,\n    app,\n    layout_chunk,\n    (&recommendations_ui[..], &header),\n    &items,\n    app.track_table.selected_index,\n    highlight_state,\n  )\n}\n\npub fn draw_song_table<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let header = TableHeader {\n    id: TableId::Song,\n    items: vec![\n      TableHeaderItem {\n        id: ColumnId::Liked,\n        text: \"\",\n        width: 2,\n      },\n      TableHeaderItem {\n        id: ColumnId::Title,\n        text: \"Title\",\n        width: get_percentage_width(layout_chunk.width, 0.3),\n      },\n      TableHeaderItem {\n        text: \"Artist\",\n        width: get_percentage_width(layout_chunk.width, 0.3),\n        ..Default::default()\n      },\n      TableHeaderItem {\n        text: \"Album\",\n        width: get_percentage_width(layout_chunk.width, 0.3),\n        ..Default::default()\n      },\n      TableHeaderItem {\n        text: \"Length\",\n        width: get_percentage_width(layout_chunk.width, 0.1),\n        ..Default::default()\n      },\n    ],\n  };\n\n  let current_route = app.get_current_route();\n  let highlight_state = (\n    current_route.active_block == ActiveBlock::TrackTable,\n    current_route.hovered_block == ActiveBlock::TrackTable,\n  );\n\n  let items = app\n    .track_table\n    .tracks\n    .iter()\n    .map(|item| TableItem {\n      id: item.id.clone().unwrap_or_else(|| \"\".to_string()),\n      format: vec![\n        \"\".to_string(),\n        item.name.to_owned(),\n        create_artist_string(&item.artists),\n        item.album.name.to_owned(),\n        millis_to_minutes(u128::from(item.duration_ms)),\n      ],\n    })\n    .collect::<Vec<TableItem>>();\n\n  draw_table(\n    f,\n    app,\n    layout_chunk,\n    (\"Songs\", &header),\n    &items,\n    app.track_table.selected_index,\n    highlight_state,\n  )\n}\n\npub fn draw_basic_view<B>(f: &mut Frame<B>, app: &App)\nwhere\n  B: Backend,\n{\n  // If space is negative, do nothing because the widget would not fit\n  if let Some(s) = app.size.height.checked_sub(BASIC_VIEW_HEIGHT) {\n    let space = s / 2;\n    let chunks = Layout::default()\n      .direction(Direction::Vertical)\n      .constraints(\n        [\n          Constraint::Length(space),\n          Constraint::Length(BASIC_VIEW_HEIGHT),\n          Constraint::Length(space),\n        ]\n        .as_ref(),\n      )\n      .split(f.size());\n\n    draw_playbar(f, app, chunks[1]);\n  }\n}\n\npub fn draw_playbar<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let chunks = Layout::default()\n    .direction(Direction::Vertical)\n    .constraints(\n      [\n        Constraint::Percentage(50),\n        Constraint::Percentage(25),\n        Constraint::Percentage(25),\n      ]\n      .as_ref(),\n    )\n    .margin(1)\n    .split(layout_chunk);\n\n  // If no track is playing, render paragraph showing which device is selected, if no selected\n  // give hint to choose a device\n  if let Some(current_playback_context) = &app.current_playback_context {\n    if let Some(track_item) = &current_playback_context.item {\n      let play_title = if current_playback_context.is_playing {\n        \"Playing\"\n      } else {\n        \"Paused\"\n      };\n\n      let shuffle_text = if current_playback_context.shuffle_state {\n        \"On\"\n      } else {\n        \"Off\"\n      };\n\n      let repeat_text = match current_playback_context.repeat_state {\n        RepeatState::Off => \"Off\",\n        RepeatState::Track => \"Track\",\n        RepeatState::Context => \"All\",\n      };\n\n      let title = format!(\n        \"{:-7} ({} | Shuffle: {:-3} | Repeat: {:-5} | Volume: {:-2}%)\",\n        play_title,\n        current_playback_context.device.name,\n        shuffle_text,\n        repeat_text,\n        current_playback_context.device.volume_percent\n      );\n\n      let current_route = app.get_current_route();\n      let highlight_state = (\n        current_route.active_block == ActiveBlock::PlayBar,\n        current_route.hovered_block == ActiveBlock::PlayBar,\n      );\n\n      let title_block = Block::default()\n        .borders(Borders::ALL)\n        .title(Span::styled(\n          &title,\n          get_color(highlight_state, app.user_config.theme),\n        ))\n        .border_style(get_color(highlight_state, app.user_config.theme));\n\n      f.render_widget(title_block, layout_chunk);\n\n      let (item_id, name, duration_ms) = match track_item {\n        PlayingItem::Track(track) => (\n          track.id.to_owned().unwrap_or_else(|| \"\".to_string()),\n          track.name.to_owned(),\n          track.duration_ms,\n        ),\n        PlayingItem::Episode(episode) => (\n          episode.id.to_owned(),\n          episode.name.to_owned(),\n          episode.duration_ms,\n        ),\n      };\n\n      let track_name = if app.liked_song_ids_set.contains(&item_id) {\n        format!(\"{}{}\", &app.user_config.padded_liked_icon(), name)\n      } else {\n        name\n      };\n\n      let play_bar_text = match track_item {\n        PlayingItem::Track(track) => create_artist_string(&track.artists),\n        PlayingItem::Episode(episode) => format!(\"{} - {}\", episode.name, episode.show.name),\n      };\n\n      let lines = Text::from(Span::styled(\n        play_bar_text,\n        Style::default().fg(app.user_config.theme.playbar_text),\n      ));\n\n      let artist = Paragraph::new(lines)\n        .style(Style::default().fg(app.user_config.theme.playbar_text))\n        .block(\n          Block::default().title(Span::styled(\n            &track_name,\n            Style::default()\n              .fg(app.user_config.theme.selected)\n              .add_modifier(Modifier::BOLD),\n          )),\n        );\n      f.render_widget(artist, chunks[0]);\n\n      let progress_ms = match app.seek_ms {\n        Some(seek_ms) => seek_ms,\n        None => app.song_progress_ms,\n      };\n\n      let perc = get_track_progress_percentage(progress_ms, duration_ms);\n\n      let song_progress_label = display_track_progress(progress_ms, duration_ms);\n      let modifier = if app.user_config.behavior.enable_text_emphasis {\n        Modifier::ITALIC | Modifier::BOLD\n      } else {\n        Modifier::empty()\n      };\n      let song_progress = Gauge::default()\n        .gauge_style(\n          Style::default()\n            .fg(app.user_config.theme.playbar_progress)\n            .bg(app.user_config.theme.playbar_background)\n            .add_modifier(modifier),\n        )\n        .percent(perc)\n        .label(Span::styled(\n          &song_progress_label,\n          Style::default().fg(app.user_config.theme.playbar_progress_text),\n        ));\n      f.render_widget(song_progress, chunks[2]);\n    }\n  }\n}\n\npub fn draw_error_screen<B>(f: &mut Frame<B>, app: &App)\nwhere\n  B: Backend,\n{\n  let chunks = Layout::default()\n    .direction(Direction::Vertical)\n    .constraints([Constraint::Percentage(100)].as_ref())\n    .margin(5)\n    .split(f.size());\n\n  let playing_text = vec![\n    Spans::from(vec![\n      Span::raw(\"Api response: \"),\n      Span::styled(\n        &app.api_error,\n        Style::default().fg(app.user_config.theme.error_text),\n      ),\n    ]),\n    Spans::from(Span::styled(\n      \"If you are trying to play a track, please check that\",\n      Style::default().fg(app.user_config.theme.text),\n    )),\n    Spans::from(Span::styled(\n      \" 1. You have a Spotify Premium Account\",\n      Style::default().fg(app.user_config.theme.text),\n    )),\n    Spans::from(Span::styled(\n      \" 2. Your playback device is active and selected - press `d` to go to device selection menu\",\n      Style::default().fg(app.user_config.theme.text),\n    )),\n    Spans::from(Span::styled(\n      \" 3. If you're using spotifyd as a playback device, your device name must not contain spaces\",\n      Style::default().fg(app.user_config.theme.text),\n    )),\n    Spans::from(Span::styled(\"Hint: a playback device must be either an official spotify client or a light weight alternative such as spotifyd\",\n        Style::default().fg(app.user_config.theme.hint)\n        ),\n    ),\n    Spans::from(\n      Span::styled(\n          \"\\nPress <Esc> to return\",\n          Style::default().fg(app.user_config.theme.inactive),\n      ),\n    )\n  ];\n\n  let playing_paragraph = Paragraph::new(playing_text)\n    .wrap(Wrap { trim: true })\n    .style(Style::default().fg(app.user_config.theme.text))\n    .block(\n      Block::default()\n        .borders(Borders::ALL)\n        .title(Span::styled(\n          \"Error\",\n          Style::default().fg(app.user_config.theme.error_border),\n        ))\n        .border_style(Style::default().fg(app.user_config.theme.error_border)),\n    );\n  f.render_widget(playing_paragraph, chunks[0]);\n}\n\nfn draw_home<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let chunks = Layout::default()\n    .direction(Direction::Vertical)\n    .constraints([Constraint::Length(7), Constraint::Length(93)].as_ref())\n    .margin(2)\n    .split(layout_chunk);\n\n  let current_route = app.get_current_route();\n  let highlight_state = (\n    current_route.active_block == ActiveBlock::Home,\n    current_route.hovered_block == ActiveBlock::Home,\n  );\n\n  let welcome = Block::default()\n    .title(Span::styled(\n      \"Welcome!\",\n      get_color(highlight_state, app.user_config.theme),\n    ))\n    .borders(Borders::ALL)\n    .border_style(get_color(highlight_state, app.user_config.theme));\n  f.render_widget(welcome, layout_chunk);\n\n  let changelog = include_str!(\"../../CHANGELOG.md\").to_string();\n\n  // If debug mode show the \"Unreleased\" header. Otherwise it is a release so there should be no\n  // unreleased features\n  let clean_changelog = if cfg!(debug_assertions) {\n    changelog\n  } else {\n    changelog.replace(\"\\n## [Unreleased]\\n\", \"\")\n  };\n\n  // Banner text with correct styling\n  let mut top_text = Text::from(BANNER);\n  top_text.patch_style(Style::default().fg(app.user_config.theme.banner));\n\n  let bottom_text_raw = format!(\n    \"{}{}\",\n    \"\\nPlease report any bugs or missing features to https://github.com/Rigellute/spotify-tui\\n\\n\",\n    clean_changelog\n  );\n  let bottom_text = Text::from(bottom_text_raw.as_str());\n\n  // Contains the banner\n  let top_text = Paragraph::new(top_text)\n    .style(Style::default().fg(app.user_config.theme.text))\n    .block(Block::default());\n  f.render_widget(top_text, chunks[0]);\n\n  // CHANGELOG\n  let bottom_text = Paragraph::new(bottom_text)\n    .style(Style::default().fg(app.user_config.theme.text))\n    .block(Block::default())\n    .wrap(Wrap { trim: false })\n    .scroll((app.home_scroll, 0));\n  f.render_widget(bottom_text, chunks[1]);\n}\n\nfn draw_artist_albums<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let chunks = Layout::default()\n    .direction(Direction::Horizontal)\n    .constraints(\n      [\n        Constraint::Percentage(33),\n        Constraint::Percentage(33),\n        Constraint::Percentage(33),\n      ]\n      .as_ref(),\n    )\n    .split(layout_chunk);\n\n  if let Some(artist) = &app.artist {\n    let top_tracks = artist\n      .top_tracks\n      .iter()\n      .map(|top_track| {\n        let mut name = String::new();\n        if let Some(context) = &app.current_playback_context {\n          let track_id = match &context.item {\n            Some(PlayingItem::Track(track)) => track.id.to_owned(),\n            Some(PlayingItem::Episode(episode)) => Some(episode.id.to_owned()),\n            _ => None,\n          };\n\n          if track_id == top_track.id {\n            name.push_str(\"▶ \");\n          }\n        };\n        name.push_str(&top_track.name);\n        name\n      })\n      .collect::<Vec<String>>();\n\n    draw_selectable_list(\n      f,\n      app,\n      chunks[0],\n      &format!(\"{} - Top Tracks\", &artist.artist_name),\n      &top_tracks,\n      get_artist_highlight_state(app, ArtistBlock::TopTracks),\n      Some(artist.selected_top_track_index),\n    );\n\n    let albums = &artist\n      .albums\n      .items\n      .iter()\n      .map(|item| {\n        let mut album_artist = String::new();\n        if let Some(album_id) = &item.id {\n          if app.saved_album_ids_set.contains(&album_id.to_owned()) {\n            album_artist.push_str(&app.user_config.padded_liked_icon());\n          }\n        }\n        album_artist.push_str(&format!(\n          \"{} - {} ({})\",\n          item.name.to_owned(),\n          create_artist_string(&item.artists),\n          item.album_type.as_deref().unwrap_or(\"unknown\")\n        ));\n        album_artist\n      })\n      .collect::<Vec<String>>();\n\n    draw_selectable_list(\n      f,\n      app,\n      chunks[1],\n      \"Albums\",\n      albums,\n      get_artist_highlight_state(app, ArtistBlock::Albums),\n      Some(artist.selected_album_index),\n    );\n\n    let related_artists = artist\n      .related_artists\n      .iter()\n      .map(|item| {\n        let mut artist = String::new();\n        if app.followed_artist_ids_set.contains(&item.id.to_owned()) {\n          artist.push_str(&app.user_config.padded_liked_icon());\n        }\n        artist.push_str(&item.name.to_owned());\n        artist\n      })\n      .collect::<Vec<String>>();\n\n    draw_selectable_list(\n      f,\n      app,\n      chunks[2],\n      \"Related artists\",\n      &related_artists,\n      get_artist_highlight_state(app, ArtistBlock::RelatedArtists),\n      Some(artist.selected_related_artist_index),\n    );\n  };\n}\n\npub fn draw_device_list<B>(f: &mut Frame<B>, app: &App)\nwhere\n  B: Backend,\n{\n  let chunks = Layout::default()\n    .direction(Direction::Vertical)\n    .constraints([Constraint::Percentage(20), Constraint::Percentage(80)].as_ref())\n    .margin(5)\n    .split(f.size());\n\n  let device_instructions: Vec<Spans> = vec![\n        \"To play tracks, please select a device. \",\n        \"Use `j/k` or up/down arrow keys to move up and down and <Enter> to select. \",\n        \"Your choice here will be cached so you can jump straight back in when you next open `spotify-tui`. \",\n        \"You can change the playback device at any time by pressing `d`.\",\n    ].into_iter().map(|instruction| Spans::from(Span::raw(instruction))).collect();\n\n  let instructions = Paragraph::new(device_instructions)\n    .style(Style::default().fg(app.user_config.theme.text))\n    .wrap(Wrap { trim: true })\n    .block(\n      Block::default().borders(Borders::NONE).title(Span::styled(\n        \"Welcome to spotify-tui!\",\n        Style::default()\n          .fg(app.user_config.theme.active)\n          .add_modifier(Modifier::BOLD),\n      )),\n    );\n  f.render_widget(instructions, chunks[0]);\n\n  let no_device_message = Span::raw(\"No devices found: Make sure a device is active\");\n\n  let items = match &app.devices {\n    Some(items) => {\n      if items.devices.is_empty() {\n        vec![ListItem::new(no_device_message)]\n      } else {\n        items\n          .devices\n          .iter()\n          .map(|device| ListItem::new(Span::raw(&device.name)))\n          .collect()\n      }\n    }\n    None => vec![ListItem::new(no_device_message)],\n  };\n\n  let mut state = ListState::default();\n  state.select(app.selected_device_index);\n  let list = List::new(items)\n    .block(\n      Block::default()\n        .title(Span::styled(\n          \"Devices\",\n          Style::default().fg(app.user_config.theme.active),\n        ))\n        .borders(Borders::ALL)\n        .border_style(Style::default().fg(app.user_config.theme.inactive)),\n    )\n    .style(Style::default().fg(app.user_config.theme.text))\n    .highlight_style(\n      Style::default()\n        .fg(app.user_config.theme.active)\n        .add_modifier(Modifier::BOLD),\n    );\n  f.render_stateful_widget(list, chunks[1], &mut state);\n}\n\npub fn draw_album_list<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let header = TableHeader {\n    id: TableId::AlbumList,\n    items: vec![\n      TableHeaderItem {\n        text: \"Name\",\n        width: get_percentage_width(layout_chunk.width, 2.0 / 5.0),\n        ..Default::default()\n      },\n      TableHeaderItem {\n        text: \"Artists\",\n        width: get_percentage_width(layout_chunk.width, 2.0 / 5.0),\n        ..Default::default()\n      },\n      TableHeaderItem {\n        text: \"Release Date\",\n        width: get_percentage_width(layout_chunk.width, 1.0 / 5.0),\n        ..Default::default()\n      },\n    ],\n  };\n\n  let current_route = app.get_current_route();\n\n  let highlight_state = (\n    current_route.active_block == ActiveBlock::AlbumList,\n    current_route.hovered_block == ActiveBlock::AlbumList,\n  );\n\n  let selected_song_index = app.album_list_index;\n\n  if let Some(saved_albums) = app.library.saved_albums.get_results(None) {\n    let items = saved_albums\n      .items\n      .iter()\n      .map(|album_page| TableItem {\n        id: album_page.album.id.to_owned(),\n        format: vec![\n          format!(\n            \"{}{}\",\n            app.user_config.padded_liked_icon(),\n            &album_page.album.name\n          ),\n          create_artist_string(&album_page.album.artists),\n          album_page.album.release_date.to_owned(),\n        ],\n      })\n      .collect::<Vec<TableItem>>();\n\n    draw_table(\n      f,\n      app,\n      layout_chunk,\n      (\"Saved Albums\", &header),\n      &items,\n      selected_song_index,\n      highlight_state,\n    )\n  };\n}\n\npub fn draw_show_episodes<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let header = TableHeader {\n    id: TableId::PodcastEpisodes,\n    items: vec![\n      TableHeaderItem {\n        // Column to mark an episode as fully played\n        text: \"\",\n        width: 2,\n        ..Default::default()\n      },\n      TableHeaderItem {\n        text: \"Date\",\n        width: get_percentage_width(layout_chunk.width, 0.5 / 5.0) - 2,\n        ..Default::default()\n      },\n      TableHeaderItem {\n        text: \"Name\",\n        width: get_percentage_width(layout_chunk.width, 3.5 / 5.0),\n        id: ColumnId::Title,\n      },\n      TableHeaderItem {\n        text: \"Duration\",\n        width: get_percentage_width(layout_chunk.width, 1.0 / 5.0),\n        ..Default::default()\n      },\n    ],\n  };\n\n  let current_route = app.get_current_route();\n\n  let highlight_state = (\n    current_route.active_block == ActiveBlock::EpisodeTable,\n    current_route.hovered_block == ActiveBlock::EpisodeTable,\n  );\n\n  if let Some(episodes) = app.library.show_episodes.get_results(None) {\n    let items = episodes\n      .items\n      .iter()\n      .map(|episode| {\n        let (played_str, time_str) = match episode.resume_point {\n          Some(ResumePoint {\n            fully_played,\n            resume_position_ms,\n          }) => (\n            if fully_played {\n              \" ✔\".to_owned()\n            } else {\n              \"\".to_owned()\n            },\n            format!(\n              \"{} / {}\",\n              millis_to_minutes(u128::from(resume_position_ms)),\n              millis_to_minutes(u128::from(episode.duration_ms))\n            ),\n          ),\n          None => (\n            \"\".to_owned(),\n            millis_to_minutes(u128::from(episode.duration_ms)),\n          ),\n        };\n        TableItem {\n          id: episode.id.to_owned(),\n          format: vec![\n            played_str,\n            episode.release_date.to_owned(),\n            episode.name.to_owned(),\n            time_str,\n          ],\n        }\n      })\n      .collect::<Vec<TableItem>>();\n\n    let title = match &app.episode_table_context {\n      EpisodeTableContext::Simplified => match &app.selected_show_simplified {\n        Some(selected_show) => {\n          format!(\n            \"{} by {}\",\n            selected_show.show.name.to_owned(),\n            selected_show.show.publisher\n          )\n        }\n        None => \"Episodes\".to_owned(),\n      },\n      EpisodeTableContext::Full => match &app.selected_show_full {\n        Some(selected_show) => {\n          format!(\n            \"{} by {}\",\n            selected_show.show.name.to_owned(),\n            selected_show.show.publisher\n          )\n        }\n        None => \"Episodes\".to_owned(),\n      },\n    };\n\n    draw_table(\n      f,\n      app,\n      layout_chunk,\n      (&title, &header),\n      &items,\n      app.episode_list_index,\n      highlight_state,\n    );\n  };\n}\n\npub fn draw_made_for_you<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let header = TableHeader {\n    id: TableId::MadeForYou,\n    items: vec![TableHeaderItem {\n      text: \"Name\",\n      width: get_percentage_width(layout_chunk.width, 2.0 / 5.0),\n      ..Default::default()\n    }],\n  };\n\n  if let Some(playlists) = &app.library.made_for_you_playlists.get_results(None) {\n    let items = playlists\n      .items\n      .iter()\n      .map(|playlist| TableItem {\n        id: playlist.id.to_owned(),\n        format: vec![playlist.name.to_owned()],\n      })\n      .collect::<Vec<TableItem>>();\n\n    let current_route = app.get_current_route();\n    let highlight_state = (\n      current_route.active_block == ActiveBlock::MadeForYou,\n      current_route.hovered_block == ActiveBlock::MadeForYou,\n    );\n\n    draw_table(\n      f,\n      app,\n      layout_chunk,\n      (\"Made For You\", &header),\n      &items,\n      app.made_for_you_index,\n      highlight_state,\n    );\n  }\n}\n\npub fn draw_recently_played_table<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)\nwhere\n  B: Backend,\n{\n  let header = TableHeader {\n    id: TableId::RecentlyPlayed,\n    items: vec![\n      TableHeaderItem {\n        id: ColumnId::Liked,\n        text: \"\",\n        width: 2,\n      },\n      TableHeaderItem {\n        id: ColumnId::Title,\n        text: \"Title\",\n        // We need to subtract the fixed value of the previous column\n        width: get_percentage_width(layout_chunk.width, 2.0 / 5.0) - 2,\n      },\n      TableHeaderItem {\n        text: \"Artist\",\n        width: get_percentage_width(layout_chunk.width, 2.0 / 5.0),\n        ..Default::default()\n      },\n      TableHeaderItem {\n        text: \"Length\",\n        width: get_percentage_width(layout_chunk.width, 1.0 / 5.0),\n        ..Default::default()\n      },\n    ],\n  };\n\n  if let Some(recently_played) = &app.recently_played.result {\n    let current_route = app.get_current_route();\n\n    let highlight_state = (\n      current_route.active_block == ActiveBlock::RecentlyPlayed,\n      current_route.hovered_block == ActiveBlock::RecentlyPlayed,\n    );\n\n    let selected_song_index = app.recently_played.index;\n\n    let items = recently_played\n      .items\n      .iter()\n      .map(|item| TableItem {\n        id: item.track.id.clone().unwrap_or_else(|| \"\".to_string()),\n        format: vec![\n          \"\".to_string(),\n          item.track.name.to_owned(),\n          create_artist_string(&item.track.artists),\n          millis_to_minutes(u128::from(item.track.duration_ms)),\n        ],\n      })\n      .collect::<Vec<TableItem>>();\n\n    draw_table(\n      f,\n      app,\n      layout_chunk,\n      (\"Recently Played Tracks\", &header),\n      &items,\n      selected_song_index,\n      highlight_state,\n    )\n  };\n}\n\nfn draw_selectable_list<B, S>(\n  f: &mut Frame<B>,\n  app: &App,\n  layout_chunk: Rect,\n  title: &str,\n  items: &[S],\n  highlight_state: (bool, bool),\n  selected_index: Option<usize>,\n) where\n  B: Backend,\n  S: std::convert::AsRef<str>,\n{\n  let mut state = ListState::default();\n  state.select(selected_index);\n\n  let lst_items: Vec<ListItem> = items\n    .iter()\n    .map(|i| ListItem::new(Span::raw(i.as_ref())))\n    .collect();\n\n  //TODO\n  let list = List::new(lst_items)\n    .block(\n      Block::default()\n        .title(Span::styled(\n          title,\n          get_color(highlight_state, app.user_config.theme),\n        ))\n        .borders(Borders::ALL)\n        .border_style(get_color(highlight_state, app.user_config.theme)),\n    )\n    .style(Style::default().fg(app.user_config.theme.text))\n    .highlight_style(\n      get_color(highlight_state, app.user_config.theme).add_modifier(Modifier::BOLD),\n    );\n  f.render_stateful_widget(list, layout_chunk, &mut state);\n}\n\nfn draw_dialog<B>(f: &mut Frame<B>, app: &App)\nwhere\n  B: Backend,\n{\n  if let ActiveBlock::Dialog(_) = app.get_current_route().active_block {\n    if let Some(playlist) = app.dialog.as_ref() {\n      let bounds = f.size();\n      // maybe do this better\n      let width = std::cmp::min(bounds.width - 2, 45);\n      let height = 8;\n      let left = (bounds.width - width) / 2;\n      let top = bounds.height / 4;\n\n      let rect = Rect::new(left, top, width, height);\n\n      f.render_widget(Clear, rect);\n\n      let block = Block::default()\n        .borders(Borders::ALL)\n        .border_style(Style::default().fg(app.user_config.theme.inactive));\n\n      f.render_widget(block, rect);\n\n      let vchunks = Layout::default()\n        .direction(Direction::Vertical)\n        .margin(2)\n        .constraints([Constraint::Min(3), Constraint::Length(3)].as_ref())\n        .split(rect);\n\n      // suggestion: possibly put this as part of\n      // app.dialog, but would have to introduce lifetime\n      let text = vec![\n        Spans::from(Span::raw(\"Are you sure you want to delete the playlist: \")),\n        Spans::from(Span::styled(\n          playlist.as_str(),\n          Style::default().add_modifier(Modifier::BOLD),\n        )),\n        Spans::from(Span::raw(\"?\")),\n      ];\n\n      let text = Paragraph::new(text)\n        .wrap(Wrap { trim: true })\n        .alignment(Alignment::Center);\n\n      f.render_widget(text, vchunks[0]);\n\n      let hchunks = Layout::default()\n        .direction(Direction::Horizontal)\n        .horizontal_margin(3)\n        .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)].as_ref())\n        .split(vchunks[1]);\n\n      let ok_text = Span::raw(\"Ok\");\n      let ok = Paragraph::new(ok_text)\n        .style(Style::default().fg(if app.confirm {\n          app.user_config.theme.hovered\n        } else {\n          app.user_config.theme.inactive\n        }))\n        .alignment(Alignment::Center);\n\n      f.render_widget(ok, hchunks[0]);\n\n      let cancel_text = Span::raw(\"Cancel\");\n      let cancel = Paragraph::new(cancel_text)\n        .style(Style::default().fg(if app.confirm {\n          app.user_config.theme.inactive\n        } else {\n          app.user_config.theme.hovered\n        }))\n        .alignment(Alignment::Center);\n\n      f.render_widget(cancel, hchunks[1]);\n    }\n  }\n}\n\nfn draw_table<B>(\n  f: &mut Frame<B>,\n  app: &App,\n  layout_chunk: Rect,\n  table_layout: (&str, &TableHeader), // (title, header colums)\n  items: &[TableItem], // The nested vector must have the same length as the `header_columns`\n  selected_index: usize,\n  highlight_state: (bool, bool),\n) where\n  B: Backend,\n{\n  let selected_style =\n    get_color(highlight_state, app.user_config.theme).add_modifier(Modifier::BOLD);\n\n  let track_playing_index = app.current_playback_context.to_owned().and_then(|ctx| {\n    ctx.item.and_then(|item| match item {\n      PlayingItem::Track(track) => items\n        .iter()\n        .position(|item| track.id.to_owned().map(|id| id == item.id).unwrap_or(false)),\n      PlayingItem::Episode(episode) => items.iter().position(|item| episode.id == item.id),\n    })\n  });\n\n  let (title, header) = table_layout;\n\n  // Make sure that the selected item is visible on the page. Need to add some rows of padding\n  // to chunk height for header and header space to get a true table height\n  let padding = 5;\n  let offset = layout_chunk\n    .height\n    .checked_sub(padding)\n    .and_then(|height| selected_index.checked_sub(height as usize))\n    .unwrap_or(0);\n\n  let rows = items.iter().skip(offset).enumerate().map(|(i, item)| {\n    let mut formatted_row = item.format.clone();\n    let mut style = Style::default().fg(app.user_config.theme.text); // default styling\n\n    // if table displays songs\n    match header.id {\n      TableId::Song | TableId::RecentlyPlayed | TableId::Album => {\n        // First check if the song should be highlighted because it is currently playing\n        if let Some(title_idx) = header.get_index(ColumnId::Title) {\n          if let Some(track_playing_offset_index) =\n            track_playing_index.and_then(|idx| idx.checked_sub(offset))\n          {\n            if i == track_playing_offset_index {\n              formatted_row[title_idx] = format!(\"▶ {}\", &formatted_row[title_idx]);\n              style = Style::default()\n                .fg(app.user_config.theme.active)\n                .add_modifier(Modifier::BOLD);\n            }\n          }\n        }\n\n        // Show this the liked icon if the song is liked\n        if let Some(liked_idx) = header.get_index(ColumnId::Liked) {\n          if app.liked_song_ids_set.contains(item.id.as_str()) {\n            formatted_row[liked_idx] = app.user_config.padded_liked_icon();\n          }\n        }\n      }\n      TableId::PodcastEpisodes => {\n        if let Some(name_idx) = header.get_index(ColumnId::Title) {\n          if let Some(track_playing_offset_index) =\n            track_playing_index.and_then(|idx| idx.checked_sub(offset))\n          {\n            if i == track_playing_offset_index {\n              formatted_row[name_idx] = format!(\"▶ {}\", &formatted_row[name_idx]);\n              style = Style::default()\n                .fg(app.user_config.theme.active)\n                .add_modifier(Modifier::BOLD);\n            }\n          }\n        }\n      }\n      _ => {}\n    }\n\n    // Next check if the item is under selection.\n    if Some(i) == selected_index.checked_sub(offset) {\n      style = selected_style;\n    }\n\n    // Return row styled data\n    Row::new(formatted_row).style(style)\n  });\n\n  let widths = header\n    .items\n    .iter()\n    .map(|h| Constraint::Length(h.width))\n    .collect::<Vec<tui::layout::Constraint>>();\n\n  let table = Table::new(rows)\n    .header(\n      Row::new(header.items.iter().map(|h| h.text))\n        .style(Style::default().fg(app.user_config.theme.header)),\n    )\n    .block(\n      Block::default()\n        .borders(Borders::ALL)\n        .style(Style::default().fg(app.user_config.theme.text))\n        .title(Span::styled(\n          title,\n          get_color(highlight_state, app.user_config.theme),\n        ))\n        .border_style(get_color(highlight_state, app.user_config.theme)),\n    )\n    .style(Style::default().fg(app.user_config.theme.text))\n    .widths(&widths);\n  f.render_widget(table, layout_chunk);\n}\n"
  },
  {
    "path": "src/ui/util.rs",
    "content": "use super::super::app::{ActiveBlock, App, ArtistBlock, SearchResultBlock};\nuse crate::user_config::Theme;\nuse rspotify::model::artist::SimplifiedArtist;\nuse tui::style::Style;\n\npub const BASIC_VIEW_HEIGHT: u16 = 6;\npub const SMALL_TERMINAL_WIDTH: u16 = 150;\npub const SMALL_TERMINAL_HEIGHT: u16 = 45;\n\npub fn get_search_results_highlight_state(\n  app: &App,\n  block_to_match: SearchResultBlock,\n) -> (bool, bool) {\n  let current_route = app.get_current_route();\n  (\n    app.search_results.selected_block == block_to_match,\n    current_route.hovered_block == ActiveBlock::SearchResultBlock\n      && app.search_results.hovered_block == block_to_match,\n  )\n}\n\npub fn get_artist_highlight_state(app: &App, block_to_match: ArtistBlock) -> (bool, bool) {\n  let current_route = app.get_current_route();\n  if let Some(artist) = &app.artist {\n    let is_hovered = artist.artist_selected_block == block_to_match;\n    let is_selected = current_route.hovered_block == ActiveBlock::ArtistBlock\n      && artist.artist_hovered_block == block_to_match;\n    (is_hovered, is_selected)\n  } else {\n    (false, false)\n  }\n}\n\npub fn get_color((is_active, is_hovered): (bool, bool), theme: Theme) -> Style {\n  match (is_active, is_hovered) {\n    (true, _) => Style::default().fg(theme.selected),\n    (false, true) => Style::default().fg(theme.hovered),\n    _ => Style::default().fg(theme.inactive),\n  }\n}\n\npub fn create_artist_string(artists: &[SimplifiedArtist]) -> String {\n  artists\n    .iter()\n    .map(|artist| artist.name.to_string())\n    .collect::<Vec<String>>()\n    .join(\", \")\n}\n\npub fn millis_to_minutes(millis: u128) -> String {\n  let minutes = millis / 60000;\n  let seconds = (millis % 60000) / 1000;\n  let seconds_display = if seconds < 10 {\n    format!(\"0{}\", seconds)\n  } else {\n    format!(\"{}\", seconds)\n  };\n\n  if seconds == 60 {\n    format!(\"{}:00\", minutes + 1)\n  } else {\n    format!(\"{}:{}\", minutes, seconds_display)\n  }\n}\n\npub fn display_track_progress(progress: u128, track_duration: u32) -> String {\n  let duration = millis_to_minutes(u128::from(track_duration));\n  let progress_display = millis_to_minutes(progress);\n  let remaining = millis_to_minutes(u128::from(track_duration).saturating_sub(progress));\n\n  format!(\"{}/{} (-{})\", progress_display, duration, remaining,)\n}\n\n// `percentage` param needs to be between 0 and 1\npub fn get_percentage_width(width: u16, percentage: f32) -> u16 {\n  let padding = 3;\n  let width = width - padding;\n  (f32::from(width) * percentage) as u16\n}\n\n// Ensure track progress percentage is between 0 and 100 inclusive\npub fn get_track_progress_percentage(song_progress_ms: u128, track_duration_ms: u32) -> u16 {\n  let min_perc = 0_f64;\n  let track_progress = std::cmp::min(song_progress_ms, track_duration_ms.into());\n  let track_perc = (track_progress as f64 / f64::from(track_duration_ms)) * 100_f64;\n  min_perc.max(track_perc) as u16\n}\n\n// Make better use of space on small terminals\npub fn get_main_layout_margin(app: &App) -> u16 {\n  if app.size.height > SMALL_TERMINAL_HEIGHT {\n    1\n  } else {\n    0\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n\n  #[test]\n  fn millis_to_minutes_test() {\n    assert_eq!(millis_to_minutes(0), \"0:00\");\n    assert_eq!(millis_to_minutes(1000), \"0:01\");\n    assert_eq!(millis_to_minutes(1500), \"0:01\");\n    assert_eq!(millis_to_minutes(1900), \"0:01\");\n    assert_eq!(millis_to_minutes(60 * 1000), \"1:00\");\n    assert_eq!(millis_to_minutes(60 * 1500), \"1:30\");\n  }\n\n  #[test]\n  fn display_track_progress_test() {\n    assert_eq!(\n      display_track_progress(0, 2 * 60 * 1000),\n      \"0:00/2:00 (-2:00)\"\n    );\n\n    assert_eq!(\n      display_track_progress(60 * 1000, 2 * 60 * 1000),\n      \"1:00/2:00 (-1:00)\"\n    );\n  }\n\n  #[test]\n  fn get_track_progress_percentage_test() {\n    let track_length = 60 * 1000;\n    assert_eq!(get_track_progress_percentage(0, track_length), 0);\n    assert_eq!(\n      get_track_progress_percentage((60 * 1000) / 2, track_length),\n      50\n    );\n\n    // If progress is somehow higher than total duration, 100 should be max\n    assert_eq!(\n      get_track_progress_percentage(60 * 1000 * 2, track_length),\n      100\n    );\n  }\n}\n"
  },
  {
    "path": "src/user_config.rs",
    "content": "use crate::event::Key;\nuse anyhow::{anyhow, Result};\nuse serde::{Deserialize, Serialize};\nuse std::{\n  fs,\n  path::{Path, PathBuf},\n};\nuse tui::style::Color;\n\nconst FILE_NAME: &str = \"config.yml\";\nconst CONFIG_DIR: &str = \".config\";\nconst APP_CONFIG_DIR: &str = \"spotify-tui\";\n\n#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]\npub struct UserTheme {\n  pub active: Option<String>,\n  pub banner: Option<String>,\n  pub error_border: Option<String>,\n  pub error_text: Option<String>,\n  pub hint: Option<String>,\n  pub hovered: Option<String>,\n  pub inactive: Option<String>,\n  pub playbar_background: Option<String>,\n  pub playbar_progress: Option<String>,\n  pub playbar_progress_text: Option<String>,\n  pub playbar_text: Option<String>,\n  pub selected: Option<String>,\n  pub text: Option<String>,\n  pub header: Option<String>,\n}\n\n#[derive(Copy, Clone, Debug)]\npub struct Theme {\n  pub analysis_bar: Color,\n  pub analysis_bar_text: Color,\n  pub active: Color,\n  pub banner: Color,\n  pub error_border: Color,\n  pub error_text: Color,\n  pub hint: Color,\n  pub hovered: Color,\n  pub inactive: Color,\n  pub playbar_background: Color,\n  pub playbar_progress: Color,\n  pub playbar_progress_text: Color,\n  pub playbar_text: Color,\n  pub selected: Color,\n  pub text: Color,\n  pub header: Color,\n}\n\nimpl Default for Theme {\n  fn default() -> Self {\n    Theme {\n      analysis_bar: Color::LightCyan,\n      analysis_bar_text: Color::Reset,\n      active: Color::Cyan,\n      banner: Color::LightCyan,\n      error_border: Color::Red,\n      error_text: Color::LightRed,\n      hint: Color::Yellow,\n      hovered: Color::Magenta,\n      inactive: Color::Gray,\n      playbar_background: Color::Black,\n      playbar_progress: Color::LightCyan,\n      playbar_progress_text: Color::LightCyan,\n      playbar_text: Color::Reset,\n      selected: Color::LightCyan,\n      text: Color::Reset,\n      header: Color::Reset,\n    }\n  }\n}\n\nfn parse_key(key: String) -> Result<Key> {\n  fn get_single_char(string: &str) -> char {\n    match string.chars().next() {\n      Some(c) => c,\n      None => panic!(),\n    }\n  }\n\n  match key.len() {\n    1 => Ok(Key::Char(get_single_char(key.as_str()))),\n    _ => {\n      let sections: Vec<&str> = key.split('-').collect();\n\n      if sections.len() > 2 {\n        return Err(anyhow!(\n          \"Shortcut can only have 2 keys, \\\"{}\\\" has {}\",\n          key,\n          sections.len()\n        ));\n      }\n\n      match sections[0].to_lowercase().as_str() {\n        \"ctrl\" => Ok(Key::Ctrl(get_single_char(sections[1]))),\n        \"alt\" => Ok(Key::Alt(get_single_char(sections[1]))),\n        \"left\" => Ok(Key::Left),\n        \"right\" => Ok(Key::Right),\n        \"up\" => Ok(Key::Up),\n        \"down\" => Ok(Key::Down),\n        \"backspace\" | \"delete\" => Ok(Key::Backspace),\n        \"del\" => Ok(Key::Delete),\n        \"esc\" | \"escape\" => Ok(Key::Esc),\n        \"pageup\" => Ok(Key::PageUp),\n        \"pagedown\" => Ok(Key::PageDown),\n        \"space\" => Ok(Key::Char(' ')),\n        _ => Err(anyhow!(\"The key \\\"{}\\\" is unknown.\", sections[0])),\n      }\n    }\n  }\n}\n\nfn check_reserved_keys(key: Key) -> Result<()> {\n  let reserved = [\n    Key::Char('h'),\n    Key::Char('j'),\n    Key::Char('k'),\n    Key::Char('l'),\n    Key::Char('H'),\n    Key::Char('M'),\n    Key::Char('L'),\n    Key::Up,\n    Key::Down,\n    Key::Left,\n    Key::Right,\n    Key::Backspace,\n    Key::Enter,\n  ];\n  for item in reserved.iter() {\n    if key == *item {\n      // TODO: Add pretty print for key\n      return Err(anyhow!(\n        \"The key {:?} is reserved and cannot be remapped\",\n        key\n      ));\n    }\n  }\n  Ok(())\n}\n\n#[derive(Clone)]\npub struct UserConfigPaths {\n  pub config_file_path: PathBuf,\n}\n\n#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]\npub struct KeyBindingsString {\n  back: Option<String>,\n  next_page: Option<String>,\n  previous_page: Option<String>,\n  jump_to_start: Option<String>,\n  jump_to_end: Option<String>,\n  jump_to_album: Option<String>,\n  jump_to_artist_album: Option<String>,\n  jump_to_context: Option<String>,\n  manage_devices: Option<String>,\n  decrease_volume: Option<String>,\n  increase_volume: Option<String>,\n  toggle_playback: Option<String>,\n  seek_backwards: Option<String>,\n  seek_forwards: Option<String>,\n  next_track: Option<String>,\n  previous_track: Option<String>,\n  help: Option<String>,\n  shuffle: Option<String>,\n  repeat: Option<String>,\n  search: Option<String>,\n  submit: Option<String>,\n  copy_song_url: Option<String>,\n  copy_album_url: Option<String>,\n  audio_analysis: Option<String>,\n  basic_view: Option<String>,\n  add_item_to_queue: Option<String>,\n}\n\n#[derive(Clone)]\npub struct KeyBindings {\n  pub back: Key,\n  pub next_page: Key,\n  pub previous_page: Key,\n  pub jump_to_start: Key,\n  pub jump_to_end: Key,\n  pub jump_to_album: Key,\n  pub jump_to_artist_album: Key,\n  pub jump_to_context: Key,\n  pub manage_devices: Key,\n  pub decrease_volume: Key,\n  pub increase_volume: Key,\n  pub toggle_playback: Key,\n  pub seek_backwards: Key,\n  pub seek_forwards: Key,\n  pub next_track: Key,\n  pub previous_track: Key,\n  pub help: Key,\n  pub shuffle: Key,\n  pub repeat: Key,\n  pub search: Key,\n  pub submit: Key,\n  pub copy_song_url: Key,\n  pub copy_album_url: Key,\n  pub audio_analysis: Key,\n  pub basic_view: Key,\n  pub add_item_to_queue: Key,\n}\n\n#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]\npub struct BehaviorConfigString {\n  pub seek_milliseconds: Option<u32>,\n  pub volume_increment: Option<u8>,\n  pub tick_rate_milliseconds: Option<u64>,\n  pub enable_text_emphasis: Option<bool>,\n  pub show_loading_indicator: Option<bool>,\n  pub enforce_wide_search_bar: Option<bool>,\n  pub liked_icon: Option<String>,\n  pub shuffle_icon: Option<String>,\n  pub repeat_track_icon: Option<String>,\n  pub repeat_context_icon: Option<String>,\n  pub playing_icon: Option<String>,\n  pub paused_icon: Option<String>,\n  pub set_window_title: Option<bool>,\n}\n\n#[derive(Clone)]\npub struct BehaviorConfig {\n  pub seek_milliseconds: u32,\n  pub volume_increment: u8,\n  pub tick_rate_milliseconds: u64,\n  pub enable_text_emphasis: bool,\n  pub show_loading_indicator: bool,\n  pub enforce_wide_search_bar: bool,\n  pub liked_icon: String,\n  pub shuffle_icon: String,\n  pub repeat_track_icon: String,\n  pub repeat_context_icon: String,\n  pub playing_icon: String,\n  pub paused_icon: String,\n  pub set_window_title: bool,\n}\n\n#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]\npub struct UserConfigString {\n  keybindings: Option<KeyBindingsString>,\n  behavior: Option<BehaviorConfigString>,\n  theme: Option<UserTheme>,\n}\n\n#[derive(Clone)]\npub struct UserConfig {\n  pub keys: KeyBindings,\n  pub theme: Theme,\n  pub behavior: BehaviorConfig,\n  pub path_to_config: Option<UserConfigPaths>,\n}\n\nimpl UserConfig {\n  pub fn new() -> UserConfig {\n    UserConfig {\n      theme: Default::default(),\n      keys: KeyBindings {\n        back: Key::Char('q'),\n        next_page: Key::Ctrl('d'),\n        previous_page: Key::Ctrl('u'),\n        jump_to_start: Key::Ctrl('a'),\n        jump_to_end: Key::Ctrl('e'),\n        jump_to_album: Key::Char('a'),\n        jump_to_artist_album: Key::Char('A'),\n        jump_to_context: Key::Char('o'),\n        manage_devices: Key::Char('d'),\n        decrease_volume: Key::Char('-'),\n        increase_volume: Key::Char('+'),\n        toggle_playback: Key::Char(' '),\n        seek_backwards: Key::Char('<'),\n        seek_forwards: Key::Char('>'),\n        next_track: Key::Char('n'),\n        previous_track: Key::Char('p'),\n        help: Key::Char('?'),\n        shuffle: Key::Ctrl('s'),\n        repeat: Key::Ctrl('r'),\n        search: Key::Char('/'),\n        submit: Key::Enter,\n        copy_song_url: Key::Char('c'),\n        copy_album_url: Key::Char('C'),\n        audio_analysis: Key::Char('v'),\n        basic_view: Key::Char('B'),\n        add_item_to_queue: Key::Char('z'),\n      },\n      behavior: BehaviorConfig {\n        seek_milliseconds: 5 * 1000,\n        volume_increment: 10,\n        tick_rate_milliseconds: 250,\n        enable_text_emphasis: true,\n        show_loading_indicator: true,\n        enforce_wide_search_bar: false,\n        liked_icon: \"♥\".to_string(),\n        shuffle_icon: \"🔀\".to_string(),\n        repeat_track_icon: \"🔂\".to_string(),\n        repeat_context_icon: \"🔁\".to_string(),\n        playing_icon: \"▶\".to_string(),\n        paused_icon: \"⏸\".to_string(),\n        set_window_title: true,\n      },\n      path_to_config: None,\n    }\n  }\n\n  pub fn get_or_build_paths(&mut self) -> Result<()> {\n    match dirs::home_dir() {\n      Some(home) => {\n        let path = Path::new(&home);\n        let home_config_dir = path.join(CONFIG_DIR);\n        let app_config_dir = home_config_dir.join(APP_CONFIG_DIR);\n\n        if !home_config_dir.exists() {\n          fs::create_dir(&home_config_dir)?;\n        }\n\n        if !app_config_dir.exists() {\n          fs::create_dir(&app_config_dir)?;\n        }\n\n        let config_file_path = &app_config_dir.join(FILE_NAME);\n\n        let paths = UserConfigPaths {\n          config_file_path: config_file_path.to_path_buf(),\n        };\n        self.path_to_config = Some(paths);\n        Ok(())\n      }\n      None => Err(anyhow!(\"No $HOME directory found for client config\")),\n    }\n  }\n\n  pub fn load_keybindings(&mut self, keybindings: KeyBindingsString) -> Result<()> {\n    macro_rules! to_keys {\n      ($name: ident) => {\n        if let Some(key_string) = keybindings.$name {\n          self.keys.$name = parse_key(key_string)?;\n          check_reserved_keys(self.keys.$name)?;\n        }\n      };\n    }\n\n    to_keys!(back);\n    to_keys!(next_page);\n    to_keys!(previous_page);\n    to_keys!(jump_to_start);\n    to_keys!(jump_to_end);\n    to_keys!(jump_to_album);\n    to_keys!(jump_to_artist_album);\n    to_keys!(jump_to_context);\n    to_keys!(manage_devices);\n    to_keys!(decrease_volume);\n    to_keys!(increase_volume);\n    to_keys!(toggle_playback);\n    to_keys!(seek_backwards);\n    to_keys!(seek_forwards);\n    to_keys!(next_track);\n    to_keys!(previous_track);\n    to_keys!(help);\n    to_keys!(shuffle);\n    to_keys!(repeat);\n    to_keys!(search);\n    to_keys!(submit);\n    to_keys!(copy_song_url);\n    to_keys!(copy_album_url);\n    to_keys!(audio_analysis);\n    to_keys!(basic_view);\n    to_keys!(add_item_to_queue);\n\n    Ok(())\n  }\n\n  pub fn load_theme(&mut self, theme: UserTheme) -> Result<()> {\n    macro_rules! to_theme_item {\n      ($name: ident) => {\n        if let Some(theme_item) = theme.$name {\n          self.theme.$name = parse_theme_item(&theme_item)?;\n        }\n      };\n    }\n\n    to_theme_item!(active);\n    to_theme_item!(banner);\n    to_theme_item!(error_border);\n    to_theme_item!(error_text);\n    to_theme_item!(hint);\n    to_theme_item!(hovered);\n    to_theme_item!(inactive);\n    to_theme_item!(playbar_background);\n    to_theme_item!(playbar_progress);\n    to_theme_item!(playbar_progress_text);\n    to_theme_item!(playbar_text);\n    to_theme_item!(selected);\n    to_theme_item!(text);\n    to_theme_item!(header);\n    Ok(())\n  }\n\n  pub fn load_behaviorconfig(&mut self, behavior_config: BehaviorConfigString) -> Result<()> {\n    if let Some(behavior_string) = behavior_config.seek_milliseconds {\n      self.behavior.seek_milliseconds = behavior_string;\n    }\n\n    if let Some(behavior_string) = behavior_config.volume_increment {\n      if behavior_string > 100 {\n        return Err(anyhow!(\n          \"Volume increment must be between 0 and 100, is {}\",\n          behavior_string,\n        ));\n      }\n      self.behavior.volume_increment = behavior_string;\n    }\n\n    if let Some(tick_rate) = behavior_config.tick_rate_milliseconds {\n      if tick_rate >= 1000 {\n        return Err(anyhow!(\"Tick rate must be below 1000\"));\n      } else {\n        self.behavior.tick_rate_milliseconds = tick_rate;\n      }\n    }\n\n    if let Some(text_emphasis) = behavior_config.enable_text_emphasis {\n      self.behavior.enable_text_emphasis = text_emphasis;\n    }\n\n    if let Some(loading_indicator) = behavior_config.show_loading_indicator {\n      self.behavior.show_loading_indicator = loading_indicator;\n    }\n\n    if let Some(wide_search_bar) = behavior_config.enforce_wide_search_bar {\n      self.behavior.enforce_wide_search_bar = wide_search_bar;\n    }\n\n    if let Some(liked_icon) = behavior_config.liked_icon {\n      self.behavior.liked_icon = liked_icon;\n    }\n\n    if let Some(paused_icon) = behavior_config.paused_icon {\n      self.behavior.paused_icon = paused_icon;\n    }\n\n    if let Some(playing_icon) = behavior_config.playing_icon {\n      self.behavior.playing_icon = playing_icon;\n    }\n\n    if let Some(shuffle_icon) = behavior_config.shuffle_icon {\n      self.behavior.shuffle_icon = shuffle_icon;\n    }\n\n    if let Some(repeat_track_icon) = behavior_config.repeat_track_icon {\n      self.behavior.repeat_track_icon = repeat_track_icon;\n    }\n\n    if let Some(repeat_context_icon) = behavior_config.repeat_context_icon {\n      self.behavior.repeat_context_icon = repeat_context_icon;\n    }\n\n    if let Some(set_window_title) = behavior_config.set_window_title {\n      self.behavior.set_window_title = set_window_title;\n    }\n\n    Ok(())\n  }\n\n  pub fn load_config(&mut self) -> Result<()> {\n    let paths = match &self.path_to_config {\n      Some(path) => path,\n      None => {\n        self.get_or_build_paths()?;\n        self.path_to_config.as_ref().unwrap()\n      }\n    };\n    if paths.config_file_path.exists() {\n      let config_string = fs::read_to_string(&paths.config_file_path)?;\n      // serde fails if file is empty\n      if config_string.trim().is_empty() {\n        return Ok(());\n      }\n\n      let config_yml: UserConfigString = serde_yaml::from_str(&config_string)?;\n\n      if let Some(keybindings) = config_yml.keybindings.clone() {\n        self.load_keybindings(keybindings)?;\n      }\n\n      if let Some(behavior) = config_yml.behavior {\n        self.load_behaviorconfig(behavior)?;\n      }\n      if let Some(theme) = config_yml.theme {\n        self.load_theme(theme)?;\n      }\n\n      Ok(())\n    } else {\n      Ok(())\n    }\n  }\n\n  pub fn padded_liked_icon(&self) -> String {\n    format!(\"{} \", &self.behavior.liked_icon)\n  }\n}\n\nfn parse_theme_item(theme_item: &str) -> Result<Color> {\n  let color = match theme_item {\n    \"Reset\" => Color::Reset,\n    \"Black\" => Color::Black,\n    \"Red\" => Color::Red,\n    \"Green\" => Color::Green,\n    \"Yellow\" => Color::Yellow,\n    \"Blue\" => Color::Blue,\n    \"Magenta\" => Color::Magenta,\n    \"Cyan\" => Color::Cyan,\n    \"Gray\" => Color::Gray,\n    \"DarkGray\" => Color::DarkGray,\n    \"LightRed\" => Color::LightRed,\n    \"LightGreen\" => Color::LightGreen,\n    \"LightYellow\" => Color::LightYellow,\n    \"LightBlue\" => Color::LightBlue,\n    \"LightMagenta\" => Color::LightMagenta,\n    \"LightCyan\" => Color::LightCyan,\n    \"White\" => Color::White,\n    _ => {\n      let colors = theme_item.split(',').collect::<Vec<&str>>();\n      if let (Some(r), Some(g), Some(b)) = (colors.get(0), colors.get(1), colors.get(2)) {\n        Color::Rgb(\n          r.trim().parse::<u8>()?,\n          g.trim().parse::<u8>()?,\n          b.trim().parse::<u8>()?,\n        )\n      } else {\n        println!(\"Unexpected color {}\", theme_item);\n        Color::Black\n      }\n    }\n  };\n\n  Ok(color)\n}\n\n#[cfg(test)]\nmod tests {\n  #[test]\n  fn test_parse_key() {\n    use super::parse_key;\n    use crate::event::Key;\n    assert_eq!(parse_key(String::from(\"j\")).unwrap(), Key::Char('j'));\n    assert_eq!(parse_key(String::from(\"J\")).unwrap(), Key::Char('J'));\n    assert_eq!(parse_key(String::from(\"ctrl-j\")).unwrap(), Key::Ctrl('j'));\n    assert_eq!(parse_key(String::from(\"ctrl-J\")).unwrap(), Key::Ctrl('J'));\n    assert_eq!(parse_key(String::from(\"-\")).unwrap(), Key::Char('-'));\n    assert_eq!(parse_key(String::from(\"esc\")).unwrap(), Key::Esc);\n    assert_eq!(parse_key(String::from(\"del\")).unwrap(), Key::Delete);\n  }\n\n  #[test]\n  fn parse_theme_item_test() {\n    use super::parse_theme_item;\n    use tui::style::Color;\n    assert_eq!(parse_theme_item(\"Reset\").unwrap(), Color::Reset);\n    assert_eq!(parse_theme_item(\"Black\").unwrap(), Color::Black);\n    assert_eq!(parse_theme_item(\"Red\").unwrap(), Color::Red);\n    assert_eq!(parse_theme_item(\"Green\").unwrap(), Color::Green);\n    assert_eq!(parse_theme_item(\"Yellow\").unwrap(), Color::Yellow);\n    assert_eq!(parse_theme_item(\"Blue\").unwrap(), Color::Blue);\n    assert_eq!(parse_theme_item(\"Magenta\").unwrap(), Color::Magenta);\n    assert_eq!(parse_theme_item(\"Cyan\").unwrap(), Color::Cyan);\n    assert_eq!(parse_theme_item(\"Gray\").unwrap(), Color::Gray);\n    assert_eq!(parse_theme_item(\"DarkGray\").unwrap(), Color::DarkGray);\n    assert_eq!(parse_theme_item(\"LightRed\").unwrap(), Color::LightRed);\n    assert_eq!(parse_theme_item(\"LightGreen\").unwrap(), Color::LightGreen);\n    assert_eq!(parse_theme_item(\"LightYellow\").unwrap(), Color::LightYellow);\n    assert_eq!(parse_theme_item(\"LightBlue\").unwrap(), Color::LightBlue);\n    assert_eq!(\n      parse_theme_item(\"LightMagenta\").unwrap(),\n      Color::LightMagenta\n    );\n    assert_eq!(parse_theme_item(\"LightCyan\").unwrap(), Color::LightCyan);\n    assert_eq!(parse_theme_item(\"White\").unwrap(), Color::White);\n    assert_eq!(\n      parse_theme_item(\"23, 43, 45\").unwrap(),\n      Color::Rgb(23, 43, 45)\n    );\n  }\n\n  #[test]\n  fn test_reserved_key() {\n    use super::check_reserved_keys;\n    use crate::event::Key;\n\n    assert!(\n      check_reserved_keys(Key::Enter).is_err(),\n      \"Enter key should be reserved\"\n    );\n  }\n}\n"
  },
  {
    "path": "src/util.rs",
    "content": "use std::{io::stdin, sync::mpsc, thread, time::Duration};\nuse termion::{event::Key, input::TermRead};\n\npub enum Event<I> {\n    Input(I),\n    Tick,\n}\n\n/// A small event handler that wrap termion input and tick events. Each event\n/// type is handled in its own thread and returned to a common `Receiver`\npub struct Events {\n    rx: mpsc::Receiver<Event<Key>>,\n}\n\n#[derive(Debug, Clone, Copy)]\npub struct Config {\n    pub exit_key: Key,\n    pub tick_rate: Duration,\n}\n\nimpl Default for Config {\n    fn default() -> Config {\n        Config {\n            exit_key: Key::Ctrl('c'),\n            tick_rate: Duration::from_millis(250),\n        }\n    }\n}\n\nimpl Events {\n    pub fn new() -> Events {\n        Events::with_config(Config::default())\n    }\n\n    pub fn with_config(config: Config) -> Events {\n        let (tx, rx) = mpsc::channel();\n        let _input_handle = {\n            let tx = tx.clone();\n            thread::spawn(move || {\n                let stdin_result = stdin();\n                for evt in stdin_result.keys() {\n                    if let Ok(key) = evt {\n                        if tx.send(Event::Input(key)).is_err() {\n                            return;\n                        }\n                        if key == config.exit_key {\n                            return;\n                        }\n                    }\n                }\n            })\n        };\n\n        let _tick_handle = {\n            let tx = tx;\n            thread::spawn(move || {\n                let tx = tx.clone();\n                loop {\n                    tx.send(Event::Tick).unwrap();\n                    thread::sleep(config.tick_rate);\n                }\n            })\n        };\n\n        Events { rx }\n    }\n\n    pub fn next(&self) -> Result<Event<Key>, mpsc::RecvError> {\n        self.rx.recv()\n    }\n}\n"
  }
]