├── .all-contributorsrc ├── .eslintrc.json ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── media ├── icon.png ├── screenshot.png └── screenshot2.png ├── package-lock.json ├── package.json ├── resources ├── dark │ ├── logo.svg │ └── refresh.svg └── light │ └── refresh.svg ├── src ├── actions │ ├── actions.ts │ └── common.ts ├── auth │ └── server │ │ └── local.ts ├── commands.ts ├── components │ ├── spotify-controls.ts │ ├── spotify-status.ts │ ├── tree-albums.ts │ ├── tree-playlists.ts │ └── tree-track.ts ├── config │ └── spotify-config.ts ├── consts │ └── consts.ts ├── extension.ts ├── info │ └── info.ts ├── isAlbum.ts ├── lyrics │ └── lyrics.ts ├── reducers │ └── root-reducer.ts ├── request │ └── request.ts ├── spotify-status-controller.ts ├── spotify │ ├── common.ts │ ├── linux-spotify-client.ts │ ├── os-agnostic-spotify-client.ts │ ├── osx-spotify-client.ts │ ├── spotify-client.ts │ ├── utils.ts │ └── web-api-spotify-client.ts ├── state │ └── state.ts ├── store │ ├── storage │ │ └── vscode-storage.ts │ └── store.ts ├── types │ └── spotify-node-applescript.d.ts └── utils │ └── utils.ts └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "vscode-spotify", 3 | "projectOwner": "ShyykoSerhiy", 4 | "badgeTemplate": "[![All Contributors](https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg?style=flat)](#contributors)", 5 | "files": [ 6 | "README.md" 7 | ], 8 | "imageSize": 100, 9 | "commit": true, 10 | "contributors": [ 11 | { 12 | "login": "ShyykoSerhiy", 13 | "name": "shyyko.serhiy", 14 | "avatar_url": "https://avatars1.githubusercontent.com/u/1106995?v=4", 15 | "profile": "https://github.com/ShyykoSerhiy", 16 | "contributions": [ 17 | "code", 18 | "design", 19 | "doc", 20 | "ideas", 21 | "review" 22 | ] 23 | }, 24 | { 25 | "login": "levrik", 26 | "name": "Levin Rickert", 27 | "avatar_url": "https://avatars3.githubusercontent.com/u/9491603?v=4", 28 | "profile": "https://www.levrik.io", 29 | "contributions": [ 30 | "bug", 31 | "code" 32 | ] 33 | }, 34 | { 35 | "login": "mrcasals", 36 | "name": "Marc Riera", 37 | "avatar_url": "https://avatars3.githubusercontent.com/u/491891?v=4", 38 | "profile": "https://github.com/mrcasals", 39 | "contributions": [ 40 | "code" 41 | ] 42 | }, 43 | { 44 | "login": "ecbrodie", 45 | "name": "Evan Brodie", 46 | "avatar_url": "https://avatars2.githubusercontent.com/u/1844664?v=4", 47 | "profile": "https://github.com/ecbrodie", 48 | "contributions": [ 49 | "code", 50 | "bug" 51 | ] 52 | }, 53 | { 54 | "login": "stephanyan", 55 | "name": "Stéphane", 56 | "avatar_url": "https://avatars1.githubusercontent.com/u/5939522?v=4", 57 | "profile": "https://github.com/stephanyan", 58 | "contributions": [ 59 | "doc" 60 | ] 61 | }, 62 | { 63 | "login": "Ryan-Gordon", 64 | "name": "Ryan Gordon", 65 | "avatar_url": "https://avatars1.githubusercontent.com/u/11082710?v=4", 66 | "profile": "https://github.com/Ryan-Gordon", 67 | "contributions": [ 68 | "doc", 69 | "ideas" 70 | ] 71 | }, 72 | { 73 | "login": "audstanley", 74 | "name": "Richard Stanley", 75 | "avatar_url": "https://avatars0.githubusercontent.com/u/2934052?v=4", 76 | "profile": "http://www.audstanley.com", 77 | "contributions": [ 78 | "code" 79 | ] 80 | }, 81 | { 82 | "login": "realbizkit", 83 | "name": "realbizkit", 84 | "avatar_url": "https://avatars1.githubusercontent.com/u/25567148?v=4", 85 | "profile": "https://github.com/realbizkit", 86 | "contributions": [ 87 | "code" 88 | ] 89 | }, 90 | { 91 | "login": "xeBuz", 92 | "name": "Jesús Roldán", 93 | "avatar_url": "https://avatars1.githubusercontent.com/u/662916?v=4", 94 | "profile": "http://jesusroldan.com", 95 | "contributions": [ 96 | "code" 97 | ] 98 | }, 99 | { 100 | "login": "negebauer", 101 | "name": "Nicolás Gebauer", 102 | "avatar_url": "https://avatars3.githubusercontent.com/u/11860880?v=4", 103 | "profile": "https://negebauer.com", 104 | "contributions": [ 105 | "code" 106 | ] 107 | }, 108 | { 109 | "login": "mikqi", 110 | "name": "Muhammad Rivki", 111 | "avatar_url": "https://avatars0.githubusercontent.com/u/4416419?v=4", 112 | "profile": "http://this.rivki.id/", 113 | "contributions": [ 114 | "code" 115 | ] 116 | }, 117 | { 118 | "login": "miguelBinpar", 119 | "name": "Miguel Rodríguez Rosales", 120 | "avatar_url": "https://avatars0.githubusercontent.com/u/14270461?v=4", 121 | "profile": "https://github.com/miguelBinpar", 122 | "contributions": [ 123 | "code" 124 | ] 125 | }, 126 | { 127 | "login": "moshfeu", 128 | "name": "Mosh Feu", 129 | "avatar_url": "https://avatars2.githubusercontent.com/u/3723951?v=4", 130 | "profile": "https://il.linkedin.com/in/moshefeuchtwanger", 131 | "contributions": [ 132 | "doc" 133 | ] 134 | }, 135 | { 136 | "login": "pzelnip", 137 | "name": "Adam Parkin", 138 | "avatar_url": "https://avatars1.githubusercontent.com/u/414933?v=4", 139 | "profile": "https://www.codependentcodr.com", 140 | "contributions": [ 141 | "doc" 142 | ] 143 | }, 144 | { 145 | "login": "AndrewBastin", 146 | "name": "Andrew Bastin", 147 | "avatar_url": "https://avatars2.githubusercontent.com/u/9131943?v=4", 148 | "profile": "https://github.com/AndrewBastin", 149 | "contributions": [ 150 | "doc" 151 | ] 152 | }, 153 | { 154 | "login": "misterfoxy", 155 | "name": "Michael Fox", 156 | "avatar_url": "https://avatars2.githubusercontent.com/u/21694891?v=4", 157 | "profile": "https://www.michaelscottfox.com", 158 | "contributions": [ 159 | "doc" 160 | ] 161 | }, 162 | { 163 | "login": "matijamrkaic", 164 | "name": "Matija Mrkaic", 165 | "avatar_url": "https://avatars1.githubusercontent.com/u/2392130?v=4", 166 | "profile": "http://korra.io/", 167 | "contributions": [ 168 | "code" 169 | ] 170 | }, 171 | { 172 | "login": "marioortizmanero", 173 | "name": "Mario", 174 | "avatar_url": "https://avatars2.githubusercontent.com/u/25647296?v=4", 175 | "profile": "https://github.com/marioortizmanero", 176 | "contributions": [ 177 | "doc" 178 | ] 179 | }, 180 | { 181 | "login": "TheFern2", 182 | "name": "Fernando B", 183 | "avatar_url": "https://avatars3.githubusercontent.com/u/10265682?v=4", 184 | "profile": "http://www.kodaman.dev/", 185 | "contributions": [ 186 | "code" 187 | ] 188 | } 189 | ], 190 | "repoType": "github", 191 | "commitConvention": "none" 192 | } 193 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/naming-convention": "warn", 13 | "@typescript-eslint/semi": "warn", 14 | "curly": "warn", 15 | "eqeqeq": "warn", 16 | "no-throw-literal": "warn", 17 | "semi": "off" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | .vscode-test 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # Typescript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # output directory for Typescript 64 | out 65 | 66 | # built vscode extension 67 | *.vsix 68 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/out/**/*.js" 17 | ], 18 | "preLaunchTask": "${defaultBuildTask}" 19 | }, 20 | { 21 | "name": "Extension Tests", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "args": [ 25 | "--extensionDevelopmentPath=${workspaceFolder}", 26 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 27 | ], 28 | "outFiles": [ 29 | "${workspaceFolder}/out/test/**/*.js" 30 | ], 31 | "preLaunchTask": "${defaultBuildTask}" 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | "typescript.tsdk": "node_modules/typescript/lib" 12 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | src/** 5 | .gitignore 6 | .yarnrc 7 | vsc-extension-quickstart.md 8 | **/tsconfig.json 9 | **/.eslintrc.json 10 | **/*.map 11 | **/*.ts 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [3.2.1] - 2020-11-07 8 | ### Fixed 9 | - Linux implementation of spotify client based on dbus is broken. #141 (kudos to TheFern2 (Fernando B) for debugging the issue). 10 | 11 | ## [3.2.0] - 2020-10-02 12 | ### Added 13 | - Seek to position in currently playing track #119 14 | - Include song title on lyrics sheet 15 | ### Fixed 16 | - Spotify in sidebar expanding on start #78 17 | - Lyrics functionality doesn't work #133 18 | 19 | ## [3.1.0] - 2018-11-20 20 | ### Added 21 | - Focus on song #56 22 | ### Fixed 23 | - Do not poll spotify if window is not focused on Mac OS (reducing CPU load). 24 | - Smaller icon size in Activity bar #58 25 | 26 | ## [3.0.4] - 2018-10-07 27 | ### Fixed 28 | - Invalid version in changelog. 29 | 30 | ## [3.0.3] - 2018-10-07 31 | ### Fixed 32 | - Invalid showLyricsButtonSignInButton, showLyricsButtonSignOutButton params. 33 | 34 | ## [3.0.2] - 2018-10-07 35 | ### Fixed 36 | - Missing option for show signIn and signOut button #54 (kudos to negebauer(Nicolás Gebauer)) 37 | 38 | ## [3.0.1] - 2018-10-07 39 | ### Fixed 40 | - Tooltip Missing Album Name in Tree Track. #52 (kudos to mikqi(Muhammad Rivki)) 41 | 42 | ## [3.0.0] - 2018-10-05 43 | ### Added 44 | - Spotify Web Api implementation to enable this extension on Windows. 45 | - Playlist/tracks custom view TreeDataProviders. 46 | ### Refactored 47 | - Refactored state to use Redux. 48 | ### Fixed 49 | - Spotilocal is not initialized. #46 50 | - Installed vscode-spotify extension but not working #43 51 | - Status Bar Icons Disappeared #42 52 | - Utilize the new Spotify Player API #27 53 | 54 | ## [2.6.1] - 2018-08-30 55 | ### Fixed 56 | - Fixed Play, Pause, PlayPause, Volume controlls on linux. ONLY on Linux(dbus) 57 | - Fixed performance issue on MacOs(to manny querys) 58 | 59 | ## [2.6.0] - 2018-08-27 60 | ### Fixed 61 | - Fix for Status Bar Icons Disappeared (#42). ONLY on Linux(dbus) 62 | 63 | ## [2.5.1] - 2018-07-23 64 | ### Fixed 65 | - Emergency fix for Status Bar Icons Disappeared (#42). ONLY on MacOS 66 | 67 | ## [2.5.0] - 2018-06-26 68 | ### Fixed 69 | - Use dbus on Linux (improved linux support) (#14) (kudos to audstanley(Richard Stanley)(https://github.com/audstanley)) 70 | 71 | ## [2.4.0] - 2018-03-25 72 | ### Fixed 73 | - Change default mac keybindings for play previous/next (#34) (kudos to ecbrodie(Evan Brodie)(https://github.com/ecbrodie)) @see the reasoning behind this at https://github.com/ShyykoSerhiy/vscode-spotify/pull/34 74 | 75 | ## [2.3.2] - 2018-02-23 76 | ### Fixed 77 | - `trackInfoPriority` setting has no effect (#30) (kudos to realbizkit(András Szepes)(https://github.com/realbizkit)) 78 | 79 | ## [2.3.1] - 2018-02-09 80 | ### Fixed 81 | - Increasing volume at max makes it go to 0 (#15) 82 | 83 | ## [2.3.0] - 2018-02-06 84 | ### Added 85 | - Show album title (via config spotify.trackInfoFormat) #28 (kudos to @mrcasals(Marc Riera) (https://github.com/mrcasals)) 86 | - Lyrics in split panel window (via spotify.openPanelLyrics) #22 (kudos to @xeBuz(Jesús Roldán) (https://github.com/xeBuz)) 87 | 88 | ## [2.2.1] - 2017-10-10 89 | ### Fixed 90 | - Remember last successfully used port (initialize speed up) #21 (kudos to @levrik(Levin Rickert) (https://github.com/levrik)) 91 | 92 | ## [2.2.0] - 2017-09-30 93 | ### Fixed 94 | - When Spotify is not open: "Failed to initialize vscode-spotify. We'll keep trying every 20 seconds." #20 95 | ### Changed 96 | - useCombinedApproachOnMacOS is now true by default. 97 | 98 | ## [2.1.1] - 2017-09-24 99 | ### Fixed 100 | - Updated info in Readme.md 101 | 102 | ## [2.1.0] - 2017-09-24 103 | ### Added 104 | - Implemented status long-polling on Windows and Linux #19 (kudos to @levrik(Levin Rickert) (https://github.com/levrik)) 105 | - Experimental applescript + http implementation of spotify client to reduce CPU usage on MacOs and improve status updating. Set spotify.useCombinedApproachOnMacOS to true to try out! (fixes https://github.com/ShyykoSerhiy/vscode-spotify/issues/12) 106 | ### Fixed 107 | - High CPU usage on MacOS #12 (via spotify.useCombinedApproachOnMacOS) 108 | 109 | ## [2.0.1] - 2017-09-23 110 | ### Fixed 111 | - Failed to initialize vscode-spotify. We'll keep trying every 20 seconds. (New VSCode Insiders / New Spotify ?) #17 112 | 113 | ## [2.0.0] - 2017-05-22 114 | ### Added 115 | - Added lyrics button(via Genius API). @see https://github.com/shyykoserhiy/vscode-spotify-lyrics 116 | - Added prefix 'vscode-spotify' for all messages from this extension. 117 | 118 | ## [1.1.0] - 2017-02-27 119 | ### Added 120 | - Added statusCheckInterval param. 121 | 122 | ## [1.0.0] - 2016-09-14 123 | ### Added 124 | - initial implementation of windows & linux support. 125 | 126 | ## [0.0.5] - 2015-12-08 127 | ### Added 128 | - added dynamic colors for buttons 129 | - added spotify.toggleRepeating and spotify.toggleShuffling commands 130 | - added buttons to status bar 131 | 132 | ### Fixed 133 | - Extension reopens Spotify if it gets closed. #4 134 | - Sometimes there is error (unexpected tocken u), that hides all the buttons. #3 135 | - Wrong stopped/playing state #2 136 | 137 | ## [0.0.2] - 2015-11-25 138 | ### Added 139 | - initial release 140 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 shyyko.serhiy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Release](https://vsmarketplacebadge.apphb.com/version-short/shyykoserhiy.vscode-spotify.svg)](https://marketplace.visualstudio.com/items?itemName=shyykoserhiy.vscode-spotify) 2 | [![Installs](https://vsmarketplacebadge.apphb.com/installs/shyykoserhiy.vscode-spotify.svg)](https://marketplace.visualstudio.com/items?itemName=shyykoserhiy.vscode-spotify) 3 | [![Rating](https://vsmarketplacebadge.apphb.com/rating-short/shyykoserhiy.vscode-spotify.svg)](https://marketplace.visualstudio.com/items?itemName=shyykoserhiy.vscode-spotify#review-details) 4 | 5 | # vscode-spotify 6 | 7 | [![All Contributors](https://img.shields.io/badge/all_contributors-15-orange.svg?style=flat)](#contributors) 8 | 9 | Use Spotify inside vscode. 10 | Provides integration with Spotify Desktop client. 11 | 12 | Note that some of the functionalities are only available on macOS systems (see [How it works section](#how-it-works)) 13 | 14 | **This extension requires Spotify Premium to work on Windows** 15 | 16 | ## How it works 17 | 18 | * On macOS, this extension uses [spotify-node-applescript](https://github.com/andrehaveman/spotify-node-applescript) (basically a wrapper for the official Spotify AppleScript API) to communicate with Spotify. 19 | * On Windows, the extension uses the Spotify Web API. 20 | * On Linux, it uses a combination of dbus and pactl. 21 | 22 | Spotify Web API implementation can be used on any platform, but it does have some drawbacks: 23 | * It doesn't work without internet connection (Linux and OS X implementations do). 24 | * Full functionality is only available to Spotify Premium users. 25 | * API calls are rate limited. 26 | 27 | At the same time it provides tighter integration and it's more or less future proofed. 28 | 29 | ## Features 30 | * Shows the currently playing song in the vscode status bar. 31 | ![status bar](media/screenshot.png) 32 | * Provides [commands](#commands) for controlling Spotify from vscode. 33 | * Provides [hotkeys](#Adding%20or%20changing%20hotkeys) of commands. 34 | * Provides [buttons](#buttons) for controlling Spotify from vscode. 35 | 36 | ## Compatibility table 37 | 38 | | Feature | macOS | Linux | Any Platform (only option on Windows) Web API | 39 | | ---------------------------- |:-------------:| :--------------------------- | :-------------------------------------------- | 40 | | Works Offline | ✅ | ✅ | ❌ | 41 | | Show Current Song | ✅ | ✅ | ✅ | 42 | | Play Next Song | ✅ | ✅ | ✅ | 43 | | Play Previous Song | ✅ | ✅ | ✅ | 44 | | Play | ✅ | ✅ | ✅ | 45 | | Pause | ✅ | ✅ | ✅ | 46 | | Play Pause | ✅ | ✅ | ✅ | 47 | | Mute Volume | ✅ | ✅ | ✅ | 48 | | Unmute Volume | ✅ | ✅ | ✅ | 49 | | Mute Unmute Volume | ✅ | ✅ | ✅ | 50 | | Volume Up | ✅ | ✅ | ✅ | 51 | | Volume Down | ✅ | ✅ | ✅ | 52 | | Toggle Repeating | ✅ | (shows repeating state) ⭕ | ✅ | 53 | | Toggle Shuffling | ✅ | (shows shuffling state) ⭕ | ✅ | 54 | | Lyrics | ✅ | ✅ | ✅ | 55 | 56 | Additional Web API features: 57 | * Playlists/tracks selection. *Make sure you have logged in with the command `>Spotify Sign In` to use these features. You can open the Virtual Studio Code command line with the hotkey `Ctrl+P` by default.* 58 | 59 | ## Contributing 60 | This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind are welcome, any contributions made will be recognised in the README. 61 | 62 | A list of contributors to this project ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 |

shyyko.serhiy

💻 🎨 📖 🤔 👀

Levin Rickert

🐛 💻

Marc Riera

💻

Evan Brodie

💻 🐛

Stéphane

📖

Ryan Gordon

📖 🤔

Richard Stanley

💻

realbizkit

💻

Jesús Roldán

💻

Nicolás Gebauer

💻

Muhammad Rivki

💻

Miguel Rodríguez Rosales

💻

Mosh Feu

📖

Adam Parkin

📖

Andrew Bastin

📖

Michael Fox

📖

Matija Mrkaic

💻

Mario

📖

Fernando B

💻
94 | 95 | 96 | 97 | 98 | 99 | 100 | ## Commands 101 | 102 | There are a number of commands available for the extension, and accessible via the command 103 | pallette. Find them by searching for "Spotify" in the command pallette: 104 | 105 | ![Commands](media/screenshot2.png) 106 | 107 | ## Adding or changing hotkeys 108 | All keyboard shortcuts in vscode can be customized via the `User/keybindings.json` file. 109 | 110 | To configure keyboard shortcuts the way you want, go to the menu under File > Preferences > Keyboard Shortcuts. 111 | This will open the Default Keyboard Shortcuts on the left and your `User/keybindings.json` file where you can overwrite the default bindings on the right. You may also see an interface to modify the shortcuts on different versions of vscode. 112 | 113 | Example : 114 | ```json 115 | { 116 | "command": "spotify.volumeDown", 117 | "key": "cmd+shift+g" 118 | } 119 | ``` 120 | 121 | For more info on hotkeys please look at https://code.visualstudio.com/docs/customization/keybindings 122 | 123 | ## Buttons 124 | This extension provides a variety of buttons to control Spotify from status bar. By default 4 buttons are enabled: 125 | 1. Previous track 126 | 2. Play / Pause 127 | 3. Next track 128 | 4. Mute / Unmute 129 | 130 | You can modify the shown buttons by changing your parameters ([go here to find out how](https://code.visualstudio.com/docs/customization/userandworkspace)): 131 | ```json 132 | "spotify.showNextButton": { 133 | "type": "boolean", 134 | "default": true, 135 | "description": "Whether to show next button." 136 | }, 137 | "spotify.showPreviousButton": { 138 | "type": "boolean", 139 | "default": true, 140 | "description": "Whether to show previous button." 141 | }, 142 | "spotify.showPlayButton": { 143 | "type": "boolean", 144 | "default": false, 145 | "description": "Whether to show play button." 146 | }, 147 | "spotify.showPauseButton": { 148 | "type": "boolean", 149 | "default": false, 150 | "description": "Whether to show pause button." 151 | }, 152 | "spotify.showPlayPauseButton": { 153 | "type": "boolean", 154 | "default": true, 155 | "description": "Whether to show play|pause button." 156 | }, 157 | "spotify.showMuteVolumeButton": { 158 | "type": "boolean", 159 | "default": false, 160 | "description": "Whether to show mute button." 161 | }, 162 | "spotify.showUnmuteVolumeButton": { 163 | "type": "boolean", 164 | "default": false, 165 | "description": "Whether to show unmute button." 166 | }, 167 | "spotify.showMuteUnmuteVolumeButton": { 168 | "type": "boolean", 169 | "default": true, 170 | "description": "Whether to show mute|unmute button." 171 | }, 172 | "spotify.showVolumeUpButton": { 173 | "type": "boolean", 174 | "default": false, 175 | "description": "Whether to show volume up button." 176 | }, 177 | "spotify.showVolumeDownButton": { 178 | "type": "boolean", 179 | "default": false, 180 | "description": "Whether to show volume down button." 181 | } 182 | "spotify.showToggleRepeatingButton": { 183 | "type": "boolean", 184 | "default": false, 185 | "description": "Whether to show toggle repeating button." 186 | }, 187 | "spotify.showToggleShufflingButton": { 188 | "type": "boolean", 189 | "default": false, 190 | "description": "Whether to show toggle shuffling button." 191 | } 192 | ``` 193 | 194 | For the full configuration options go [here](https://github.com/ShyykoSerhiy/vscode-spotify/blob/master/package.json#L161). 195 | 196 | Note that due to limitations of Spotify's Applescript API ```toggleRepeatingButton``` toggles only 197 | 'repeat all' property of spotify. There is no way to set 'repeat one' via vscode-spotify. 198 | 199 | You can also change the position of buttons by changing the parameters below: 200 | 201 | ```json 202 | "spotify.priorityBase": { 203 | "type": "number", 204 | "default": 30, 205 | "description": "Base value of priority for all vscode-spotify elements in Status Bar(priority = basePriority+priority). This is done to avoid 'conflicts' with other extensions. " 206 | }, 207 | "spotify.nextButtonPriority": { 208 | "type": "number", 209 | "default": 8, 210 | "description": "Priority of next button." 211 | }, 212 | "spotify.previousButtonPriority": { 213 | "type": "number", 214 | "default": 10, 215 | "description": "Priority of previous button." 216 | }, 217 | "spotify.playButtonPriority": { 218 | "type": "number", 219 | "default": 7, 220 | "description": "Priority of play button." 221 | }, 222 | "spotify.pauseButtonPriority": { 223 | "type": "number", 224 | "default": 6, 225 | "description": "Priority of pause button." 226 | }, 227 | "spotify.playPauseButtonPriority": { 228 | "type": "number", 229 | "default": 9, 230 | "description": "Priority of play|pause button." 231 | }, 232 | "spotify.muteButtonPriority": { 233 | "type": "number", 234 | "default": 5, 235 | "description": "Priority of mute button." 236 | }, 237 | "spotify.unmuteButtonPriority": { 238 | "type": "number", 239 | "default": 4, 240 | "description": "Priority of unmute button." 241 | }, 242 | "spotify.muteUnmuteButtonPriority": { 243 | "type": "number", 244 | "default": 3, 245 | "description": "Priority of mute|unmute button." 246 | }, 247 | "spotify.volumeUpButtonPriority": { 248 | "type": "number", 249 | "default": 2, 250 | "description": "Priority of volume up button." 251 | }, 252 | "spotify.volumeDownButtonPriority": { 253 | "type": "number", 254 | "default": 1, 255 | "description": "Priority of volume down button." 256 | }, 257 | "spotify.trackInfoPriority": { 258 | "type": "number", 259 | "default": 0, 260 | "description": "Priority of volume track info." 261 | }, 262 | "spotify.toggleRepeatingButtonPriority": { 263 | "type": "number", 264 | "default": 11, 265 | "description": "Priority of toggle repeating button." 266 | }, 267 | "spotify.toggleShufflingButtonPriority": { 268 | "type": "number", 269 | "default": 12, 270 | "description": "Priority of toggle shuffling button." 271 | } 272 | ``` 273 | 274 | ## Seeking to a specific point in a song 275 | You can use `Spotify Seek To` command (`spotify.seekTo`) to seek to the specific point in a song. You can specify hotkey that will seek to a custom time in a song by adding keybinding in keybindings.json. For example: 276 | 277 | ```json 278 | { 279 | "command": "spotify.seekTo", 280 | "key": "alt+d", 281 | "args": "1:15" 282 | } 283 | ``` 284 | 285 | [MIT LICENSE](LICENSE) 286 | -------------------------------------------------------------------------------- /media/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShyykoSerhiy/vscode-spotify/bdc1750523294c2331da47074a340558c0daae27/media/icon.png -------------------------------------------------------------------------------- /media/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShyykoSerhiy/vscode-spotify/bdc1750523294c2331da47074a340558c0daae27/media/screenshot.png -------------------------------------------------------------------------------- /media/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShyykoSerhiy/vscode-spotify/bdc1750523294c2331da47074a340558c0daae27/media/screenshot2.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vscode-spotify", 3 | "description": "Use Spotify inside vscode.", 4 | "version": "3.2.1", 5 | "publisher": "shyykoserhiy", 6 | "license": "MIT", 7 | "engines": { 8 | "vscode": "^1.49.0" 9 | }, 10 | "icon": "media/icon.png", 11 | "galleryBanner": { 12 | "color": "#11B460", 13 | "theme": "light" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/ShyykoSerhiy/vscode-spotify.git" 18 | }, 19 | "categories": [ 20 | "Other" 21 | ], 22 | "activationEvents": [ 23 | "*" 24 | ], 25 | "contributes": { 26 | "viewsContainers": { 27 | "activitybar": [ 28 | { 29 | "id": "vscode-spotify", 30 | "title": "vscode-spotify", 31 | "icon": "resources/dark/logo.svg" 32 | } 33 | ] 34 | }, 35 | "views": { 36 | "vscode-spotify": [ 37 | { 38 | "id": "vscode-spotify-playlists", 39 | "name": "Playlists" 40 | }, 41 | { 42 | "id": "vscode-spotify-albums", 43 | "name": "Albums" 44 | }, 45 | { 46 | "id": "vscode-spotify-tracks", 47 | "name": "Tracks" 48 | } 49 | ] 50 | }, 51 | "commands": [ 52 | { 53 | "command": "spotify.next", 54 | "title": "Spotify Play Next Song" 55 | }, 56 | { 57 | "command": "spotify.previous", 58 | "title": "Spotify Play Previous Song" 59 | }, 60 | { 61 | "command": "spotify.play", 62 | "title": "Spotify Play" 63 | }, 64 | { 65 | "command": "spotify.pause", 66 | "title": "Spotify Pause" 67 | }, 68 | { 69 | "command": "spotify.playPause", 70 | "title": "Spotify Play|Pause" 71 | }, 72 | { 73 | "command": "spotify.muteVolume", 74 | "title": "Spotify Mute Volume" 75 | }, 76 | { 77 | "command": "spotify.unmuteVolume", 78 | "title": "Spotify Unmute Volume" 79 | }, 80 | { 81 | "command": "spotify.muteUnmuteVolume", 82 | "title": "Spotify Mute|Unmute Volume" 83 | }, 84 | { 85 | "command": "spotify.volumeUp", 86 | "title": "Spotify Volume Up" 87 | }, 88 | { 89 | "command": "spotify.volumeDown", 90 | "title": "Spotify Volume Down" 91 | }, 92 | { 93 | "command": "spotify.toggleRepeating", 94 | "title": "Spotify Toggle Repeating" 95 | }, 96 | { 97 | "command": "spotify.toggleShuffling", 98 | "title": "Spotify Toggle Shuffling" 99 | }, 100 | { 101 | "command": "spotify.lyrics", 102 | "title": "Spotify Lyrics" 103 | }, 104 | { 105 | "command": "spotify.signIn", 106 | "title": "Spotify Sign In" 107 | }, 108 | { 109 | "command": "spotify.signOut", 110 | "title": "Spotify Sign Out" 111 | }, 112 | { 113 | "command": "spotify.loadPlaylists", 114 | "title": "Spotify Load Playlists", 115 | "icon": { 116 | "light": "resources/light/refresh.svg", 117 | "dark": "resources/dark/refresh.svg" 118 | } 119 | }, 120 | { 121 | "command": "spotify.loadAlbums", 122 | "title": "Spotify Load Albums", 123 | "icon": { 124 | "light": "resources/light/refresh.svg", 125 | "dark": "resources/dark/refresh.svg" 126 | } 127 | }, 128 | { 129 | "command": "spotify.loadTracks", 130 | "title": "Spotify Load Tracks", 131 | "icon": { 132 | "light": "resources/light/refresh.svg", 133 | "dark": "resources/dark/refresh.svg" 134 | } 135 | }, 136 | { 137 | "command": "spotify.trackInfoClick", 138 | "title": "Spotify TrackInfo Click" 139 | }, 140 | { 141 | "command": "spotify.seekTo", 142 | "title": "Spotify Seek To" 143 | } 144 | ], 145 | "keybindings": [ 146 | { 147 | "command": "spotify.next", 148 | "key": "ctrl+shift+]", 149 | "mac": "ctrl+cmd+]" 150 | }, 151 | { 152 | "command": "spotify.previous", 153 | "key": "ctrl+shift+[", 154 | "mac": "ctrl+cmd+[" 155 | }, 156 | { 157 | "command": "spotify.volumeUp", 158 | "key": "ctrl+shift+'", 159 | "mac": "cmd+shift+'" 160 | }, 161 | { 162 | "command": "spotify.volumeDown", 163 | "key": "ctrl+shift+;", 164 | "mac": "cmd+shift+;" 165 | } 166 | ], 167 | "menus": { 168 | "view/title": [ 169 | { 170 | "command": "spotify.loadPlaylists", 171 | "when": "view == vscode-spotify-playlists", 172 | "group": "navigation" 173 | }, 174 | { 175 | "command": "spotify.loadAlbums", 176 | "when": "view == vscode-spotify-albums", 177 | "group": "navigation" 178 | }, 179 | { 180 | "command": "spotify.loadTracks", 181 | "when": "view == vscode-spotify-tracks", 182 | "group": "navigation" 183 | } 184 | ] 185 | }, 186 | "configuration": { 187 | "type": "object", 188 | "title": "vscode-spotify configuration", 189 | "properties": { 190 | "spotify.trackInfoFormat": { 191 | "type": "string", 192 | "default": "artistName - trackName", 193 | "description": "Current track info that will be displayed. Available keywords: albumName, artistName, trackName" 194 | }, 195 | "spotify.trackInfoClickBehaviour": { 196 | "type": "string", 197 | "enum": [ 198 | "none", 199 | "focus_song", 200 | "play_pause" 201 | ], 202 | "default": "focus_song", 203 | "description": "What to do when trackInfo is clicked: 'none' - do nothing, 'focus_song' - current song will be selected Playlists/Tracks section if it exists there, 'play_pause' - trackInfo essentially becomes playPause button (great option for minimal ui). " 204 | }, 205 | "spotify.showNextButton": { 206 | "type": "boolean", 207 | "default": true, 208 | "description": "Whether to show next button." 209 | }, 210 | "spotify.showPreviousButton": { 211 | "type": "boolean", 212 | "default": true, 213 | "description": "Whether to show previous button." 214 | }, 215 | "spotify.showPlayButton": { 216 | "type": "boolean", 217 | "default": false, 218 | "description": "Whether to show play button." 219 | }, 220 | "spotify.showPauseButton": { 221 | "type": "boolean", 222 | "default": false, 223 | "description": "Whether to show pause button." 224 | }, 225 | "spotify.showPlayPauseButton": { 226 | "type": "boolean", 227 | "default": true, 228 | "description": "Whether to show play|pause button." 229 | }, 230 | "spotify.showMuteVolumeButton": { 231 | "type": "boolean", 232 | "default": false, 233 | "description": "Whether to show mute button." 234 | }, 235 | "spotify.showUnmuteVolumeButton": { 236 | "type": "boolean", 237 | "default": false, 238 | "description": "Whether to show unmute button." 239 | }, 240 | "spotify.showMuteUnmuteVolumeButton": { 241 | "type": "boolean", 242 | "default": true, 243 | "description": "Whether to show mute|unmute button." 244 | }, 245 | "spotify.showVolumeUpButton": { 246 | "type": "boolean", 247 | "default": false, 248 | "description": "Whether to show volume up button." 249 | }, 250 | "spotify.showVolumeDownButton": { 251 | "type": "boolean", 252 | "default": false, 253 | "description": "Whether to show volume down button." 254 | }, 255 | "spotify.showToggleRepeatingButton": { 256 | "type": "boolean", 257 | "default": false, 258 | "description": "Whether to show toggle repeating button." 259 | }, 260 | "spotify.showToggleShufflingButton": { 261 | "type": "boolean", 262 | "default": false, 263 | "description": "Whether to show toggle shuffling button." 264 | }, 265 | "spotify.showLyricsButton": { 266 | "type": "boolean", 267 | "default": true, 268 | "description": "Whether to show lyrics button." 269 | }, 270 | "spotify.showSignInButton": { 271 | "type": "boolean", 272 | "default": true, 273 | "description": "Whether to show sign in button." 274 | }, 275 | "spotify.showSignOutButton": { 276 | "type": "boolean", 277 | "default": true, 278 | "description": "Whether to show sign out button." 279 | }, 280 | "spotify.openPanelLyrics": { 281 | "type": "number", 282 | "default": 1, 283 | "enum": [ 284 | 1, 285 | 2, 286 | 3 287 | ], 288 | "description": "Panel to display the Lyrics" 289 | }, 290 | "spotify.priorityBase": { 291 | "type": "number", 292 | "default": 30, 293 | "description": "Base value of priority for all vscode-spotify elements in Status Bar(priority = basePriority+priority). This is done to avoid 'conflicts' with other extensions. " 294 | }, 295 | "spotify.nextButtonPriority": { 296 | "type": "number", 297 | "default": 8, 298 | "description": "Priority of next button." 299 | }, 300 | "spotify.previousButtonPriority": { 301 | "type": "number", 302 | "default": 10, 303 | "description": "Priority of previous button." 304 | }, 305 | "spotify.playButtonPriority": { 306 | "type": "number", 307 | "default": 7, 308 | "description": "Priority of play button." 309 | }, 310 | "spotify.pauseButtonPriority": { 311 | "type": "number", 312 | "default": 6, 313 | "description": "Priority of pause button." 314 | }, 315 | "spotify.playPauseButtonPriority": { 316 | "type": "number", 317 | "default": 9, 318 | "description": "Priority of play|pause button." 319 | }, 320 | "spotify.muteButtonPriority": { 321 | "type": "number", 322 | "default": 5, 323 | "description": "Priority of mute button." 324 | }, 325 | "spotify.unmuteButtonPriority": { 326 | "type": "number", 327 | "default": 4, 328 | "description": "Priority of unmute button." 329 | }, 330 | "spotify.muteUnmuteButtonPriority": { 331 | "type": "number", 332 | "default": 3, 333 | "description": "Priority of mute|unmute button." 334 | }, 335 | "spotify.volumeUpButtonPriority": { 336 | "type": "number", 337 | "default": 2, 338 | "description": "Priority of volume up button." 339 | }, 340 | "spotify.volumeDownButtonPriority": { 341 | "type": "number", 342 | "default": 1, 343 | "description": "Priority of volume down button." 344 | }, 345 | "spotify.trackInfoPriority": { 346 | "type": "number", 347 | "default": 0, 348 | "description": "Priority of track info." 349 | }, 350 | "spotify.toggleRepeatingButtonPriority": { 351 | "type": "number", 352 | "default": 11, 353 | "description": "Priority of toggle repeating button." 354 | }, 355 | "spotify.toggleShufflingButtonPriority": { 356 | "type": "number", 357 | "default": 12, 358 | "description": "Priority of toggle shuffling button." 359 | }, 360 | "spotify.lyricsButtonPriority": { 361 | "type": "number", 362 | "default": 0, 363 | "description": "Priority of lyrics button." 364 | }, 365 | "spotify.signInButtonPriority": { 366 | "type": "number", 367 | "default": 15, 368 | "description": "Priority of sign in button." 369 | }, 370 | "spotify.signOutButtonPriority": { 371 | "type": "number", 372 | "default": 15, 373 | "description": "Priority of sign out button." 374 | }, 375 | "spotify.statusCheckInterval": { 376 | "type": "number", 377 | "default": 5000, 378 | "description": "Interval of spotify status checks. Frequent status checks may result in faster battery drain and high CPU load (especially on MacOs). Note that with web api implementation 5000 is mimimal possible value." 379 | }, 380 | "spotify.lyricsServerUrl": { 381 | "type": "string", 382 | "default": "https://vscode-spotify-lyrics.azurewebsites.net/v3/songs", 383 | "description": "Url for lyrics server. @see https://github.com/ShyykoSerhiy/vscode-spotify-lyrics ." 384 | }, 385 | "spotify.authServerUrl": { 386 | "type": "string", 387 | "default": "https://vscode-spotify-auth.azurewebsites.net", 388 | "description": "Url to auth server. @see https://github.com/ShyykoSerhiy/vscode-spotify-auth" 389 | }, 390 | "spotify.spotifyApiUrl": { 391 | "type": "string", 392 | "default": "https://api.spotify.com/v1", 393 | "description": "Url to spotify api server. @see https://beta.developer.spotify.com/documentation/web-api/reference/playlists/" 394 | }, 395 | "spotify.forceWebApiImplementation": { 396 | "type": "boolean", 397 | "default": false, 398 | "description": "Whether to force web api implementation on Linux or MacOs. If you you want this enabled, please, consider using your own spotify.authServerUrl ." 399 | }, 400 | "spotify.enableLogs": { 401 | "type": "boolean", 402 | "default": false, 403 | "description": "Whether to enable logs." 404 | } 405 | } 406 | } 407 | }, 408 | "main": "./out/extension.js", 409 | "scripts": { 410 | "vscode:prepublish": "npm run compile", 411 | "compile": "tsc -p ./", 412 | "lint": "eslint src --ext ts", 413 | "watch": "tsc -watch -p ./", 414 | "pretest": "npm run compile && npm run lint", 415 | "madge:circular": "npx madge --circular --extensions ts ./src", 416 | "contributors:add": "all-contributors add", 417 | "contributors:generate": "all-contributors generate", 418 | "contributors:check": "all-contributors check" 419 | }, 420 | "devDependencies": { 421 | "@types/cheerio": "^0.22.22", 422 | "@types/express": "^4.11.1", 423 | "@types/mocha": "^8.0.0", 424 | "@types/node": "^14.0.27", 425 | "@types/superagent": "^2.0.36", 426 | "@types/vscode": "^1.49.0", 427 | "@typescript-eslint/eslint-plugin": "^4.1.1", 428 | "@typescript-eslint/parser": "^4.1.1", 429 | "all-contributors-cli": "^6.17.4", 430 | "eslint": "^7.9.0", 431 | "typescript": "^4.0.2", 432 | "vscode-test": "^1.4.0" 433 | }, 434 | "dependencies": { 435 | "@vscodespotify/spotify-common": "1.4.1", 436 | "autobind-decorator": "^2.1.0", 437 | "cheerio": "^1.0.0-rc.3", 438 | "child_process": "^1.0.2", 439 | "express": "^4.16.2", 440 | "immutable": "^3.8.2", 441 | "moment": "^2.22.2", 442 | "redux": "^3.7.2", 443 | "redux-persist": "^5.10.0", 444 | "request-light": "^0.2.1", 445 | "spotify-node-applescript": "1.1.1" 446 | } 447 | } 448 | -------------------------------------------------------------------------------- /resources/dark/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /resources/dark/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/light/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/actions/actions.ts: -------------------------------------------------------------------------------- 1 | import { Api, getApi } from '@vscodespotify/spotify-common/lib/spotify/api'; 2 | import { Album, Playlist, Track } from '@vscodespotify/spotify-common/lib/spotify/consts'; 3 | import autobind from 'autobind-decorator'; 4 | import { commands, Uri, window } from 'vscode'; 5 | 6 | import { createDisposableAuthSever } from '../auth/server/local'; 7 | import { getAuthServerUrl } from '../config/spotify-config'; 8 | import { SIGN_IN_COMMAND } from '../consts/consts'; 9 | import { log, showInformationMessage, showWarningMessage, showErrorMessage } from '../info/info'; 10 | import { isAlbum } from '../isAlbum'; 11 | import { DUMMY_PLAYLIST, ILoginState, ISpotifyStatusState } from '../state/state'; 12 | import { getState, getStore } from '../store/store'; 13 | import { artistsToArtist } from '../utils/utils'; 14 | import { 15 | UpdateStateAction, 16 | UPDATE_STATE_ACTION, 17 | PlaylistsLoadAction, 18 | PLAYLISTS_LOAD_ACTION, 19 | AlbumLoadAction, 20 | ALBUM_LOAD_ACTION, 21 | SelectPlaylistAction, 22 | SELECT_PLAYLIST_ACTION, 23 | SelectAlbumAction, 24 | SELECT_ALBUM_ACTION, 25 | SelectTrackAction, 26 | SELECT_TRACK_ACTION, 27 | TracksLoadAction, 28 | TRACKS_LOAD_ACTION, 29 | SignInAction, 30 | SIGN_IN_ACTION, 31 | SignOutAction, 32 | SIGN_OUT_ACTION 33 | } from './common'; 34 | 35 | export function withApi() { 36 | return (_target: any, _key: any, descriptor: PropertyDescriptor) => { 37 | const originalMethod = descriptor.value; 38 | 39 | descriptor.value = function(...args: any[]) { 40 | const api = getSpotifyWebApi(); 41 | if (api) { 42 | return originalMethod.apply(this, [...args, api]); 43 | } else { 44 | (async () => { 45 | const signIn = 'Sign in'; 46 | const result = await showWarningMessage('You should be logged in order to use this feature.', signIn); 47 | if (result === signIn) { 48 | commands.executeCommand(SIGN_IN_COMMAND); 49 | } 50 | })(); 51 | } 52 | }; 53 | 54 | return descriptor; 55 | }; 56 | } 57 | 58 | export function withErrorAsync() { 59 | return (_target: any, _key: any, descriptor: PropertyDescriptor) => { 60 | const originalMethod = descriptor.value; 61 | 62 | descriptor.value = async function (...args: any[]) { 63 | try { 64 | return await originalMethod.apply(this, args); 65 | } catch (e) { 66 | showWarningMessage('Failed to perform operation ' + e.message || e); 67 | } 68 | }; 69 | 70 | return descriptor; 71 | }; 72 | } 73 | 74 | function actionCreator() { 75 | return (_target: any, _key: any, descriptor: PropertyDescriptor) => { 76 | const originalMethod = descriptor.value; 77 | 78 | descriptor.value = function (...args: any[]) { 79 | const action = originalMethod.apply(this, args); 80 | if (!action) { 81 | return; 82 | } 83 | getStore().dispatch(action); 84 | }; 85 | 86 | return descriptor; 87 | }; 88 | } 89 | 90 | function asyncActionCreator() { 91 | return (_target: any, _key: any, descriptor: PropertyDescriptor) => { 92 | const originalMethod = descriptor.value; 93 | 94 | descriptor.value = async function(...args: any[]) { 95 | let action; 96 | try { 97 | action = await originalMethod.apply(this, args); 98 | if (!action) { 99 | return; 100 | } 101 | } catch (e) { 102 | showWarningMessage('Failed to perform operation ' + e.message || e); 103 | } 104 | getStore().dispatch(action); 105 | }; 106 | 107 | return descriptor; 108 | }; 109 | } 110 | 111 | const apiMap = new WeakMap(); 112 | export const getSpotifyWebApi = () => { 113 | const { loginState } = getState(); 114 | if (!loginState) { 115 | log('getSpotifyWebApi', 'NOT LOGGED IN'); 116 | return null; 117 | } 118 | if (!window.state.focused) { 119 | log('getSpotifyWebApi', 'NOT FOCUSED'); 120 | return null; 121 | } 122 | let api = apiMap.get(loginState); 123 | if (!api) { 124 | api = getApi(getAuthServerUrl(), loginState.accessToken, loginState.refreshToken, (token: string) => { 125 | actionsCreator._actionSignIn(token, loginState.refreshToken); 126 | }); 127 | apiMap.set(loginState, api); 128 | } 129 | return api; 130 | }; 131 | 132 | class ActionCreator { 133 | @autobind 134 | @actionCreator() 135 | updateStateAction(state: Partial): UpdateStateAction { 136 | return { 137 | type: UPDATE_STATE_ACTION, 138 | state 139 | }; 140 | } 141 | 142 | @autobind 143 | @asyncActionCreator() 144 | @withApi() 145 | async loadPlaylists(api?: Api): Promise { 146 | const playlists = await api!.playlists.getAll(); 147 | return { 148 | type: PLAYLISTS_LOAD_ACTION, 149 | playlists 150 | }; 151 | } 152 | 153 | @autobind 154 | @asyncActionCreator() 155 | @withApi() 156 | async loadAlbums(api?: Api): Promise { 157 | const albums = await api!.albums.getAll(); 158 | return { 159 | type: ALBUM_LOAD_ACTION, 160 | albums 161 | }; 162 | } 163 | 164 | 165 | 166 | @autobind 167 | @actionCreator() 168 | selectPlaylistAction(p: Playlist): SelectPlaylistAction { 169 | return { 170 | type: SELECT_PLAYLIST_ACTION, 171 | playlist: p 172 | }; 173 | } 174 | 175 | @autobind 176 | @actionCreator() 177 | selectAlbumAction(album: Album): SelectAlbumAction { 178 | return { 179 | type: SELECT_ALBUM_ACTION, 180 | album 181 | }; 182 | } 183 | 184 | @autobind 185 | @actionCreator() 186 | selectTrackAction(track: Track): SelectTrackAction { 187 | return { 188 | type: SELECT_TRACK_ACTION, 189 | track 190 | }; 191 | } 192 | 193 | @autobind 194 | selectCurrentTrack() { 195 | const state = getState(); 196 | if (state.playerState && state.track) { 197 | let track: Track; 198 | const currentTrack = state.track; 199 | const playlist = state.playlists.find(p => { 200 | const tracks = state.tracks.get(p.id); 201 | if (tracks) { 202 | const foundTrack = tracks.find(t => t.track.name === currentTrack.name 203 | && t.track.album.name === currentTrack.album 204 | && artistsToArtist(t.track.artists) === currentTrack.artist); 205 | 206 | if (foundTrack) { 207 | track = foundTrack; 208 | return true; 209 | } 210 | } 211 | return false; 212 | }); 213 | 214 | if (playlist) { 215 | this.selectPlaylistAction(playlist); 216 | this.selectTrackAction(track!); 217 | } 218 | } 219 | } 220 | 221 | @autobind 222 | loadTracksForSelectedPlaylist(): void { 223 | this.loadTracks(getState().selectedList); 224 | } 225 | 226 | @autobind 227 | loadTracksIfNotLoaded(list: Playlist | Album): void { 228 | if (!list) { 229 | return void 0; 230 | } 231 | const { tracks } = getState(); 232 | if (!tracks.has(isAlbum(list) ? list.album.id : list.id)) { 233 | this.loadTracks(list); 234 | } 235 | } 236 | 237 | @autobind 238 | @asyncActionCreator() 239 | @withApi() 240 | async loadTracks(list?: Playlist | Album, api?: Api): Promise { 241 | if (isAlbum(list)) { 242 | const tracks = await api!.albums.tracks.getAll(list); 243 | return { 244 | type: TRACKS_LOAD_ACTION, 245 | list, 246 | tracks 247 | }; 248 | 249 | } 250 | 251 | if (!list || list.id === DUMMY_PLAYLIST.id) { 252 | return void 0; 253 | } 254 | const tracks = await api!.playlists.tracks.getAll(list); 255 | return { 256 | type: TRACKS_LOAD_ACTION, 257 | list, 258 | tracks 259 | }; 260 | } 261 | 262 | @autobind 263 | @withErrorAsync() 264 | @withApi() 265 | async playTrack(offset: number, list: Playlist | Album, api?: Api): Promise { 266 | await api!.player.play.put({ 267 | offset, 268 | albumUri: isAlbum(list) ? list.album.uri : list.uri 269 | }); 270 | return; 271 | } 272 | 273 | @autobind 274 | @withErrorAsync() 275 | @withApi() 276 | async seekTo(time: string, api?: Api): Promise { 277 | const timeS = time || await window.showInputBox({ prompt: 'Select time to seek to in format mm:ss or ss' }); 278 | if (!timeS) { 279 | return; 280 | } 281 | const invalidTimeFormatError = 'Invalid time format. Should be in format mm:ss or ss'; 282 | const timeA = timeS.split(':'); 283 | if (timeA.length > 2) { 284 | showErrorMessage(invalidTimeFormatError); 285 | return; 286 | } 287 | let minutes = 0; 288 | let seconds = 0; 289 | if (timeA[1]){ 290 | minutes = parseFloat(timeA[0]); 291 | seconds = parseFloat(timeA[1]); 292 | } else { 293 | seconds = parseFloat(timeA[0]); 294 | } 295 | 296 | if (Number.isNaN(seconds) || Number.isNaN(minutes)){ 297 | showErrorMessage(invalidTimeFormatError); 298 | return; 299 | } 300 | 301 | const seekTo = Math.round((minutes * 60 + seconds) * 1000); 302 | 303 | await api!.player.seek.put(seekTo); 304 | } 305 | 306 | @autobind 307 | actionSignIn() { 308 | commands.executeCommand('vscode.open', Uri.parse(`${getAuthServerUrl()}/login`)).then(() => { 309 | const { createServerPromise, dispose } = createDisposableAuthSever(); 310 | createServerPromise.then(({ accessToken, refreshToken }) => { 311 | this._actionSignIn(accessToken, refreshToken); 312 | }).catch(e => { 313 | showInformationMessage(`Failed to retrieve access token : ${JSON.stringify(e)}`); 314 | }).then(() => { 315 | dispose(); 316 | }); 317 | }); 318 | } 319 | 320 | @autobind 321 | @actionCreator() 322 | _actionSignIn(accessToken: string, refreshToken: string): SignInAction { 323 | return { 324 | accessToken, 325 | refreshToken, 326 | type: SIGN_IN_ACTION 327 | }; 328 | } 329 | 330 | @autobind 331 | @actionCreator() 332 | actionSignOut(): SignOutAction { 333 | return { 334 | type: SIGN_OUT_ACTION 335 | }; 336 | } 337 | } 338 | 339 | export const actionsCreator = new ActionCreator(); 340 | -------------------------------------------------------------------------------- /src/actions/common.ts: -------------------------------------------------------------------------------- 1 | import { Album, Playlist, Track } from '@vscodespotify/spotify-common/lib/spotify/consts'; 2 | import { ISpotifyStatusState } from '../state/state'; 3 | 4 | export const UPDATE_STATE_ACTION = 'UPDATE_STATE_ACTION' as 'UPDATE_STATE_ACTION'; 5 | export const SIGN_IN_ACTION = 'SIGN_IN_ACTION' as 'SIGN_IN_ACTION'; 6 | export const SIGN_OUT_ACTION = 'SIGN_OUT_ACTION' as 'SIGN_OUT_ACTION'; 7 | export const PLAYLISTS_LOAD_ACTION = 'PLAYLISTS_LOAD_ACTION' as 'PLAYLISTS_LOAD_ACTION'; 8 | export const ALBUM_LOAD_ACTION = 'ALBUM_LOAD_ACTION' as const; 9 | export const SELECT_PLAYLIST_ACTION = 'SELECT_PLAYLIST_ACTION' as 'SELECT_PLAYLIST_ACTION'; 10 | export const SELECT_ALBUM_ACTION = 'SELECT_ALBUM_ACTION' as const; 11 | export const TRACKS_LOAD_ACTION = 'TRACKS_LOAD_ACTION' as 'TRACKS_LOAD_ACTION'; 12 | export const SELECT_TRACK_ACTION = 'SELECT_TRACK_ACTION' as 'SELECT_TRACK_ACTION'; 13 | 14 | export interface UpdateStateAction { 15 | type: typeof UPDATE_STATE_ACTION; 16 | state: Partial; 17 | } 18 | 19 | export interface SignInAction { 20 | type: typeof SIGN_IN_ACTION; 21 | accessToken: string; 22 | refreshToken: string; 23 | } 24 | 25 | export interface SignOutAction { 26 | type: typeof SIGN_OUT_ACTION; 27 | } 28 | 29 | export interface PlaylistsLoadAction { 30 | type: typeof PLAYLISTS_LOAD_ACTION; 31 | playlists: Playlist[]; 32 | } 33 | 34 | export interface AlbumLoadAction { 35 | type: typeof ALBUM_LOAD_ACTION; 36 | albums: Album[]; 37 | } 38 | 39 | export interface TracksLoadAction { 40 | type: typeof TRACKS_LOAD_ACTION; 41 | list: Playlist | Album; 42 | tracks: Track[]; 43 | } 44 | 45 | export interface SelectPlaylistAction { 46 | type: typeof SELECT_PLAYLIST_ACTION; 47 | playlist: Playlist; 48 | } 49 | 50 | export interface SelectAlbumAction { 51 | type: typeof SELECT_ALBUM_ACTION; 52 | album: Album; 53 | } 54 | 55 | export interface SelectTrackAction { 56 | type: typeof SELECT_TRACK_ACTION; 57 | track: Track; 58 | } 59 | 60 | export type Action = UpdateStateAction | 61 | SignInAction | 62 | SignOutAction | 63 | PlaylistsLoadAction | 64 | AlbumLoadAction | 65 | SelectPlaylistAction | 66 | SelectAlbumAction | 67 | TracksLoadAction | 68 | SelectTrackAction; 69 | -------------------------------------------------------------------------------- /src/auth/server/local.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import { Server } from 'http'; 3 | 4 | import { getAuthServerUrl } from '../../config/spotify-config'; 5 | import { log } from '../../info/info'; 6 | 7 | export interface CreateDisposableAuthSeverPromiseResult { 8 | accessToken: string; 9 | refreshToken: string; 10 | } 11 | 12 | export function createDisposableAuthSever() { 13 | let server: Server; 14 | const createServerPromise = new Promise((res, rej) => { 15 | setTimeout(() => { 16 | rej('Timeout error. No response for 10 minutes.'); 17 | }, 10 * 60 * 1000 /*10 minutes*/); 18 | try { 19 | const app = express(); 20 | 21 | app.get('/callback', (request, response) => { 22 | const { access_token: accessToken, refresh_token: refreshToken, error } = request.query; 23 | if (!error) { 24 | res({ accessToken, refreshToken }); 25 | } else { 26 | rej(error); 27 | } 28 | response.redirect(`${getAuthServerUrl()}/?message=${encodeURIComponent('You can now close this tab')}`); 29 | request.destroy(); 30 | }); 31 | 32 | server = app.listen(8350); 33 | } catch (e) { 34 | rej(e); 35 | } 36 | }); 37 | 38 | return { 39 | createServerPromise, 40 | dispose: () => server && server.close(() => { 41 | log('server closed'); 42 | }) 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import { commands, Disposable } from 'vscode'; 2 | 3 | import { actionsCreator } from './actions/actions'; 4 | import { getTrackInfoClickBehaviour } from './config/spotify-config'; 5 | import { LyricsController } from './lyrics/lyrics'; 6 | import { SpotifyClient } from './spotify/common'; 7 | import { Album, Playlist } from './state/state'; 8 | import { SIGN_IN_COMMAND } from './consts/consts'; 9 | 10 | export function createCommands(sC: SpotifyClient): { dispose: () => void } { 11 | const lC = new LyricsController(); 12 | 13 | const lyrics = commands.registerCommand('spotify.lyrics', lC.findLyrics.bind(lC)); 14 | const next = commands.registerCommand('spotify.next', sC.next.bind(sC)); 15 | const previous = commands.registerCommand('spotify.previous', sC.previous.bind(sC)); 16 | const play = commands.registerCommand('spotify.play', sC.play.bind(sC)); 17 | const pause = commands.registerCommand('spotify.pause', sC.pause.bind(sC)); 18 | const playPause = commands.registerCommand('spotify.playPause', sC.playPause.bind(sC)); 19 | const muteVolume = commands.registerCommand('spotify.muteVolume', sC.muteVolume.bind(sC)); 20 | const unmuteVolume = commands.registerCommand('spotify.unmuteVolume', sC.unmuteVolume.bind(sC)); 21 | const muteUnmuteVolume = commands.registerCommand('spotify.muteUnmuteVolume', sC.muteUnmuteVolume.bind(sC)); 22 | const volumeUp = commands.registerCommand('spotify.volumeUp', sC.volumeUp.bind(sC)); 23 | const volumeDown = commands.registerCommand('spotify.volumeDown', sC.volumeDown.bind(sC)); 24 | const toggleRepeating = commands.registerCommand('spotify.toggleRepeating', sC.toggleRepeating.bind(sC)); 25 | const toggleShuffling = commands.registerCommand('spotify.toggleShuffling', sC.toggleShuffling.bind(sC)); 26 | const signIn = commands.registerCommand(SIGN_IN_COMMAND, actionsCreator.actionSignIn); 27 | const signOut = commands.registerCommand('spotify.signOut', actionsCreator.actionSignOut); 28 | const loadPlaylists = commands.registerCommand('spotify.loadPlaylists', actionsCreator.loadPlaylists); 29 | const loadAlbums = commands.registerCommand('spotify.loadAlbums', actionsCreator.loadAlbums); 30 | const loadTracks = commands.registerCommand('spotify.loadTracks', actionsCreator.loadTracksForSelectedPlaylist); 31 | const trackInfoClick = commands.registerCommand('spotify.trackInfoClick', () => { 32 | const trackInfoClickBehaviour = getTrackInfoClickBehaviour(); 33 | if (trackInfoClickBehaviour === 'focus_song') { 34 | actionsCreator.selectCurrentTrack(); 35 | } else if (trackInfoClickBehaviour === 'play_pause') { 36 | sC.playPause(); 37 | } 38 | }); 39 | const playTrack = commands.registerCommand('spotify.playTrack', async (/*arguments from TrackTreeItem event*/offset: number, list: Playlist | Album) => { 40 | await actionsCreator.playTrack(offset, list); 41 | sC.queryStatusFunc(); 42 | }); 43 | const seekTo = commands.registerCommand('spotify.seekTo', (seekToMs: string) => { 44 | actionsCreator.seekTo(seekToMs); 45 | }); 46 | 47 | return Disposable.from(lyrics, 48 | next, 49 | previous, 50 | play, 51 | pause, 52 | playPause, 53 | muteVolume, 54 | unmuteVolume, 55 | muteUnmuteVolume, 56 | volumeUp, 57 | volumeDown, 58 | toggleRepeating, 59 | toggleShuffling, 60 | signIn, 61 | signOut, 62 | loadPlaylists, 63 | loadAlbums, 64 | loadTracks, 65 | trackInfoClick, 66 | playTrack, 67 | seekTo, 68 | lC.registration 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/spotify-controls.ts: -------------------------------------------------------------------------------- 1 | import { extensions, StatusBarAlignment, StatusBarItem, window } from 'vscode'; 2 | 3 | import { getButtonPriority, isButtonToBeShown } from '../config/spotify-config'; 4 | import { BUTTON_ID_SIGN_IN, BUTTON_ID_SIGN_OUT } from '../consts/consts'; 5 | 6 | export interface Button { 7 | /** 8 | * Id of button 9 | */ 10 | id: string; 11 | /** 12 | * Text of button(Octicons) 13 | */ 14 | text: string; 15 | /** 16 | * Generator of text for button(Octicons) 17 | */ 18 | dynamicText?: (cond: boolean) => string; 19 | /** 20 | * Generator of color for button 21 | */ 22 | dynamicColor?: (cond: boolean) => string; 23 | /** 24 | * Name of a button 25 | */ 26 | buttonName: string; 27 | /** 28 | * Command that is executed when button is pressed 29 | */ 30 | buttonCommand: string; 31 | /** 32 | * Priority of button (higher means closer to left side) 33 | */ 34 | buttonPriority: number; 35 | /** 36 | * True if button is enabled in settings 37 | */ 38 | visible: boolean; 39 | /** 40 | * vscode status bar item 41 | */ 42 | statusBarItem: StatusBarItem; 43 | } 44 | 45 | export interface ButtonWithDynamicText extends Button { 46 | /** 47 | * Generator of text for button(Octicons) 48 | */ 49 | dynamicText: (cond: boolean) => string; 50 | } 51 | 52 | export interface ButtonWithDynamicColor extends Button { 53 | /** 54 | * Generator of color for button 55 | */ 56 | dynamicColor: (cond: boolean) => string; 57 | } 58 | 59 | export class SpotifyControls { 60 | get buttons(): Button[] { 61 | return this._buttons; 62 | } 63 | 64 | /** 65 | * All buttons of vscode-spotify 66 | */ 67 | private _buttons: Button[]; 68 | private _playPauseButton!: ButtonWithDynamicText; 69 | private _muteUnmuteVolumeButton!: ButtonWithDynamicText; 70 | private _toggleRepeatingButton!: ButtonWithDynamicColor; 71 | private _toggleShufflingButton!: ButtonWithDynamicColor; 72 | private _signInButton!: Button; 73 | private _signOutButton!: Button; 74 | 75 | constructor() { 76 | const buttonsInfo = [ 77 | { id: 'next', text: '$(chevron-right)' }, 78 | { id: 'previous', text: '$(chevron-left)' }, 79 | { id: 'play', text: '$(triangle-right)' }, 80 | { id: 'pause', text: '$(primitive-square)' }, 81 | { 82 | id: 'playPause', 83 | text: '$(triangle-right)', 84 | dynamicText: (isPlaying?: boolean) => isPlaying ? '$(primitive-square)' : '$(triangle-right)' 85 | }, 86 | { id: 'muteVolume', text: '$(mute)' }, 87 | { id: 'unmuteVolume', text: '$(unmute)' }, 88 | { id: 'muteUnmuteVolume', text: '$(mute)', dynamicText: (isMuted?: boolean) => isMuted ? '$(mute)' : '$(unmute)' }, 89 | { id: 'volumeUp', text: '$(arrow-small-up)' }, 90 | { id: 'volumeDown', text: '$(arrow-small-down)' }, 91 | { id: 'toggleRepeating', text: '$(sync)', dynamicColor: (isRepeating?: boolean) => isRepeating ? 'white' : 'darkgrey' }, 92 | { id: 'toggleShuffling', text: '$(git-branch)', dynamicColor: (isShuffling?: boolean) => isShuffling ? 'white' : 'darkgrey' }, 93 | { id: 'lyrics', text: '$(book)' }, 94 | { id: BUTTON_ID_SIGN_IN, text: '$(sign-in)' }, 95 | { id: BUTTON_ID_SIGN_OUT, text: '$(sign-out)' } 96 | ]; 97 | const extension = extensions.getExtension('shyykoserhiy.vscode-spotify'); 98 | if (!extension) { 99 | this._buttons = []; 100 | return; 101 | } 102 | const commands: { command: string, title: string }[] = extension.packageJSON.contributes.commands; 103 | this._buttons = buttonsInfo.map(item => { 104 | const buttonName = item.id + 'Button'; 105 | const buttonCommand = 'spotify.' + item.id; 106 | const buttonPriority = getButtonPriority(buttonName); 107 | const visible = isButtonToBeShown(buttonName); 108 | const statusBarItem = window.createStatusBarItem(StatusBarAlignment.Left, buttonPriority); 109 | const {title} = commands.filter(command => command.command === buttonCommand)[0] || { title: '' }; 110 | statusBarItem.text = item.text; 111 | statusBarItem.command = buttonCommand; 112 | statusBarItem.tooltip = title; 113 | 114 | return Object.assign({}, item, { buttonName, buttonCommand, buttonPriority, statusBarItem, visible }); 115 | }); 116 | this._buttons.forEach(button => { 117 | if (button.id === 'playPause') { 118 | this._playPauseButton = button as ButtonWithDynamicText; 119 | return; 120 | } 121 | if (button.id === 'muteUnmuteVolume') { 122 | this._muteUnmuteVolumeButton = button as ButtonWithDynamicText; 123 | return; 124 | } 125 | if (button.id === 'toggleRepeating') { 126 | this._toggleRepeatingButton = button as ButtonWithDynamicColor; 127 | return; 128 | } 129 | if (button.id === 'toggleShuffling') { 130 | this._toggleShufflingButton = button as ButtonWithDynamicColor; 131 | return; 132 | } 133 | if (button.id === BUTTON_ID_SIGN_IN) { 134 | this._signInButton = button; 135 | return; 136 | } 137 | if (button.id === BUTTON_ID_SIGN_OUT) { 138 | this._signOutButton = button; 139 | } 140 | }); 141 | } 142 | 143 | /** 144 | * Updates dynamicText buttons 145 | */ 146 | updateDynamicButtons(playing: boolean, muted: boolean, repeating: boolean, shuffling: boolean): boolean { 147 | let changed = false; 148 | changed = this._updateText(this._playPauseButton, playing) || changed; 149 | changed = this._updateText(this._muteUnmuteVolumeButton, muted) || changed; 150 | changed = this._updateColor(this._toggleRepeatingButton, repeating) || changed; 151 | changed = this._updateColor(this._toggleShufflingButton, shuffling) || changed; 152 | return changed; 153 | } 154 | 155 | showHideAuthButtons() { 156 | this._hideShowButton(this._signInButton); 157 | this._hideShowButton(this._signOutButton); 158 | } 159 | 160 | /** 161 | * Show buttons that are visible 162 | */ 163 | showVisible() { 164 | this.buttons.forEach(button => button.visible && button.statusBarItem.show()); 165 | } 166 | 167 | /** 168 | * Hides all the buttons except auth buttons 169 | */ 170 | hideAll() { 171 | this.buttons.forEach(button => { 172 | if (button === this._signInButton || button === this._signOutButton) { 173 | return; 174 | } 175 | button.statusBarItem.hide(); 176 | }); 177 | } 178 | 179 | /** 180 | * Disposes all the buttons 181 | */ 182 | dispose() { 183 | this.buttons.forEach(button => { button.statusBarItem.dispose(); }); 184 | } 185 | private _hideShowButton(button: Button) { 186 | button.visible = isButtonToBeShown(button.buttonName); 187 | button.visible ? button.statusBarItem.show() : button.statusBarItem.hide(); 188 | } 189 | 190 | private _updateText(button: ButtonWithDynamicText, condition: boolean): boolean { 191 | if (!isButtonToBeShown(button.buttonName)) { 192 | return false; 193 | } 194 | const dynamicText = button.dynamicText(condition); 195 | if (dynamicText !== button.statusBarItem.text) { 196 | button.statusBarItem.text = dynamicText; 197 | return true; 198 | } 199 | return false; 200 | } 201 | 202 | private _updateColor(button: ButtonWithDynamicColor, condition: boolean): boolean { 203 | if (!isButtonToBeShown(button.buttonName)) { 204 | return false; 205 | } 206 | const dynamicColor = button.dynamicColor(condition); 207 | if (dynamicColor !== button.statusBarItem.color) { 208 | button.statusBarItem.color = dynamicColor; 209 | return true; 210 | } 211 | return false; 212 | } 213 | } -------------------------------------------------------------------------------- /src/components/spotify-status.ts: -------------------------------------------------------------------------------- 1 | import { StatusBarAlignment, StatusBarItem, window } from 'vscode'; 2 | 3 | import { getButtonPriority, getTrackInfoClickBehaviour, getTrackInfoFormat } from '../config/spotify-config'; 4 | import { ILoginState, ITrack } from '../state/state'; 5 | import { getState, getStore } from '../store/store'; 6 | 7 | import { SpotifyControls } from './spotify-controls'; 8 | 9 | export class SpotifyStatus { 10 | /** 11 | * Status bar with info from spotify 12 | */ 13 | private _statusBarItem: StatusBarItem | undefined; 14 | private _spotifyControls: SpotifyControls | undefined; 15 | private loginState: ILoginState | null = null; 16 | 17 | constructor() { 18 | getStore().subscribe(() => { 19 | this.render(); 20 | }); 21 | } 22 | 23 | /** 24 | * Updates spotify status bar inside vscode 25 | */ 26 | render() { 27 | const state = getState(); 28 | // Create as needed 29 | if (!this._statusBarItem) { 30 | this._statusBarItem = window.createStatusBarItem(StatusBarAlignment.Left, getButtonPriority('trackInfo')); 31 | this._statusBarItem.show(); 32 | } 33 | if (!this._spotifyControls) { 34 | this._spotifyControls = new SpotifyControls(); 35 | this._spotifyControls.showVisible(); 36 | } 37 | if (this.loginState !== state.loginState) { 38 | this.loginState = state.loginState; 39 | this._spotifyControls.showHideAuthButtons(); 40 | } 41 | 42 | if (state.isRunning) { 43 | const { state: playing, volume, isRepeating, isShuffling } = state.playerState; 44 | const text = this.formattedTrackInfo(state.track); 45 | let toRedraw = false; 46 | if (text !== this._statusBarItem.text) {// we need this guard to prevent flickering 47 | this._statusBarItem.text = text; 48 | toRedraw = true; 49 | } 50 | if (this._spotifyControls.updateDynamicButtons(playing === 'playing', volume === 0, isRepeating, isShuffling)) { 51 | toRedraw = true; 52 | } 53 | if (toRedraw) { 54 | this._statusBarItem.show(); 55 | this._spotifyControls.showVisible(); 56 | } 57 | const trackInfoClickBehaviour = getTrackInfoClickBehaviour(); 58 | if (trackInfoClickBehaviour === 'none') { 59 | this._statusBarItem.command = undefined; 60 | } else { 61 | this._statusBarItem.command = 'spotify.trackInfoClick'; 62 | } 63 | } else { 64 | this._statusBarItem.text = ''; 65 | this._statusBarItem.hide(); 66 | this._spotifyControls.hideAll(); 67 | } 68 | } 69 | 70 | /** 71 | * Disposes status bar items(if exist) 72 | */ 73 | dispose() { 74 | if (this._statusBarItem) { 75 | this._statusBarItem.dispose(); 76 | } 77 | if (this._spotifyControls) { 78 | this._spotifyControls.dispose(); 79 | } 80 | } 81 | 82 | private formattedTrackInfo(track: ITrack): string { 83 | const { album, artist, name } = track; 84 | const keywordsMap: { [index: string]: string } = { 85 | albumName: album, 86 | artistName: artist, 87 | trackName: name 88 | }; 89 | 90 | return getTrackInfoFormat().replace(/albumName|artistName|trackName/gi, matched => keywordsMap[matched]); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/components/tree-albums.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vscode from 'vscode'; 3 | 4 | import { actionsCreator } from '../actions/actions'; 5 | import { isAlbum } from '../isAlbum'; 6 | import { Album } from '../state/state'; 7 | import { getState, getStore } from '../store/store'; 8 | 9 | export const connectAlbumTreeView = (view: vscode.TreeView) => 10 | vscode.Disposable.from( 11 | view.onDidChangeSelection(e => { 12 | actionsCreator.selectAlbumAction(e.selection[0]); 13 | actionsCreator.loadTracksIfNotLoaded(e.selection[0]); 14 | }), 15 | view.onDidChangeVisibility(e => { 16 | if (e.visible) { 17 | const state = getState(); 18 | if (!state.albums.length) { 19 | actionsCreator.loadAlbums(); 20 | } 21 | 22 | const playlistOrAlbum = state.selectedList; 23 | if (playlistOrAlbum && isAlbum(playlistOrAlbum)) { 24 | const p = state.albums.find(({album}) => album.id === playlistOrAlbum.album.id); 25 | if (p && !view.selection.indexOf(p)) { 26 | view.reveal(p, { focus: true, select: true }); 27 | } 28 | } 29 | } 30 | }) 31 | ); 32 | 33 | export class TreeAlbumProvider implements vscode.TreeDataProvider { 34 | readonly onDidChangeTreeDataEmitter: vscode.EventEmitter = new vscode.EventEmitter(); 35 | readonly onDidChangeTreeData: vscode.Event = this.onDidChangeTreeDataEmitter.event; 36 | 37 | private albums: Album[] = []; 38 | 39 | constructor() { 40 | getStore().subscribe(() => { 41 | const { albums } = getState(); 42 | 43 | if (this.albums !== albums) { 44 | this.albums = albums; 45 | this.refresh(); 46 | } 47 | }); 48 | } 49 | 50 | getParent(_p: Album) { 51 | return void 0; // all albums are in root 52 | } 53 | 54 | refresh(): void { 55 | this.onDidChangeTreeDataEmitter.fire(void 0); 56 | } 57 | 58 | getTreeItem(p: Album): AlbumTreeItem { 59 | return new AlbumTreeItem(p, vscode.TreeItemCollapsibleState.None); 60 | } 61 | 62 | getChildren(element?: Album): Thenable { 63 | if (element) { 64 | return Promise.resolve([]); 65 | } 66 | if (!this.albums) { 67 | return Promise.resolve([]); 68 | } 69 | 70 | return new Promise(resolve => { 71 | resolve(this.albums); 72 | }); 73 | } 74 | } 75 | 76 | class AlbumTreeItem extends vscode.TreeItem { 77 | // @ts-expect-error 78 | get tooltip(): string { 79 | return `${this.album.album.name} - ${this.album.album.artists.map(a => a.name).join(", ")}`; 80 | } 81 | 82 | iconPath = { 83 | light: path.join(__filename, '..', '..', '..', 'resources', 'light', 'playlist.svg'), 84 | dark: path.join(__filename, '..', '..', '..', 'resources', 'dark', 'playlist.svg') 85 | }; 86 | contextValue = 'album'; 87 | 88 | constructor( 89 | private readonly album: Album, 90 | readonly collapsibleState: vscode.TreeItemCollapsibleState, 91 | readonly command?: vscode.Command 92 | ) { 93 | super(album.album.name, collapsibleState); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/components/tree-playlists.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vscode from 'vscode'; 3 | 4 | import { actionsCreator } from '../actions/actions'; 5 | import { isAlbum } from '../isAlbum'; 6 | import { Playlist } from '../state/state'; 7 | import { getState, getStore } from '../store/store'; 8 | 9 | export const connectPlaylistTreeView = (view: vscode.TreeView) => 10 | vscode.Disposable.from( 11 | view.onDidChangeSelection(e => { 12 | actionsCreator.selectPlaylistAction(e.selection[0]); 13 | actionsCreator.loadTracksIfNotLoaded(e.selection[0]); 14 | }), 15 | view.onDidChangeVisibility(e => { 16 | if (e.visible) { 17 | const state = getState(); 18 | if (!state.playlists.length) { 19 | actionsCreator.loadPlaylists(); 20 | } 21 | 22 | const playlistOrAlbum = state.selectedList; 23 | if (playlistOrAlbum && !isAlbum(playlistOrAlbum)) { 24 | const p = state.playlists.find(pl => pl.id === playlistOrAlbum.id); 25 | if (p && !view.selection.indexOf(p)) { 26 | view.reveal(p, { focus: true, select: true }); 27 | } 28 | } 29 | } 30 | }) 31 | ); 32 | 33 | export class TreePlaylistProvider implements vscode.TreeDataProvider { 34 | readonly onDidChangeTreeDataEmitter: vscode.EventEmitter = new vscode.EventEmitter(); 35 | readonly onDidChangeTreeData: vscode.Event = this.onDidChangeTreeDataEmitter.event; 36 | 37 | private playlists: Playlist[] = []; 38 | 39 | constructor() { 40 | getStore().subscribe(() => { 41 | const { playlists } = getState(); 42 | 43 | if (this.playlists !== playlists) { 44 | this.playlists = playlists; 45 | this.refresh(); 46 | } 47 | }); 48 | } 49 | 50 | getParent(_p: Playlist) { 51 | return void 0; // all playlists are in root 52 | } 53 | 54 | refresh(): void { 55 | this.onDidChangeTreeDataEmitter.fire(void 0); 56 | } 57 | 58 | getTreeItem(p: Playlist): PlaylistTreeItem { 59 | return new PlaylistTreeItem(p, vscode.TreeItemCollapsibleState.None); 60 | } 61 | 62 | getChildren(element?: Playlist): Thenable { 63 | if (element) { 64 | return Promise.resolve([]); 65 | } 66 | if (!this.playlists) { 67 | return Promise.resolve([]); 68 | } 69 | 70 | return new Promise(resolve => { 71 | resolve(this.playlists); 72 | }); 73 | } 74 | } 75 | 76 | class PlaylistTreeItem extends vscode.TreeItem { 77 | // @ts-expect-error 78 | get tooltip(): string { 79 | return `${this.playlist.name} by ${this.playlist.owner.display_name}`; 80 | } 81 | 82 | iconPath = { 83 | light: path.join(__filename, '..', '..', '..', 'resources', 'light', 'playlist.svg'), 84 | dark: path.join(__filename, '..', '..', '..', 'resources', 'dark', 'playlist.svg') 85 | }; 86 | contextValue = 'playlist'; 87 | 88 | constructor( 89 | private readonly playlist: Playlist, 90 | readonly collapsibleState: vscode.TreeItemCollapsibleState, 91 | readonly command?: vscode.Command 92 | ) { 93 | super(playlist.name, collapsibleState); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/components/tree-track.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vscode from 'vscode'; 3 | 4 | import { actionsCreator } from '../actions/actions'; 5 | import { isAlbum } from '../isAlbum'; 6 | import { Album, Playlist, Track } from '../state/state'; 7 | import { getState, getStore } from '../store/store'; 8 | 9 | const createTrackTreeItem = (t: Track, playlistOrAlbum: Playlist | Album, trackIndex: number) => 10 | new TrackTreeItem(t, vscode.TreeItemCollapsibleState.None, { 11 | command: 'spotify.playTrack', 12 | title: 'Play track', 13 | arguments: [trackIndex, playlistOrAlbum] 14 | }); 15 | 16 | export const connectTrackTreeView = (view: vscode.TreeView) => 17 | vscode.Disposable.from( 18 | view.onDidChangeSelection(e => { 19 | const track = e.selection[0]; 20 | actionsCreator.selectTrackAction(track); 21 | }), 22 | view.onDidChangeVisibility(e => { 23 | if (e.visible) { 24 | const state = getState(); 25 | const { selectedTrack, selectedList } = state; 26 | 27 | if (selectedTrack && selectedList) { 28 | const tracks = state.tracks.get(isAlbum(selectedList) ? selectedList.album.id : selectedList.id); 29 | const p = tracks?.find(t => t.track.id === selectedTrack.track.id); 30 | 31 | if (p && !view.selection.indexOf(p)) { 32 | view.reveal(p, { focus: true, select: true }); 33 | } 34 | } 35 | } 36 | }) 37 | ); 38 | 39 | export class TreeTrackProvider implements vscode.TreeDataProvider { 40 | readonly onDidChangeTreeDataEmitter: vscode.EventEmitter = new vscode.EventEmitter(); 41 | readonly onDidChangeTreeData: vscode.Event = this.onDidChangeTreeDataEmitter.event; 42 | 43 | private tracks: Track[] = []; 44 | private selectedList?: Playlist | Album; 45 | private selectedTrack?: Track; 46 | private view!: vscode.TreeView; 47 | 48 | constructor() { 49 | getStore().subscribe(() => { 50 | const { tracks, selectedList, selectedTrack } = getState(); 51 | const newTracks = tracks.get((isAlbum(selectedList) ? selectedList?.album.id : selectedList?.id) || ""); 52 | if (this.tracks !== newTracks || this.selectedTrack !== selectedTrack) { 53 | if (this.selectedTrack !== selectedTrack) { 54 | this.selectedTrack = selectedTrack!; 55 | 56 | if (this.selectedTrack && this.view) { 57 | this.view.reveal(this.selectedTrack, { focus: true, select: true }); 58 | } 59 | } 60 | this.selectedList = selectedList!; 61 | this.selectedTrack = selectedTrack!; 62 | this.tracks = newTracks || []; 63 | this.refresh(); 64 | } 65 | }); 66 | } 67 | 68 | bindView(view: vscode.TreeView): void { 69 | this.view = view; 70 | } 71 | 72 | getParent(_t: Track) { 73 | return void 0; // all tracks are in root 74 | } 75 | 76 | refresh(): void { 77 | this.onDidChangeTreeDataEmitter.fire(void 0); 78 | } 79 | 80 | getTreeItem(t: Track): TrackTreeItem { 81 | const { selectedList, tracks } = this; 82 | const index = tracks.findIndex(track => 83 | t.track.id === track.track.id); 84 | return createTrackTreeItem(t, selectedList!, index); 85 | } 86 | 87 | getChildren(element?: Track): Thenable { 88 | if (element) { 89 | return Promise.resolve([]); 90 | } 91 | if (!this.tracks) { 92 | return Promise.resolve([]); 93 | } 94 | 95 | return new Promise(resolve => { 96 | resolve(this.tracks); 97 | }); 98 | } 99 | } 100 | 101 | const getArtists = (track: Track) => 102 | track.track.artists.map(a => a.name).join(', '); 103 | class TrackTreeItem extends vscode.TreeItem { 104 | // @ts-ignore 105 | get tooltip(): string { 106 | return `${getArtists(this.track)} - ${this.track.track.album.name} - ${this.track.track.name}`; 107 | } 108 | 109 | iconPath = { 110 | light: path.join(__filename, '..', '..', '..', 'resources', 'light', 'track.svg'), 111 | dark: path.join(__filename, '..', '..', '..', 'resources', 'dark', 'track.svg') 112 | }; 113 | 114 | contextValue = 'track'; 115 | constructor( 116 | private readonly track: Track, 117 | readonly collapsibleState: vscode.TreeItemCollapsibleState, 118 | readonly command?: vscode.Command 119 | ) { 120 | super(`${getArtists(track)} - ${track.track.name}`, collapsibleState); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/config/spotify-config.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import { Memento, workspace } from 'vscode'; 3 | 4 | import { BUTTON_ID_SIGN_IN, BUTTON_ID_SIGN_OUT } from '../consts/consts'; 5 | import { getState } from '../store/store'; 6 | 7 | export function getConfig() { 8 | return workspace.getConfiguration('spotify'); 9 | } 10 | 11 | export function isWebApiSpotifyClient() { 12 | const platform = os.platform(); 13 | return (platform !== 'darwin' && platform !== 'linux') || getForceWebApiImplementation(); 14 | } 15 | 16 | export function isButtonToBeShown(buttonId: string): boolean { 17 | const shouldShow = getConfig().get(`show${buttonId[0].toUpperCase()}${buttonId.slice(1)}`, false); 18 | const { loginState } = getState(); 19 | 20 | if (buttonId === `${BUTTON_ID_SIGN_IN}Button`) { 21 | return shouldShow && !loginState; 22 | } else if (buttonId === `${BUTTON_ID_SIGN_OUT}Button`) { 23 | return shouldShow && !!loginState; 24 | } 25 | 26 | return shouldShow; 27 | } 28 | 29 | export function getButtonPriority(buttonId: string): number { 30 | const config = getConfig(); 31 | return config.get('priorityBase', 0) + config.get(`${buttonId}Priority`, 0); 32 | } 33 | 34 | export function getStatusCheckInterval(): number { 35 | const isWebApiClient = isWebApiSpotifyClient(); 36 | let interval = getConfig().get('statusCheckInterval', 5000); 37 | if (isWebApiClient) { 38 | interval = Math.max(interval, 5000); 39 | } 40 | return interval; 41 | } 42 | 43 | export function getLyricsServerUrl(): string { 44 | return getConfig().get('lyricsServerUrl', ''); 45 | } 46 | 47 | export function getAuthServerUrl(): string { 48 | return getConfig().get('authServerUrl', ''); 49 | } 50 | 51 | export function getSpotifyApiUrl(): string { 52 | return getConfig().get('spotifyApiUrl', ''); 53 | } 54 | 55 | export function openPanelLyrics(): number { 56 | return getConfig().get('openPanelLyrics', 1); 57 | } 58 | 59 | export function getTrackInfoFormat(): string { 60 | return getConfig().get('trackInfoFormat', ''); 61 | } 62 | 63 | export function getForceWebApiImplementation(): boolean { 64 | return getConfig().get('forceWebApiImplementation', false); 65 | } 66 | 67 | export function getEnableLogs(): boolean { 68 | return getConfig().get('enableLogs', false); 69 | } 70 | 71 | export type TrackInfoClickBehaviour = 'none' | 'focus_song' | 'play_pause'; 72 | 73 | export function getTrackInfoClickBehaviour(): TrackInfoClickBehaviour { 74 | return getConfig().get('trackInfoClickBehaviour', 'focus_song'); 75 | } 76 | 77 | let globalState: Memento; 78 | 79 | export function registerGlobalState(memento: Memento) { 80 | globalState = memento; 81 | } 82 | 83 | const LAST_USED_PORT = 'lastUsedPort'; 84 | 85 | export function getLastUsedPort() { 86 | return globalState.get(LAST_USED_PORT); 87 | } 88 | 89 | export function setLastUsedPort(port: number) { 90 | globalState.update(LAST_USED_PORT, port); 91 | } 92 | -------------------------------------------------------------------------------- /src/consts/consts.ts: -------------------------------------------------------------------------------- 1 | export const BUTTON_ID_SIGN_IN = 'signIn'; 2 | export const BUTTON_ID_SIGN_OUT = 'signOut'; 3 | export const SIGN_IN_COMMAND = 'spotify.signIn'; -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext, window } from 'vscode'; 2 | 3 | import { createCommands } from './commands'; 4 | import { SpotifyStatus } from './components/spotify-status'; 5 | import { connectPlaylistTreeView, TreePlaylistProvider } from './components/tree-playlists'; 6 | import { connectAlbumTreeView, TreeAlbumProvider } from './components/tree-albums'; 7 | import { connectTrackTreeView, TreeTrackProvider } from './components/tree-track'; 8 | import { registerGlobalState } from './config/spotify-config'; 9 | import { SpotifyStatusController } from './spotify-status-controller'; 10 | import { SpoifyClientSingleton } from './spotify/spotify-client'; 11 | import { getStore } from './store/store'; 12 | 13 | // This method is called when your extension is activated. Activation is 14 | // controlled by the activation events defined in package.json. 15 | export function activate(context: ExtensionContext) { 16 | // This line of code will only be executed once when your extension is activated. 17 | registerGlobalState(context.globalState); 18 | getStore(context.globalState); 19 | const spotifyStatus = new SpotifyStatus(); 20 | const controller = new SpotifyStatusController(); 21 | const playlistTreeView = window.createTreeView('vscode-spotify-playlists', { treeDataProvider: new TreePlaylistProvider() }); 22 | const albumTreeView = window.createTreeView('vscode-spotify-albums', { treeDataProvider: new TreeAlbumProvider() }); 23 | const treeTrackProvider = new TreeTrackProvider(); 24 | const trackTreeView = window.createTreeView('vscode-spotify-tracks', { treeDataProvider: treeTrackProvider }); 25 | treeTrackProvider.bindView(trackTreeView); 26 | // Add to a list of disposables which are disposed when this extension is deactivated. 27 | context.subscriptions.push(connectPlaylistTreeView(playlistTreeView)); 28 | context.subscriptions.push(connectAlbumTreeView(albumTreeView)); 29 | context.subscriptions.push(connectTrackTreeView(trackTreeView)); 30 | context.subscriptions.push(controller); 31 | context.subscriptions.push(spotifyStatus); 32 | context.subscriptions.push(playlistTreeView); 33 | context.subscriptions.push(createCommands(SpoifyClientSingleton.spotifyClient)); 34 | } 35 | -------------------------------------------------------------------------------- /src/info/info.ts: -------------------------------------------------------------------------------- 1 | import * as moment from 'moment'; 2 | import { window } from 'vscode'; 3 | 4 | import { getEnableLogs } from '../config/spotify-config'; 5 | 6 | export function showInformationMessage(message: string) { 7 | window.showInformationMessage(`vscode-spotify: ${message}`); 8 | } 9 | 10 | export function showWarningMessage(message: string, ...items: string[]) { 11 | return window.showWarningMessage(`vscode-spotify: ${message}`, ...items); 12 | } 13 | 14 | export function showErrorMessage(message: string) { 15 | window.showErrorMessage(`vscode-spotify: ${message}`, ); 16 | } 17 | 18 | export function log(...args: any[]) { 19 | if (getEnableLogs()) { 20 | console.log.apply(console, ['vscode-spotify', moment().format('YYYY/MM/DD HH:MM:mm:ss:SSS'), ...args]); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/isAlbum.ts: -------------------------------------------------------------------------------- 1 | import { Album, Playlist } from "./state/state"; 2 | 3 | export const isAlbum = (list?: Playlist | Album): list is Album => { 4 | return !!list && ("album" in list); 5 | }; 6 | -------------------------------------------------------------------------------- /src/lyrics/lyrics.ts: -------------------------------------------------------------------------------- 1 | import { Event, EventEmitter, ProgressLocation, TextDocumentContentProvider, Uri, window, workspace, QuickPickItem, env } from 'vscode'; 2 | 3 | import { getLyricsServerUrl, openPanelLyrics } from '../config/spotify-config'; 4 | import { showInformationMessage } from '../info/info'; 5 | import { xhr } from '../request/request'; 6 | import { getState } from '../store/store'; 7 | import * as cheerio from 'cheerio'; 8 | 9 | class TextContentProvider implements TextDocumentContentProvider { 10 | htmlContent = ''; 11 | 12 | private _onDidChange = new EventEmitter(); 13 | 14 | get onDidChange(): Event { 15 | return this._onDidChange.event; 16 | } 17 | 18 | provideTextDocumentContent(_uri: Uri): string { 19 | return this.htmlContent; 20 | } 21 | 22 | update(uri: Uri) { 23 | this._onDidChange.fire(uri); 24 | } 25 | } 26 | 27 | interface Song { 28 | artist: string, 29 | title: string, 30 | geniusPath?: string, 31 | /** 32 | * String similarity score for the song. 33 | */ 34 | similarity?: number 35 | } 36 | 37 | type V3SongsResponse = { 38 | songs?: Song[]; 39 | }; 40 | 41 | export class LyricsController { 42 | private static lyricsContentProvider = new TextContentProvider(); 43 | 44 | readonly registration = workspace.registerTextDocumentContentProvider('vscode-spotify', LyricsController.lyricsContentProvider); 45 | 46 | private readonly previewUri = Uri.parse('vscode-spotify://authority/vscode-spotify'); 47 | 48 | async findLyrics() { 49 | window.withProgress({ location: ProgressLocation.Window, title: 'Searching for lyrics. This might take a while.' }, () => 50 | this._findLyrics()); 51 | } 52 | 53 | private async _findLyrics() { 54 | const state = getState(); 55 | const { artist, name } = state.track; 56 | 57 | try { 58 | const url = `${getLyricsServerUrl()}?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(name)}`; 59 | const result = await xhr({ 60 | url 61 | }); 62 | 63 | const { songs } = JSON.parse(result.responseText) as V3SongsResponse; 64 | 65 | if (!songs || !songs.length) { 66 | await this._previewLyrics(`Song lyrics for ${artist} - ${name} not found.\nYou can add it on https://genius.com/ .`); 67 | return; 68 | } 69 | 70 | let song = songs[0]; 71 | if (song.similarity !== 1) { 72 | type QuickPickItemSong = QuickPickItem & { song: Song }; 73 | const pick = await window.showQuickPick( 74 | Array.from(songs.map((s): QuickPickItemSong => { 75 | return { 76 | label: `${s.artist} - ${s.title}`, 77 | song: s 78 | }; 79 | })), 80 | { 81 | ignoreFocusOut: true, 82 | placeHolder: 'Select one of the songs that we think might be your song.' 83 | } 84 | ); 85 | if (!pick) { 86 | return; 87 | } 88 | song = pick.song; 89 | } 90 | 91 | const geniusUrl = `https://genius.com${song.geniusPath}`; 92 | try { 93 | const fetchRes = await xhr({ 94 | url: geniusUrl 95 | }); 96 | const $ = cheerio.load(fetchRes.responseText); 97 | const lyrics = $('.lyrics').text().trim(); 98 | await this._previewLyrics(`${artist} - ${name}\n\n${lyrics}`); 99 | } 100 | catch (e) { 101 | if (e.status === 403) { 102 | // probably captcha. Open in browser 103 | await env.openExternal(Uri.parse(geniusUrl)); 104 | await this._previewLyrics(`Song lyrics for ${artist} - ${name} not found.\nYou can add it on https://genius.com/ .`); 105 | } 106 | if (e.status === 404) { 107 | await this._previewLyrics(`Song lyrics for ${artist} - ${name} not found.\nYou can add it on https://genius.com/ .`); 108 | } 109 | if (e.status === 500) { 110 | await this._previewLyrics(`Error: ${e.responseText}`); 111 | } 112 | } 113 | } catch (e) { 114 | if (e.status === 404) { 115 | await this._previewLyrics(`Song lyrics for ${artist} - ${name} not found.\nYou can add it on https://genius.com/ .`); 116 | } 117 | if (e.status === 500) { 118 | await this._previewLyrics(`Error: ${e.responseText}`); 119 | } 120 | } 121 | } 122 | 123 | private async _previewLyrics(lyrics: string) { 124 | LyricsController.lyricsContentProvider.htmlContent = lyrics; 125 | LyricsController.lyricsContentProvider.update(this.previewUri); 126 | 127 | try { 128 | const document = await workspace.openTextDocument(this.previewUri); 129 | await window.showTextDocument(document, openPanelLyrics(), true); 130 | } catch (_ignored) { 131 | showInformationMessage('Failed to show lyrics' + _ignored); 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/reducers/root-reducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Action, 3 | ALBUM_LOAD_ACTION, 4 | PLAYLISTS_LOAD_ACTION, 5 | SELECT_ALBUM_ACTION, 6 | SELECT_PLAYLIST_ACTION, 7 | SELECT_TRACK_ACTION, 8 | SIGN_IN_ACTION, 9 | SIGN_OUT_ACTION, 10 | TRACKS_LOAD_ACTION, 11 | UPDATE_STATE_ACTION 12 | } from '../actions/common'; 13 | import { log } from '../info/info'; 14 | import { isAlbum } from '../isAlbum'; 15 | import { DEFAULT_STATE, DUMMY_PLAYLIST, ISpotifyStatusState } from '../state/state'; 16 | 17 | export function update(obj: T, propertyUpdate: Partial): T { 18 | return Object.assign({}, obj, propertyUpdate); 19 | } 20 | 21 | export default function (state: ISpotifyStatusState, action: Action): ISpotifyStatusState { 22 | log('root-reducer', action.type, JSON.stringify(action)); 23 | if (action.type === UPDATE_STATE_ACTION) { 24 | return update(state, action.state); 25 | } 26 | if (action.type === SIGN_IN_ACTION) { 27 | return update(state, { 28 | loginState: update( 29 | state.loginState, { accessToken: action.accessToken, refreshToken: action.refreshToken } 30 | ) 31 | }); 32 | } 33 | if (action.type === SIGN_OUT_ACTION) { 34 | return DEFAULT_STATE; 35 | } 36 | if (action.type === PLAYLISTS_LOAD_ACTION) { 37 | return update(state, { 38 | playlists: (action.playlists && action.playlists.length) ? action.playlists : [DUMMY_PLAYLIST] 39 | }); 40 | } 41 | if (action.type === ALBUM_LOAD_ACTION) { 42 | return update(state, { 43 | albums: action.albums 44 | }); 45 | } 46 | if (action.type === SELECT_ALBUM_ACTION) { 47 | return update(state, { 48 | selectedList: action.album 49 | }); 50 | } 51 | if (action.type === SELECT_PLAYLIST_ACTION) { 52 | return update(state, { 53 | selectedList: action.playlist 54 | }); 55 | } 56 | if (action.type === SELECT_TRACK_ACTION) { 57 | return update(state, { 58 | selectedTrack: action.track 59 | }); 60 | } 61 | if (action.type === TRACKS_LOAD_ACTION) { 62 | return update(state, { 63 | tracks: state.tracks.set( 64 | isAlbum(action.list) ? action.list.album.id : action.list.id, 65 | action.tracks 66 | ) 67 | }); 68 | } 69 | return state; 70 | } 71 | -------------------------------------------------------------------------------- /src/request/request.ts: -------------------------------------------------------------------------------- 1 | import * as httpRequest from 'request-light'; 2 | import { workspace } from 'vscode'; 3 | 4 | export function configureHttpRequest() { 5 | const httpSettings = workspace.getConfiguration('http'); 6 | httpRequest.configure(httpSettings.get('proxy', ''), httpSettings.get('proxyStrictSSL', false)); 7 | } 8 | 9 | export function xhr(xhrOptions: httpRequest.XHROptions) { 10 | configureHttpRequest(); 11 | return httpRequest.xhr(xhrOptions); 12 | } -------------------------------------------------------------------------------- /src/spotify-status-controller.ts: -------------------------------------------------------------------------------- 1 | import autobind from 'autobind-decorator'; 2 | 3 | import { actionsCreator } from './actions/actions'; 4 | import { getStatusCheckInterval } from './config/spotify-config'; 5 | import { SpoifyClientSingleton } from './spotify/spotify-client'; 6 | import { CANCELED_REASON } from './spotify/utils'; 7 | 8 | export class SpotifyStatusController { 9 | private _retryCount: number; 10 | private _cancelCb?: () => void; 11 | /** 12 | * How many sequential errors is needed to hide all buttons 13 | */ 14 | private _maxRetryCount: number; 15 | 16 | constructor() { 17 | this._retryCount = 0; 18 | this._maxRetryCount = 5; 19 | this.queryStatus(); 20 | } 21 | 22 | /** 23 | * Retrieves status of spotify and passes it to spotifyStatus; 24 | */ 25 | @autobind 26 | queryStatus() { 27 | this._cancelPreviousPoll(); 28 | const { promise, cancel } = SpoifyClientSingleton.getSpotifyClient(this.queryStatus).pollStatus(status => { 29 | actionsCreator.updateStateAction(status); 30 | this._retryCount = 0; 31 | }, getStatusCheckInterval); 32 | this._cancelCb = cancel; 33 | promise.catch(this.clearState); 34 | } 35 | 36 | dispose() { 37 | this._cancelPreviousPoll(); 38 | } 39 | 40 | private clearState = (reason: any) => { 41 | // canceling of the promise only happens when method queryStatus is triggered. 42 | if (reason !== CANCELED_REASON) { 43 | this._retryCount++; 44 | if (this._retryCount >= this._maxRetryCount) { 45 | actionsCreator.updateStateAction({ 46 | playerState: { 47 | position: 0, volume: 0, state: 'paused', isRepeating: false, 48 | isShuffling: false 49 | }, 50 | track: { album: '', artist: '', name: '' }, 51 | isRunning: false 52 | }); 53 | this._retryCount = 0; 54 | } 55 | setTimeout(this.queryStatus, getStatusCheckInterval()); 56 | } 57 | }; 58 | 59 | private _cancelPreviousPoll() { 60 | if (this._cancelCb) { 61 | this._cancelCb(); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/spotify/common.ts: -------------------------------------------------------------------------------- 1 | import { ISpotifyStatusStatePartial } from '../state/state'; 2 | 3 | export type QueryStatusFunction = () => void; 4 | 5 | export interface SpotifyClient { 6 | queryStatusFunc: QueryStatusFunction; 7 | next(): void; 8 | previous(): void; 9 | play(): void; 10 | pause(): void; 11 | playPause(): void; 12 | muteVolume(): void; 13 | unmuteVolume(): void; 14 | muteUnmuteVolume(): void; 15 | volumeUp(): void; 16 | volumeDown(): void; 17 | toggleRepeating(): void; 18 | toggleShuffling(): void; 19 | pollStatus(cb: (status: ISpotifyStatusStatePartial) => void, getInterval: () => number): { promise: Promise, cancel: () => void }; 20 | } -------------------------------------------------------------------------------- /src/spotify/linux-spotify-client.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | 3 | import { log } from '../info/info'; 4 | import { ISpotifyStatusStatePartial } from '../state/state'; 5 | 6 | import { OsAgnosticSpotifyClient } from './os-agnostic-spotify-client'; 7 | import { QueryStatusFunction, SpotifyClient } from './common'; 8 | import { createCancelablePromise } from './utils'; 9 | 10 | const SP_DEST = 'org.mpris.MediaPlayer2.spotify'; 11 | const SP_PATH = '/org/mpris/MediaPlayer2'; 12 | const SP_MEMB = 'org.mpris.MediaPlayer2.Player'; 13 | const DB_P_GET = 'org.freedesktop.DBus.Properties.Get'; 14 | const createCommandString = (command: string) => 15 | `dbus-send --print-reply --dest=${SP_DEST} ${SP_PATH} ${SP_MEMB}.${command}`; 16 | const playPauseDebianCmd = createCommandString('PlayPause'); 17 | const pauseDebianCmd = createCommandString('Pause'); 18 | const playNextTrackDebianCmd = createCommandString('Next'); 19 | const playPreviousTrackDebianCmd = createCommandString('Previous'); 20 | const getPlaybackStatus = `dbus-send --print-reply --dest=${SP_DEST} ${SP_PATH} ${DB_P_GET} string:${SP_MEMB} string:PlaybackStatus`; 21 | // @see https://gist.github.com/wandernauta/6800547 22 | /** 23 | * Example response 24 | trackid|spotify:track:4SQ0ytpTP8v1Rx8FWR22cv 25 | length|354000000 26 | artUrl|https://open.spotify.com/image/e51e7bc95101b3990d86ca58b18a6eb6ba057db3 27 | album|In My Body 28 | albumArtist|SYML 29 | artist|SYML 30 | autoRating|0.52 31 | discNumber|1 32 | title|The War 33 | trackNumber|6 34 | url|https://open.spotify.com/track/4SQ0ytpTP8v1Rx8FWR22cv 35 | */ 36 | const getMetadataCommand = `dbus-send \ 37 | --print-reply \\ 38 | --dest=${SP_DEST} \\ 39 | ${SP_PATH} \\ 40 | org.freedesktop.DBus.Properties.Get \\ 41 | string:"${SP_MEMB}" string:'Metadata' \\ 42 | | grep -Ev "^method" \\ 43 | | grep -Eo '("(.*)")|(\\b[0-9][a-zA-Z0-9.]*\\b)' \\ 44 | | sed -E '2~2 a|' \\ 45 | | tr -d '\\n' \\ 46 | | sed -E 's/\\|/\\n/g' \\ 47 | | sed -E 's/(xesam:)|(mpris:)//' \\ 48 | | sed -E 's/^"//' \\ 49 | | sed -E 's/"$//' \\ 50 | | sed -E 's/"+/|/' \\ 51 | | sed -E 's/ +/ /g' 52 | `; 53 | 54 | const terminalCommand = (cmd: string) => 55 | /** 56 | * This is a wrapper for executing terminal commands 57 | * by using NodeJs' built in "child process" library. 58 | * This function initially returns a Promise. 59 | * 60 | * @param {string} cmd This is the command to execute 61 | * @return {string} the standard output of the executed command on successful execution 62 | * @return {boolean} returns false if the executed command is unsuccessful 63 | * 64 | */ 65 | new Promise((resolve, _reject) => { 66 | exec(cmd, (e, stdout, stderr) => { 67 | if (e) { return resolve(''); } 68 | if (stderr) { return resolve(''); } 69 | resolve(stdout); 70 | }); 71 | }); 72 | 73 | interface ICurrentVol { sinkNum: string | null; volume: number; } 74 | 75 | export class LinuxSpotifyClient extends OsAgnosticSpotifyClient implements SpotifyClient { 76 | get queryStatusFunc() { 77 | return this._queryStatusFunc; 78 | } 79 | 80 | private currentOnVolume: number = 0; 81 | private _queryStatusFunc: QueryStatusFunction; 82 | 83 | constructor(_queryStatusFunc: QueryStatusFunction) { 84 | super(); 85 | this._queryStatusFunc = () => { 86 | // spotify with dbfus doesn't return correct state right after next/prev/pause/play 87 | // command executtion. we need to wait 88 | setTimeout(_queryStatusFunc, /*magic number*/600); 89 | }; 90 | } 91 | 92 | pollStatus(cb: (status: ISpotifyStatusStatePartial) => void, getInterval: () => number) { 93 | let canceled = false; 94 | const p = createCancelablePromise((_, reject) => { 95 | const _poll = () => { 96 | if (canceled) { 97 | return; 98 | } 99 | this.getStatus().then(status => { 100 | cb(status); 101 | setTimeout(_poll, getInterval()); 102 | }).catch(reject); 103 | }; 104 | _poll(); 105 | }); 106 | p.promise = p.promise.catch(err => { 107 | canceled = true; 108 | throw err; 109 | }); 110 | return p; 111 | } 112 | 113 | play() { 114 | terminalCommand(playPauseDebianCmd); 115 | } 116 | 117 | pause() { 118 | terminalCommand(pauseDebianCmd); 119 | } 120 | 121 | async playPause() { 122 | await terminalCommand(playPauseDebianCmd); 123 | this._queryStatusFunc(); 124 | } 125 | 126 | async next() { 127 | await terminalCommand(playNextTrackDebianCmd); 128 | this._queryStatusFunc(); 129 | } 130 | 131 | async previous() { 132 | await terminalCommand(playPreviousTrackDebianCmd); 133 | this._queryStatusFunc(); 134 | } 135 | 136 | /** 137 | * This function checks to see which "Sinked Input #" 138 | * is actually running spotify. 139 | * 140 | * @param s The sting that might be contain the Sinked Input # 141 | * we are looking for. 142 | * @return This function was intended to be used with map in order 143 | * to remap an Array where there should only be one element after we "findSpotify" 144 | */ 145 | findSpotify(s: string) { 146 | const foundSpotifySink = s.match(/(Spotify)/i); 147 | return (foundSpotifySink !== null) ? ((foundSpotifySink.length > 1) ? true : false) : false; 148 | } 149 | 150 | async getCurrentVolume(): Promise { 151 | try { 152 | const d = await terminalCommand('pactl list sink-inputs'); 153 | const sinkedArr = d.split('Sinked Input #'); 154 | const a = sinkedArr ? sinkedArr.filter(this.findSpotify) : []; 155 | if (a.length > 0) { 156 | const currentVol = a[0].match(/(\d{1,3})%/i); 157 | if (currentVol !== null) { 158 | const sinkNum = a[0].match(/Sink Input #(\d{1,3})/); 159 | if (currentVol.length > 1) { 160 | if (parseInt(currentVol[1]) >= 0 && sinkNum !== null) { 161 | return { sinkNum: sinkNum[1], volume: parseInt(currentVol[1]) }; 162 | } 163 | } 164 | } 165 | } 166 | } catch (ignored) { log(ignored); } 167 | return { sinkNum: null, volume: 0 }; 168 | } 169 | 170 | async muteVolume(currentVol?: ICurrentVol) { 171 | const v = currentVol || await this.getCurrentVolume(); 172 | if (v.sinkNum && v.volume !== 0) { 173 | this.currentOnVolume = v.volume; 174 | this._setVolume(v.sinkNum, 0); 175 | } 176 | } 177 | 178 | async unmuteVolume(currentVol?: ICurrentVol) { 179 | const v = currentVol || await this.getCurrentVolume(); 180 | if (v.sinkNum && v.volume === 0) { 181 | this._setVolume(v.sinkNum, this.currentOnVolume || 100); 182 | } 183 | } 184 | 185 | async muteUnmuteVolume() { 186 | const v = await this.getCurrentVolume(); 187 | if (v.sinkNum) { 188 | if (v.volume === 0) { 189 | this.unmuteVolume(v); 190 | } else { 191 | this.muteVolume(v); 192 | } 193 | } 194 | } 195 | 196 | volumeUp() { 197 | terminalCommand('pactl list sink-inputs') 198 | .then((d: string) => { 199 | const sinkedArr = d.split('Sinked Input #'); 200 | return (sinkedArr !== null) ? sinkedArr.filter(this.findSpotify) : []; 201 | }) 202 | .then((a: string[]) => { 203 | if (a.length > 0) { 204 | const sinkNum = a[0].match(/Sink Input #(\d{1,3})/); 205 | if (sinkNum !== null) { 206 | terminalCommand(`pactl set-sink-input-volume ${sinkNum[1]} +5%)`); 207 | } 208 | } 209 | }) 210 | .catch(log); 211 | } 212 | 213 | volumeDown() { 214 | terminalCommand('pactl list sink-inputs') 215 | .then((d: string) => { 216 | const sinkedArr = d.split('Sinked Input #'); 217 | return (sinkedArr !== null) ? sinkedArr.filter(this.findSpotify) : []; 218 | }) 219 | .then((a: string[]) => { 220 | if (a.length > 0) { 221 | const sinkNum = a[0].match(/Sink Input #(\d{1,3})/); 222 | if (sinkNum !== null) { 223 | terminalCommand(`pactl set-sink-input-volume ${sinkNum[1]} -5%`); 224 | } 225 | } 226 | }) 227 | .catch(log); 228 | } 229 | 230 | private async getStatus(): Promise { 231 | try { 232 | const playbackStatus = await terminalCommand(getPlaybackStatus); 233 | const metadata = await terminalCommand(getMetadataCommand); 234 | if (!playbackStatus || !metadata) { 235 | return Promise.reject(`Spotify isn't running`); 236 | } 237 | const state = playbackStatus.indexOf('Playing') ? 'playing' : 'paused'; 238 | 239 | const result: ISpotifyStatusStatePartial = { 240 | playerState: { 241 | state, 242 | volume: 100, // dbus doesn't return real value for this 243 | position: 0, // dbus doesn't return real value for this, 244 | isRepeating: false, // dbus doesn't return real value for this 245 | isShuffling: false// dbus doesn't return real value for this 246 | }, 247 | track: { 248 | album: (/album\|(.+)/g.exec(metadata) || [])[1], 249 | artist: (/artist\|(.+)/g.exec(metadata) || [])[1], 250 | name: (/title\|(.+)/g.exec(metadata) || [])[1] 251 | }, 252 | isRunning: true 253 | }; 254 | return result; 255 | } catch (ignored) { log(ignored); } 256 | return Promise.reject(`Spotify isn't running`); 257 | } 258 | 259 | private _setVolume(sinkNum: string, volume: number) { 260 | terminalCommand(`pactl set-sink-input-volume ${sinkNum} ${volume}%`); 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/spotify/os-agnostic-spotify-client.ts: -------------------------------------------------------------------------------- 1 | import { showInformationMessage } from '../info/info'; 2 | import { ISpotifyStatusStatePartial } from '../state/state'; 3 | 4 | import { SpotifyClient } from './common'; 5 | import { createCancelablePromise } from './utils'; 6 | 7 | function notSupported(_ignoredTarget: any, _ignoredPropertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor { 8 | const fn = descriptor.value; 9 | 10 | if (typeof fn !== 'function') { 11 | throw new Error(`@notSupported can only be applied to method and not to ${typeof fn}`); 12 | } 13 | 14 | return Object.assign({}, descriptor, { 15 | value() { 16 | showInformationMessage('This functionality is not supported on this platform.'); 17 | return; 18 | } 19 | }); 20 | } 21 | 22 | // tslint:disable:no-empty 23 | export class OsAgnosticSpotifyClient implements SpotifyClient { 24 | get queryStatusFunc() { 25 | return this.next; 26 | } 27 | 28 | @notSupported 29 | next() { 30 | } 31 | @notSupported 32 | previous() { 33 | } 34 | @notSupported 35 | play() { 36 | } 37 | @notSupported 38 | pause() { 39 | } 40 | @notSupported 41 | playPause() { 42 | } 43 | @notSupported 44 | pollStatus(_cb: (status: ISpotifyStatusStatePartial) => void, _getInterval: () => number) { 45 | return createCancelablePromise((_resolve, _reject) => {}); 46 | } 47 | @notSupported 48 | muteVolume() { 49 | } 50 | @notSupported 51 | unmuteVolume() { 52 | } 53 | @notSupported 54 | muteUnmuteVolume() { 55 | } 56 | @notSupported 57 | volumeUp() { 58 | } 59 | @notSupported 60 | volumeDown() { 61 | } 62 | @notSupported 63 | toggleRepeating() { 64 | } 65 | @notSupported 66 | toggleShuffling() { 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/spotify/osx-spotify-client.ts: -------------------------------------------------------------------------------- 1 | import * as spotify from 'spotify-node-applescript'; 2 | import { window } from 'vscode'; 3 | 4 | import { ISpotifyStatusStatePartial } from '../state/state'; 5 | import { isMuted } from '../store/store'; 6 | 7 | import { QueryStatusFunction, SpotifyClient } from './common'; 8 | import { createCancelablePromise } from './utils'; 9 | 10 | export class OsxSpotifyClient implements SpotifyClient { 11 | private _queryStatusFunc: QueryStatusFunction; 12 | 13 | constructor(queryStatusFunc: QueryStatusFunction) { 14 | this._queryStatusFunc = queryStatusFunc; 15 | } 16 | 17 | get queryStatusFunc() { 18 | return this._queryStatusFunc; 19 | } 20 | 21 | next() { 22 | spotify.next(this._queryStatusFunc); 23 | } 24 | previous() { 25 | spotify.previous(this._queryStatusFunc); 26 | } 27 | play() { 28 | spotify.play(this._queryStatusFunc); 29 | } 30 | pause() { 31 | spotify.pause(this._queryStatusFunc); 32 | } 33 | playPause() { 34 | spotify.playPause(this._queryStatusFunc); 35 | } 36 | muteVolume() { 37 | spotify.muteVolume(this._queryStatusFunc); 38 | } 39 | unmuteVolume() { 40 | spotify.unmuteVolume(this._queryStatusFunc); 41 | } 42 | muteUnmuteVolume() { 43 | if (isMuted()) { 44 | spotify.unmuteVolume(this._queryStatusFunc); 45 | } else { 46 | spotify.muteVolume(this._queryStatusFunc); 47 | } 48 | } 49 | volumeUp() { 50 | spotify.volumeUp(this._queryStatusFunc); 51 | } 52 | volumeDown() { 53 | spotify.volumeDown(this._queryStatusFunc); 54 | } 55 | toggleRepeating() { 56 | spotify.toggleRepeating(this._queryStatusFunc); 57 | } 58 | toggleShuffling() { 59 | spotify.toggleShuffling(this._queryStatusFunc); 60 | } 61 | pollStatus(cb: (status: ISpotifyStatusStatePartial) => void, getInterval: () => number) { 62 | let canceled = false; 63 | const p = createCancelablePromise((_, reject) => { 64 | const _poll = () => { 65 | if (canceled) { 66 | return; 67 | } 68 | if (!window.state.focused) { 69 | setTimeout(_poll, getInterval()); 70 | return; 71 | } 72 | this.getStatus().then(status => { 73 | cb(status); 74 | setTimeout(_poll, getInterval()); 75 | }).catch(reject); 76 | }; 77 | _poll(); 78 | }); 79 | p.promise = p.promise.catch(err => { 80 | canceled = true; 81 | throw err; 82 | }); 83 | return p; 84 | } 85 | 86 | private getStatus(): Promise { 87 | return this._promiseIsRunning().then(isRunning => { 88 | if (!isRunning) { 89 | return Promise.reject('Spotify isn\'t running'); 90 | } 91 | return Promise.all([ 92 | this._promiseGetState(), 93 | this._promiseGetTrack(), 94 | this._promiseIsRepeating(), 95 | this._promiseIsShuffling() 96 | ]).then(values => { 97 | const spState = values[0] as spotify.State & { state: 'playing' | 'paused' }; 98 | const state: ISpotifyStatusStatePartial = { 99 | playerState: Object.assign(spState, { 100 | isRepeating: values[2] as boolean, 101 | isShuffling: values[3] as boolean 102 | }), 103 | track: values[1] as spotify.Track, 104 | isRunning: true 105 | }; 106 | return state; 107 | }) as any; 108 | }); 109 | } 110 | 111 | private _promiseIsRunning() { 112 | return new Promise((resolve, reject) => { 113 | spotify.isRunning((err, isRunning) => { 114 | if (err) { 115 | reject(err); 116 | } else { 117 | resolve(isRunning); 118 | } 119 | }); 120 | }); 121 | } 122 | 123 | private _promiseGetState() { 124 | return new Promise((resolve, reject) => { 125 | spotify.getState((err, state) => { 126 | if (err) { 127 | reject(err); 128 | } else { 129 | resolve(state); 130 | } 131 | }); 132 | }); 133 | } 134 | 135 | private _promiseGetTrack() { 136 | return new Promise((resolve, reject) => { 137 | spotify.getTrack((err, track) => { 138 | if (err) { 139 | reject(err); 140 | } else { 141 | resolve(track); 142 | } 143 | }); 144 | }); 145 | } 146 | 147 | private _promiseIsRepeating() { 148 | return new Promise((resolve, reject) => { 149 | spotify.isRepeating((err, repeating) => { 150 | if (err) { 151 | reject(err); 152 | } else { 153 | resolve(repeating); 154 | } 155 | }); 156 | }); 157 | } 158 | 159 | private _promiseIsShuffling() { 160 | return new Promise((resolve, reject) => { 161 | spotify.isShuffling((err, shuffling) => { 162 | if (err) { 163 | reject(err); 164 | } else { 165 | resolve(shuffling); 166 | } 167 | }); 168 | }); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/spotify/spotify-client.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | 3 | import { QueryStatusFunction, SpotifyClient } from './common'; 4 | 5 | import { LinuxSpotifyClient } from './linux-spotify-client'; 6 | import { OsxSpotifyClient } from './osx-spotify-client'; 7 | import { isWebApiSpotifyClient } from '../config/spotify-config'; 8 | import { WebApiSpotifyClient } from './web-api-spotify-client'; 9 | 10 | export class SpoifyClientSingleton { 11 | static spotifyClient: SpotifyClient; 12 | static getSpotifyClient(queryStatus: QueryStatusFunction) { 13 | if (this.spotifyClient) { 14 | return this.spotifyClient; 15 | } 16 | 17 | const platform = os.platform(); 18 | if (isWebApiSpotifyClient()) { 19 | this.spotifyClient = new WebApiSpotifyClient(queryStatus); 20 | return this.spotifyClient; 21 | } 22 | 23 | if (platform === 'darwin') { 24 | this.spotifyClient = new OsxSpotifyClient(queryStatus); 25 | } 26 | if (platform === 'linux') { 27 | this.spotifyClient = new LinuxSpotifyClient(queryStatus); 28 | } 29 | 30 | return this.spotifyClient; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/spotify/utils.ts: -------------------------------------------------------------------------------- 1 | export const CANCELED_REASON = 'canceled' as 'canceled'; 2 | export const NOT_RUNNING_REASON = 'not_running' as 'not_running'; 3 | 4 | export function createCancelablePromise( 5 | executor: (resolve: (value?: T | PromiseLike) => void, 6 | reject: (reason?: any) => void) => void 7 | ) { 8 | let cancel: () => void = null as any; 9 | const promise = new Promise((resolve, reject) => { 10 | cancel = () => { 11 | reject(CANCELED_REASON); 12 | }; 13 | executor(resolve, reject); 14 | }); 15 | return { promise, cancel }; 16 | } 17 | -------------------------------------------------------------------------------- /src/spotify/web-api-spotify-client.ts: -------------------------------------------------------------------------------- 1 | import { Api } from '@vscodespotify/spotify-common/lib/spotify/api'; 2 | 3 | import { getSpotifyWebApi, withApi, withErrorAsync } from '../actions/actions'; 4 | import { log } from '../info/info'; 5 | import { ISpotifyStatusStatePartial } from '../state/state'; 6 | import { getState } from '../store/store'; 7 | import { artistsToArtist } from '../utils/utils'; 8 | import { QueryStatusFunction, SpotifyClient } from './common'; 9 | import { createCancelablePromise, NOT_RUNNING_REASON } from './utils'; 10 | 11 | export class WebApiSpotifyClient implements SpotifyClient { 12 | private prevVolume: number = 0; 13 | private _queryStatusFunc: QueryStatusFunction; 14 | 15 | constructor(_queryStatusFunc: QueryStatusFunction) { 16 | this._queryStatusFunc = () => { 17 | log('SCHEDULED QUERY STATUS'); 18 | setTimeout(_queryStatusFunc, /*magic number for 'rapid' update. 1 second should? be enough*/1000); 19 | }; 20 | } 21 | 22 | get queryStatusFunc() { 23 | return this._queryStatusFunc; 24 | } 25 | 26 | @withErrorAsync() 27 | @withApi() 28 | async next(api?: Api) { 29 | await api!.player.next.post(); 30 | this._queryStatusFunc(); 31 | } 32 | 33 | @withErrorAsync() 34 | @withApi() 35 | async previous(api?: Api) { 36 | await api!.player.previous.post(); 37 | this._queryStatusFunc(); 38 | } 39 | 40 | @withErrorAsync() 41 | @withApi() 42 | async play(api?: Api) { 43 | await api!.player.play.put({}); 44 | this._queryStatusFunc(); 45 | } 46 | 47 | @withErrorAsync() 48 | @withApi() 49 | async pause(api?: Api) { 50 | await api!.player.pause.put(); 51 | this._queryStatusFunc(); 52 | } 53 | 54 | playPause() { 55 | const { playerState } = getState(); 56 | if (playerState.state === 'playing') { 57 | this.pause(); 58 | } else { 59 | this.play(); 60 | } 61 | } 62 | 63 | pollStatus(_cb: (status: ISpotifyStatusStatePartial) => void, getInterval: () => number) { 64 | let canceled = false; 65 | const p = createCancelablePromise((_, reject) => { 66 | const _poll = async () => { 67 | if (canceled) { 68 | return; 69 | } 70 | const api = getSpotifyWebApi(); 71 | try { 72 | if (api) { 73 | log('GETTING STATUS'); 74 | 75 | const player = await api.player.get(); 76 | if (!player) { 77 | reject(NOT_RUNNING_REASON); 78 | return; 79 | } 80 | 81 | log('GOT STATUS', JSON.stringify(player)); 82 | 83 | if (!canceled) { 84 | _cb({ 85 | isRunning: player.device.is_active, 86 | playerState: { 87 | // fixme more than two states 88 | isRepeating: player.repeat_state !== 'off', 89 | isShuffling: player.shuffle_state, 90 | position: player.progress_ms, 91 | state: player.is_playing ? 'playing' : 'paused', 92 | volume: player.device.volume_percent 93 | }, 94 | track: { 95 | album: player.item.album.name, 96 | artist: artistsToArtist(player.item.artists), 97 | name: player.item.name 98 | }, 99 | context: player.context ? { 100 | uri: player.context.uri, 101 | trackNumber: player.item.track_number 102 | } : void 0 103 | }); 104 | } 105 | } 106 | } catch (_e) { 107 | reject(_e); 108 | return; 109 | } 110 | setTimeout(_poll, getInterval()); 111 | }; 112 | _poll(); 113 | }); 114 | p.promise = p.promise.catch(err => { 115 | canceled = true; 116 | throw err; 117 | }); 118 | return p; 119 | } 120 | 121 | @withErrorAsync() 122 | @withApi() 123 | async muteVolume(api?: Api) { 124 | this.prevVolume = getState().playerState.volume; 125 | if (this.prevVolume !== 0) { 126 | await api!.player.volume.put(0); 127 | this._queryStatusFunc(); 128 | } 129 | } 130 | 131 | @withErrorAsync() 132 | @withApi() 133 | async unmuteVolume(api?: Api) { 134 | if (this.prevVolume) { 135 | await api!.player.volume.put(this.prevVolume); 136 | this._queryStatusFunc(); 137 | } 138 | } 139 | 140 | muteUnmuteVolume() { 141 | const volume = getState().playerState.volume; 142 | if (volume === 0) { 143 | this.unmuteVolume(); 144 | } else { 145 | this.muteVolume(); 146 | } 147 | } 148 | 149 | @withErrorAsync() 150 | @withApi() 151 | async volumeUp(api?: Api) { 152 | const volume = getState().playerState.volume || 0; 153 | await api!.player.volume.put(Math.min(volume + 20, 100)); 154 | this._queryStatusFunc(); 155 | } 156 | 157 | @withErrorAsync() 158 | @withApi() 159 | async volumeDown(api?: Api) { 160 | const volume = getState().playerState.volume || 0; 161 | await api!.player.volume.put(Math.max(volume - 20, 0)); 162 | this._queryStatusFunc(); 163 | } 164 | 165 | @withErrorAsync() 166 | @withApi() 167 | async toggleRepeating(api?: Api) { 168 | const { playerState } = getState(); 169 | // fixme more than two states 170 | await api!.player.repeat.put((!playerState.isRepeating) ? 'context' : 'off'); 171 | this._queryStatusFunc(); 172 | } 173 | 174 | @withErrorAsync() 175 | @withApi() 176 | async toggleShuffling(api?: Api) { 177 | const { playerState } = getState(); 178 | await api!.player.shuffle.put(!playerState.isShuffling); 179 | this._queryStatusFunc(); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/state/state.ts: -------------------------------------------------------------------------------- 1 | import { Playlist, Track, Album } from '@vscodespotify/spotify-common/lib/spotify/consts'; 2 | import { Map } from 'immutable'; 3 | 4 | export { Playlist, Track, Album }; 5 | 6 | export interface ITrack { 7 | album: string; 8 | artist: string; 9 | name: string; 10 | } 11 | 12 | export interface ILoginState { 13 | accessToken: string; 14 | refreshToken: string; 15 | } 16 | 17 | export interface IPlayerState { 18 | /** 19 | * 20 | */ 21 | volume: number; 22 | position: number; 23 | state: 'playing' | 'paused'; 24 | /** 25 | * true if repeating is enabled 26 | */ 27 | isRepeating: boolean; 28 | /** 29 | * true if shuffling is enabled 30 | */ 31 | isShuffling: boolean; 32 | } 33 | 34 | export interface ISpotifyStatusStatePartial { 35 | /** 36 | * true if spotify is open 37 | */ 38 | isRunning: boolean; 39 | /** 40 | * additional state 41 | */ 42 | playerState: IPlayerState; 43 | /** 44 | * current track 45 | */ 46 | track: ITrack; 47 | /** 48 | * current context 49 | */ 50 | context?: { 51 | /** 52 | * uri for the current track 53 | */ 54 | uri?: string, 55 | /** 56 | * Track number in current context 57 | */ 58 | trackNumber?: number 59 | }; 60 | } 61 | 62 | export interface ISpotifyStatusState extends ISpotifyStatusStatePartial { 63 | loginState: ILoginState | null; 64 | playlists: Playlist[]; 65 | albums: Album[]; 66 | selectedList?: Playlist | Album; 67 | /** 68 | * Map 69 | */ 70 | tracks: Map; 71 | selectedTrack: Track | null; 72 | } 73 | 74 | export const DUMMY_PLAYLIST: Playlist = { 75 | collaborative: false, 76 | /* eslint-disable @typescript-eslint/naming-convention */ 77 | external_urls: { 78 | spotify: '' 79 | }, 80 | href: '', 81 | id: 'No Playlists', 82 | images: [{ 83 | height: 100, 84 | url: 'none', 85 | width: 100 86 | }], 87 | name: 'It seems that you don\'t have any playlists. To refresh use spotify.loadPlaylists command.', 88 | owner: { 89 | /* eslint-disable @typescript-eslint/naming-convention */ 90 | display_name: '', 91 | /* eslint-disable @typescript-eslint/naming-convention */ 92 | external_urls: { 93 | spotify: '' 94 | }, 95 | href: '', 96 | id: '', 97 | type: '', 98 | uri: '' 99 | }, 100 | primary_color: null, 101 | public: false, 102 | snapshot_id: '', 103 | tracks: { 104 | href: '', 105 | total: 0 106 | }, 107 | type: '', 108 | uri: '' 109 | }; 110 | 111 | export const DEFAULT_STATE: ISpotifyStatusState = { 112 | playerState: { 113 | position: 0, 114 | volume: 0, 115 | state: 'paused', 116 | isRepeating: false, 117 | isShuffling: false 118 | }, 119 | track: { 120 | album: '', 121 | artist: '', 122 | name: '' 123 | }, 124 | isRunning: false, 125 | loginState: null, 126 | context: void 0, 127 | playlists: [], 128 | albums: [], 129 | selectedList: undefined, 130 | tracks: Map(), 131 | selectedTrack: null 132 | }; 133 | -------------------------------------------------------------------------------- /src/store/storage/vscode-storage.ts: -------------------------------------------------------------------------------- 1 | import { Memento } from 'vscode'; 2 | 3 | export function createVscodeStorage(memento: Memento) { 4 | return { 5 | getItem: (key: string): Promise => 6 | new Promise((resolve, _reject) => { 7 | resolve(memento.get(key)); 8 | }), 9 | setItem: (key: string, item: string): Promise => 10 | new Promise((resolve, _reject) => { 11 | memento.update(key, item).then(resolve); 12 | }), 13 | removeItem: (key: string): Promise => 14 | new Promise((resolve, _reject) => { 15 | memento.update(key, null).then(resolve); 16 | }) 17 | }; 18 | } 19 | 20 | export function createDummyStorage() { 21 | return { 22 | getItem: (_key: string): Promise => 23 | new Promise((resolve, _reject) => { 24 | resolve(''); 25 | }), 26 | setItem: (_key: string, _item: string): Promise => 27 | new Promise((resolve, _reject) => { 28 | resolve(); 29 | }), 30 | removeItem: (_key: string): Promise => 31 | new Promise((resolve, _reject) => { 32 | resolve(); 33 | }) 34 | }; 35 | } -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | import { createStore, Store } from 'redux'; 3 | import { PersistConfig, persistReducer, persistStore } from 'redux-persist'; 4 | import { Memento } from 'vscode'; 5 | 6 | import rootReducer from '../reducers/root-reducer'; 7 | import { DEFAULT_STATE, ISpotifyStatusState } from '../state/state'; 8 | 9 | import { createDummyStorage, createVscodeStorage } from './storage/vscode-storage'; 10 | 11 | export type SpotifyStore = Store; 12 | 13 | let store: SpotifyStore; 14 | 15 | export function getStore(memento?: Memento) { 16 | if (!store) { 17 | const notToPersistList: (keyof ISpotifyStatusState)[] = ['selectedTrack', 'selectedList']; 18 | 19 | const persistConfig: PersistConfig = { 20 | key: 'root', 21 | storage: memento ? createVscodeStorage(memento) : createDummyStorage(), 22 | transforms: [{ 23 | out: (val: any, key: string) => { 24 | if (~notToPersistList.indexOf(key as keyof ISpotifyStatusState)) { 25 | return null; 26 | } 27 | if (key === 'tracks') { 28 | return Map(val); 29 | } 30 | return val; 31 | }, 32 | in: (val: any, key: string) => { 33 | if (~notToPersistList.indexOf(key as keyof ISpotifyStatusState)) { 34 | return null; 35 | } 36 | return val; 37 | } 38 | }] 39 | }; 40 | const persistedReducer = persistReducer(persistConfig, rootReducer); 41 | 42 | store = createStore(persistedReducer, DEFAULT_STATE); 43 | persistStore(store); 44 | } 45 | return store; 46 | } 47 | 48 | export function getState() { 49 | return getStore().getState(); 50 | } 51 | 52 | /** 53 | * True if on last state of Spotify it was muted(volume was equal 0) 54 | */ 55 | export function isMuted() { 56 | const state = getState(); 57 | return state && state.playerState.volume === 0; 58 | } 59 | -------------------------------------------------------------------------------- /src/types/spotify-node-applescript.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'spotify-node-applescript' { 2 | /** 3 | * { 4 | * artist: 'Bob Dylan', 5 | * album: 'Highway 61 Revisited', 6 | * disc_number: 1, 7 | * duration: 370, 8 | * played count: 0, 9 | * track_number: 1, 10 | * starred: false, 11 | * popularity: 71, 12 | * id: 'spotify:track:3AhXZa8sUQht0UEdBJgpGc', 13 | * name: 'Like A Rolling Stone', 14 | * album_artist: 'Bob Dylan', 15 | * spotify_url: 'spotify:track:3AhXZa8sUQht0UEdBJgpGc' } 16 | * } 17 | */ 18 | export interface Track { 19 | artist: string; 20 | album: string; 21 | disc_number: number; 22 | duration: number; 23 | played_count: number; 24 | track_number: number; 25 | starred: boolean; 26 | popularity: number; 27 | id: string; 28 | name: string; 29 | album_artist: string; 30 | spotify_url: string; 31 | } 32 | /** 33 | * { 34 | * volume: 99, 35 | * position: 232, 36 | * state: 'playing' 37 | * } 38 | */ 39 | export interface State { 40 | volume: number; 41 | position: number; 42 | state: string; 43 | } 44 | 45 | /** 46 | * Open track 47 | */ 48 | export function open(uri: string, callback: () => void): void; 49 | /** 50 | * Play a track with Spotify URI uri. 51 | * spotify.playTrack('spotify:track:3AhXZa8sUQht0UEdBJgpGc', function(){// track is playing}); 52 | */ 53 | export function playTrack(uri: string, callback: () => void): void; 54 | /** 55 | * Play a track in context of for example an album. 56 | * playTrackInContext('spotify:track:0R8P9KfGJCDULmlEoBagcO', 'spotify:album:6ZG5lRT77aJ3btmArcykra', function(){ 57 | * // Track is playing in context of an album 58 | * }) 59 | */ 60 | export function playTrackInContext(uri: string, contextUri: string, callback: () => void): void; 61 | /** 62 | * Get the current track. callback is called with the current track as second argument. 63 | */ 64 | export function getTrack(callback: (err: any, track: Track) => void): void; 65 | /** 66 | * Get player state. 67 | */ 68 | export function getState(callback: (err: any, state: State) => void): void; 69 | /** 70 | * Jump to a specific second of the current song. 71 | */ 72 | export function jumpTo(second: number, callback: () => void): void; 73 | /** 74 | * Resume playing current track. 75 | */ 76 | export function play(callback: () => void): void; 77 | /** 78 | * Pause playing track. 79 | */ 80 | export function pause(callback: () => void): void; 81 | /** 82 | * Toggle play. 83 | */ 84 | export function playPause(callback: () => void): void; 85 | /** 86 | * Play next track. 87 | */ 88 | export function next(callback: () => void): void; 89 | /** 90 | * Play previous track. 91 | */ 92 | export function previous(callback: () => void): void; 93 | /** 94 | * Turn volume up. 95 | */ 96 | export function volumeUp(callback: () => void): void; 97 | /** 98 | * Turn volume down. 99 | */ 100 | export function volumeDown(callback: () => void): void; 101 | /** 102 | * Sets the volume. 103 | */ 104 | export function setVolume(volume: number, callback: () => void): void; 105 | /** 106 | * Reduces audio to 0, saving the previous volume. 107 | */ 108 | export function muteVolume(callback: () => void): void; 109 | /** 110 | * Returns audio to original volume. 111 | */ 112 | export function unmuteVolume(callback: () => void): void; 113 | /** 114 | * Check if Spotify is running. 115 | */ 116 | export function isRunning(callback: (err: any, isRunning: boolean) => void): void; 117 | /** 118 | * Is repeating on or off? 119 | */ 120 | export function isRepeating(callback: (err: any, isRepeating: boolean) => void): void; 121 | /** 122 | * Is shuffling on or off? 123 | */ 124 | export function isShuffling(callback: (err: any, isShuffling: boolean) => void): void; 125 | /** 126 | * Sets repeating on or off 127 | */ 128 | export function setRepeating(repeating: boolean, callback: (err: any) => void): void; 129 | /** 130 | * Sets shuffling on or off 131 | */ 132 | export function setShuffling(shuffling: boolean, callback: (err: any) => void): void; 133 | /** 134 | * Toggles repeating 135 | */ 136 | export function toggleRepeating(callback: (err: any) => void): void; 137 | /** 138 | * Toggles shuffling 139 | */ 140 | export function toggleShuffling(callback: (err: any) => void): void; 141 | } -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function artistsToArtist(artists: { name: string }[]): string { 2 | return artists.map((a => a.name)).join(', '); 3 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": [ 7 | "es6" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true, /* enable all strict type-checking options */ 12 | "experimentalDecorators": true, 13 | "skipLibCheck": true 14 | /* Additional Checks */ 15 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 16 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 17 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 18 | }, 19 | "exclude": [ 20 | "node_modules", 21 | ".vscode-test" 22 | ] 23 | } --------------------------------------------------------------------------------