├── .babelrc ├── .gitignore ├── .nvmrc ├── .vscode └── settings.json ├── @types ├── globals.d.ts └── jest-dom.d.ts ├── ISSUE_TEMPLATE.md ├── LICENSE.md ├── README.md ├── __mocks__ ├── fileMock.js └── styleMock.js ├── config └── entitlements.mac.plist ├── designs ├── icon.afdesign ├── tray_iconHighlight.afdesign └── tray_iconTemplate.afdesign ├── docs ├── CNAME ├── favicon.ico ├── favicon │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── images │ ├── screenshot-1.min.jpg │ └── screenshot-2.min.jpg ├── index.html ├── style.css └── version.js ├── package.json ├── scripts └── notarize.ts ├── src ├── config.ts ├── main │ ├── main.ts │ └── static │ │ └── icon │ │ ├── icon.icns │ │ ├── icon.png │ │ ├── png2icns.sh │ │ ├── tray_iconHighlight.png │ │ ├── tray_iconHighlight@2x.png │ │ ├── tray_iconTemplate.png │ │ └── tray_iconTemplate@2x.png ├── renderer │ ├── App.tsx │ ├── index.css │ ├── index.html │ └── index.tsx └── utils │ └── spotify.ts ├── tsconfig.json ├── webpack.main.config.js ├── webpack.renderer.config.js ├── webpack.rules.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ], 11 | "@babel/preset-react", 12 | "@babel/preset-typescript" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | out 4 | .webpack 5 | yarn-error.log 6 | .env 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "workbench.colorCustomizations": { 4 | "titleBar.activeBackground": "#2fd566", 5 | "titleBar.activeForeground": "#15202b", 6 | "titleBar.inactiveBackground": "#2fd56699", 7 | "titleBar.inactiveForeground": "#15202b99", 8 | "sash.hoverBorder": "#59de85" 9 | }, 10 | "peacock.color": "#2fd566" 11 | } 12 | -------------------------------------------------------------------------------- /@types/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' 2 | declare module '*.svg' 3 | -------------------------------------------------------------------------------- /@types/jest-dom.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-dom/extend-expect' 2 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present, Will Stone 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SpotSpot 2 | 3 | **SpotSpot is no longer maintained. It was a fun project, but I simply do not 4 | use it any more. A great alternative looks to be 5 | [Lofi](https://github.com/dvx/lofi) which is also more feature rich than 6 | SpotSpot.** 7 | 8 | ## Development 9 | 10 | `git clone git@github.com:will-stone/SpotSpot.git` 11 | 12 | `cd spotspot` 13 | 14 | `yarn` 15 | 16 | `yarn start` 17 | 18 | To package use `yarn run package` 19 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | // __mocks__/fileMock.js 2 | 3 | module.exports = 'test-file-stub' 4 | -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /config/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | 12 | -------------------------------------------------------------------------------- /designs/icon.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/designs/icon.afdesign -------------------------------------------------------------------------------- /designs/tray_iconHighlight.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/designs/tray_iconHighlight.afdesign -------------------------------------------------------------------------------- /designs/tray_iconTemplate.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/designs/tray_iconTemplate.afdesign -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | spotspot.wstone.uk -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/docs/favicon.ico -------------------------------------------------------------------------------- /docs/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/docs/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/docs/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/docs/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #191917 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/docs/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /docs/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/docs/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /docs/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/docs/favicon/favicon.ico -------------------------------------------------------------------------------- /docs/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/docs/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /docs/favicon/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SpotSpot", 3 | "short_name": "SpotSpot", 4 | "icons": [ 5 | { 6 | "src": "android-chrome-192x192.png?v=jw7XGzYW6l", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "android-chrome-512x512.png?v=jw7XGzYW6l", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /docs/images/screenshot-1.min.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/docs/images/screenshot-1.min.jpg -------------------------------------------------------------------------------- /docs/images/screenshot-2.min.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/docs/images/screenshot-2.min.jpg -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | SpotSpot 12 | 13 | 14 | 15 | 19 | 20 | 21 | 26 | 32 | 38 | 39 | 44 | 45 | 46 | 47 | 48 | 52 | 53 | 54 | 55 | 56 |
57 | 64 | 65 | 66 | 67 | 68 | 73 | 79 | 80 | 81 | 82 | 83 | 84 |

Spotify mini-player for macOS

85 | 86 |
87 | screenshot 88 | screenshot 89 |
90 |
91 | 92 |
93 |

94 | 95 | 110 | Source 111 | 112 |

113 | 114 |

115 | 116 | SpotSpot is not affiliated with Apple, Spotify or Badflower and these 117 | are the trademarks of the respective parties. 118 | 119 |

120 | 121 |

122 | 123 | Desktop background by 124 | @sidrik. 125 | 126 |

127 |
128 | 129 | 133 | 134 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: white; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, 4 | Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 5 | color: #363636; 6 | line-height: 1.6; 7 | font-size: 19px; 8 | text-align: center; 9 | margin: 0; 10 | } 11 | 12 | h2, 13 | p { 14 | margin: 0 0 2rem; 15 | } 16 | 17 | h2 { 18 | font-size: 3rem; 19 | font-weight: 100; 20 | } 21 | 22 | a { 23 | text-decoration: none; 24 | color: #ec45ab; 25 | } 26 | 27 | a:hover { 28 | text-decoration: underline; 29 | } 30 | 31 | header { 32 | background: linear-gradient(to bottom, #191917 80%, white 0%); 33 | padding: 3rem 1rem 0; 34 | color: white; 35 | } 36 | 37 | main { 38 | padding: 0 1rem 3rem; 39 | max-width: 700px; 40 | margin: 0 auto; 41 | } 42 | 43 | footer { 44 | border-top: 1px solid #2fd566; 45 | padding: 2rem 1rem 3rem; 46 | max-width: 700px; 47 | margin: 0 auto; 48 | } 49 | 50 | .button { 51 | display: inline-flex; 52 | align-items: center; 53 | justify-content: center; 54 | margin: 0.5rem; 55 | padding: 7px 12px; 56 | text-decoration: none; 57 | border: 4px solid #8c8c8b; 58 | background: transparent; 59 | font-weight: 600; 60 | color: #8c8c8b; 61 | transition: all 150ms ease-in-out; 62 | } 63 | 64 | .button:hover { 65 | background-color: #2fd566; 66 | border-color: #2fd566; 67 | color: white; 68 | text-decoration: none; 69 | } 70 | 71 | .button--pink { 72 | border-color: #ec45ab; 73 | color: #ec45ab; 74 | } 75 | 76 | .button--pink:hover { 77 | background-color: #ec45ab; 78 | border-color: #ec45ab; 79 | color: white; 80 | } 81 | 82 | .button > svg { 83 | margin-right: 8px; 84 | } 85 | 86 | .justify { 87 | text-align: justify; 88 | } 89 | 90 | @keyframes blob1Anim { 91 | 0% { 92 | transform: scale(calc(1 / 3)) translate(-150%, 150%); 93 | } 94 | 25% { 95 | transform: scale(1.5) translate(0, 0); 96 | } 97 | 50% { 98 | transform: scale(calc(1 / 3)) translate(-150%, 150%); 99 | } 100 | 100% { 101 | transform: scale(calc(1 / 3)) translate(-150%, 150%); 102 | } 103 | } 104 | 105 | @keyframes blob2Anim { 106 | 0% { 107 | transform: scale(1) translate(50%, -50%); 108 | } 109 | 25% { 110 | transform: scale(1.5) translate(0, 0); 111 | } 112 | 50% { 113 | transform: scale(1) translate(50%, -50%); 114 | } 115 | 100% { 116 | transform: scale(1) translate(50%, -50%); 117 | } 118 | } 119 | 120 | @keyframes fadeOut { 121 | 0% { 122 | opacity: 1; 123 | } 124 | 100% { 125 | opacity: 0.5; 126 | } 127 | } 128 | 129 | @keyframes fadeIn { 130 | 0% { 131 | opacity: 0; 132 | } 133 | 100% { 134 | opacity: 1; 135 | } 136 | } 137 | 138 | .logo { 139 | height: 200px; 140 | width: 200px; 141 | position: relative; 142 | background-size: cover; 143 | border-radius: 5px; 144 | margin-left: auto; 145 | margin-right: auto; 146 | } 147 | 148 | .blobs { 149 | filter: url('#goo'); 150 | position: absolute; 151 | top: 0; 152 | left: 0; 153 | bottom: 0; 154 | right: 0; 155 | animation-name: fadeOut; 156 | animation-duration: 500ms; 157 | animation-delay: 3s; 158 | animation-fill-mode: forwards; 159 | opacity: 1; 160 | } 161 | 162 | .blob { 163 | position: absolute; 164 | background: #2fd566; 165 | left: 50%; 166 | top: 50%; 167 | width: 30%; 168 | height: 30%; 169 | border-radius: 100%; 170 | margin-top: -15%; 171 | margin-left: -15%; 172 | will-change: contents; 173 | animation-timing-function: cubic-bezier(0.77, 0, 0.175, 1); 174 | animation-duration: 6s; 175 | animation-iteration-count: infinite; 176 | } 177 | 178 | .blob1 { 179 | animation-name: blob1Anim; 180 | } 181 | 182 | .blob2 { 183 | animation-name: blob2Anim; 184 | } 185 | 186 | .title { 187 | position: absolute; 188 | top: 0; 189 | left: 0; 190 | bottom: 0; 191 | right: 0; 192 | color: white; 193 | animation-name: fadeIn; 194 | animation-duration: 500ms; 195 | animation-delay: 3s; 196 | animation-fill-mode: forwards; 197 | z-index: 2; 198 | opacity: 0; 199 | display: flex; 200 | justify-content: center; 201 | align-items: center; 202 | font-weight: 100; 203 | font-size: 5rem; 204 | font-family: 'Passion One', -apple-system, BlinkMacSystemFont, 'Segoe UI', 205 | Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 206 | 'Helvetica Neue', sans-serif; 207 | line-height: 1; 208 | } 209 | 210 | #logo-filter { 211 | /* hide the space taken up by the filter svg; display none causes the blobs not to show */ 212 | height: 0; 213 | } 214 | 215 | .tagline { 216 | margin-bottom: 4rem; 217 | } 218 | 219 | .screenshots { 220 | position: relative; 221 | max-width: 576px; 222 | width: 100%; 223 | margin: 0 auto 2rem auto; 224 | } 225 | 226 | .screenshots img { 227 | width: 100%; 228 | border-bottom-left-radius: 8px; 229 | border-bottom-right-radius: 8px; 230 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 231 | 0 4px 6px -2px rgba(0, 0, 0, 0.05); 232 | } 233 | 234 | @keyframes fade { 235 | 0% { 236 | opacity: 1; 237 | } 238 | 45% { 239 | opacity: 1; 240 | } 241 | 55% { 242 | opacity: 0; 243 | } 244 | 100% { 245 | opacity: 0; 246 | } 247 | } 248 | 249 | .screenshots img:last-child { 250 | position: absolute; 251 | left: 0; 252 | width: 100%; 253 | animation-name: fade; 254 | animation-timing-function: ease-in-out; 255 | animation-iteration-count: infinite; 256 | animation-duration: 5s; 257 | animation-direction: alternate; 258 | } 259 | -------------------------------------------------------------------------------- /docs/version.js: -------------------------------------------------------------------------------- 1 | const version = '4.0.2' 2 | 3 | const downloadButton = document.querySelector('#js-download-button') 4 | downloadButton.href = `https://github.com/will-stone/SpotSpot/releases/download/v${version}/SpotSpot-${version}.dmg` 5 | 6 | const downloadButtonText = document.querySelector('#js-download-button-text') 7 | downloadButtonText.innerHTML = `Download v${version}` 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spotspot", 3 | "version": "4.0.2", 4 | "description": "Spotify mini-player for macOS", 5 | "keywords": [ 6 | "Electron", 7 | "Spotify", 8 | "mini-player", 9 | "macOS" 10 | ], 11 | "homepage": "https://will-stone.github.io/SpotSpot/", 12 | "bugs": { 13 | "url": "https://github.com/will-stone/SpotSpot/issues" 14 | }, 15 | "repository": "https://github.com/will-stone/SpotSpot", 16 | "license": "MIT", 17 | "author": "Will Stone", 18 | "main": ".webpack/main", 19 | "scripts": { 20 | "docs": "npx http-server ./docs", 21 | "icns": "cd ./src/main/static/icon && ./png2icns.sh icon.png", 22 | "lint": "eslint . --ignore-path .gitignore", 23 | "make": "electron-forge make --skip-package", 24 | "notarize": "ts-node ./scripts/notarize.ts", 25 | "prepackage": "rimraf ./out/", 26 | "package": "electron-forge package --platform=darwin --arch=x64", 27 | "release": "yarn version && yarn package && yarn notarize && yarn make", 28 | "start": "ENV=DEV ELECTRON_DISABLE_SECURITY_WARNINGS=true electron-forge start", 29 | "test": "jest", 30 | "typecheck": "tsc --noEmit --skipLibCheck" 31 | }, 32 | "husky": { 33 | "hooks": { 34 | "pre-commit": "lint-staged" 35 | } 36 | }, 37 | "lint-staged": { 38 | "*.{css,json,md}": [ 39 | "prettier --write" 40 | ], 41 | "*.{js,jsx,ts,tsx}": [ 42 | "eslint --fix" 43 | ] 44 | }, 45 | "config": { 46 | "forge": { 47 | "packagerConfig": { 48 | "appBundleId": "io.wstone.spotspot", 49 | "asar": true, 50 | "appCategoryType": "public.app-category.music", 51 | "packageManager": "yarn", 52 | "osxSign": { 53 | "gatekeeper-assess": false, 54 | "hardened-runtime": true, 55 | "entitlements": "config/entitlements.mac.plist", 56 | "entitlements-inherit": "config/entitlements.mac.plist" 57 | }, 58 | "icon": "src/main/static/icon/icon.icns" 59 | }, 60 | "makers": [ 61 | { 62 | "name": "@electron-forge/maker-zip", 63 | "platforms": [ 64 | "darwin" 65 | ] 66 | }, 67 | { 68 | "name": "@electron-forge/maker-dmg", 69 | "config": { 70 | "format": "ULFO" 71 | } 72 | } 73 | ], 74 | "plugins": [ 75 | [ 76 | "@electron-forge/plugin-webpack", 77 | { 78 | "mainConfig": "./webpack.main.config.js", 79 | "renderer": { 80 | "config": "./webpack.renderer.config.js", 81 | "entryPoints": [ 82 | { 83 | "html": "./src/renderer/index.html", 84 | "js": "./src/renderer/index.tsx", 85 | "name": "main_window" 86 | } 87 | ] 88 | } 89 | } 90 | ] 91 | ] 92 | } 93 | }, 94 | "prettier": "@will-stone/prettier-config", 95 | "eslintConfig": { 96 | "extends": [ 97 | "@will-stone/eslint-config", 98 | "@will-stone/eslint-config/node", 99 | "@will-stone/eslint-config/react", 100 | "@will-stone/eslint-config/jest", 101 | "@will-stone/eslint-config/typescript" 102 | ] 103 | }, 104 | "jest": { 105 | "moduleNameMapper": { 106 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", 107 | "\\.(css|less)$": "/__mocks__/styleMock.js" 108 | }, 109 | "modulePathIgnorePatterns": [ 110 | "/docs/", 111 | "/out/" 112 | ], 113 | "setupFilesAfterEnv": [ 114 | "@testing-library/jest-dom/extend-expect" 115 | ], 116 | "testPathIgnorePatterns": [ 117 | "/node_modules/", 118 | "/docs/", 119 | "/out/" 120 | ] 121 | }, 122 | "dependencies": { 123 | "@fortawesome/fontawesome-svg-core": "^1.2.25", 124 | "@fortawesome/free-solid-svg-icons": "^5.11.2", 125 | "@fortawesome/react-fontawesome": "^0.1.7", 126 | "electron-is-dev": "^1.1.0", 127 | "electron-squirrel-startup": "^1.0.0", 128 | "electron-store": "^5.1.1", 129 | "execa": "^4.0.0", 130 | "react": "^16.13.1", 131 | "react-dom": "^16.13.1", 132 | "react-spring": "^8.0.27", 133 | "update-electron-app": "^1.5.0" 134 | }, 135 | "devDependencies": { 136 | "@babel/core": "^7.9.0", 137 | "@babel/preset-env": "^7.9.0", 138 | "@babel/preset-react": "^7.9.4", 139 | "@babel/preset-typescript": "^7.9.0", 140 | "@electron-forge/cli": "6.0.0-beta.51", 141 | "@electron-forge/maker-deb": "6.0.0-beta.51", 142 | "@electron-forge/maker-dmg": "^6.0.0-beta.51", 143 | "@electron-forge/maker-rpm": "6.0.0-beta.51", 144 | "@electron-forge/maker-squirrel": "6.0.0-beta.51", 145 | "@electron-forge/maker-zip": "6.0.0-beta.51", 146 | "@electron-forge/plugin-webpack": "6.0.0-beta.51", 147 | "@marshallofsound/webpack-asset-relocator-loader": "^0.5.0", 148 | "@testing-library/jest-dom": "^5.9.0", 149 | "@testing-library/react": "^10.2.0", 150 | "@types/jest": "^24.0.25", 151 | "@types/node": "^12.0.4", 152 | "@types/react": "^16.9.17", 153 | "@types/react-dom": "^16.9.4", 154 | "@will-stone/eslint-config": "^1.13.1", 155 | "@will-stone/prettier-config": "^3.1.0", 156 | "copy-webpack-plugin": "^5.0.3", 157 | "css-loader": "^3.2.0", 158 | "dotenv": "^8.2.0", 159 | "electron": "^8.0.0", 160 | "electron-notarize": "^0.2.0", 161 | "eslint": "^7.2.0", 162 | "fork-ts-checker-webpack-plugin": "^4.1.6", 163 | "husky": "^4.2.5", 164 | "jest": "^25.1.0", 165 | "lint-staged": "^10.2.2", 166 | "mini-css-extract-plugin": "^0.8.0", 167 | "node-loader": "^0.6.0", 168 | "prettier": "^2.0.5", 169 | "rimraf": "^3.0.2", 170 | "style-loader": "^1.0.0", 171 | "ts-loader": "^7.0.5", 172 | "ts-node": "^8.3.0", 173 | "typescript": "^3.9.5", 174 | "url-loader": "^2.0.0", 175 | "webpack": "^4.40.2" 176 | }, 177 | "engines": { 178 | "node": ">=10.4.1" 179 | }, 180 | "productName": "SpotSpot" 181 | } 182 | -------------------------------------------------------------------------------- /scripts/notarize.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import { notarize } from 'electron-notarize' 3 | import path from 'path' 4 | 5 | config() 6 | 7 | notarize({ 8 | appBundleId: 'io.wstone.spotspot', 9 | appPath: path.resolve( 10 | __dirname, 11 | '..', 12 | `out/SpotSpot-darwin-x64/SpotSpot.app`, 13 | ), 14 | appleId: String(process.env.APPLE_ID), 15 | appleIdPassword: '@keychain:AC_PASSWORD', 16 | // Team ID 17 | ascProvider: 'Z89KPMLTFR', 18 | }).catch((error) => { 19 | // eslint-disable-next-line no-console 20 | console.error("Notarization didn't work :(", error.message) 21 | // eslint-disable-next-line unicorn/no-process-exit 22 | process.exit(1) 23 | }) 24 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const BLACK = '#191917' 2 | export const GREEN = '#2fd566' 3 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | app, 3 | BrowserWindow, 4 | Menu, 5 | Rectangle, 6 | systemPreferences, 7 | Tray, 8 | } from 'electron' 9 | import Store from 'electron-store' 10 | import path from 'path' 11 | 12 | import { BLACK } from '../config' 13 | 14 | // Autp update 15 | // eslint-disable-next-line @typescript-eslint/no-var-requires 16 | require('update-electron-app')({ 17 | repo: 'will-stone/SpotSpot', 18 | }) 19 | 20 | declare const MAIN_WINDOW_WEBPACK_ENTRY: string 21 | declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string 22 | 23 | const store = new Store() 24 | 25 | const DEFAULT_BOUNDS: Rectangle = { 26 | x: 0, 27 | y: 0, 28 | width: 100, 29 | height: 100, 30 | } 31 | 32 | const bounds = store.get('bounds', DEFAULT_BOUNDS) as Rectangle 33 | 34 | // Prevents garbage collection 35 | let bWindow: Electron.BrowserWindow | undefined 36 | let tray: Tray | undefined 37 | 38 | function createMainWindow() { 39 | bWindow = new BrowserWindow({ 40 | x: bounds.x, 41 | y: bounds.y, 42 | width: bounds.width, 43 | minWidth: 100, 44 | maxWidth: 400, 45 | height: bounds.height, 46 | minHeight: 100, 47 | maxHeight: 400, 48 | acceptFirstMouse: true, 49 | alwaysOnTop: true, 50 | icon: path.join(__dirname, '/static/icon/icon.png'), 51 | focusable: false, 52 | frame: false, 53 | resizable: true, 54 | // prevents flash of white 55 | show: false, 56 | title: 'SpotSpot', 57 | webPreferences: { 58 | nodeIntegration: true, 59 | preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY, 60 | }, 61 | backgroundColor: BLACK, 62 | }) 63 | 64 | // and load the index.html of the app. 65 | bWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY) 66 | 67 | // Menubar icon 68 | tray = new Tray(path.join(__dirname, '/static/icon/tray_iconTemplate.png')) 69 | tray.setPressedImage( 70 | path.join(__dirname, '/static/icon/tray_iconHighlight.png'), 71 | ) 72 | const contextMenu = Menu.buildFromTemplate([ 73 | { 74 | label: 'About', 75 | click: app.showAboutPanel, 76 | }, 77 | { 78 | label: 'Quit', 79 | click() { 80 | app.quit() 81 | }, 82 | }, 83 | ]) 84 | tray.setToolTip('SpotSpot') 85 | tray.setContextMenu(contextMenu) 86 | 87 | // Open the DevTools. 88 | if (process.env.ENV === 'DEV') { 89 | bWindow.webContents.openDevTools({ mode: 'detach' }) 90 | } 91 | 92 | // Hide dock icon 93 | app.dock.hide() 94 | 95 | // Move window across desktops when switching 96 | bWindow.setVisibleOnAllWorkspaces(true) 97 | 98 | // Maintain square window ratio 99 | bWindow.setAspectRatio(1, { width: 0, height: 0 }) 100 | 101 | // Only show window when it's ready; prevents flash of white 102 | bWindow.on('ready-to-show', () => { 103 | bWindow?.show() 104 | }) 105 | 106 | // Emitted when the window is closed. 107 | bWindow.on('closed', () => { 108 | bWindow = undefined 109 | }) 110 | 111 | bWindow.on('resize', () => { 112 | const mainWindowBounds = bWindow?.getBounds() || DEFAULT_BOUNDS 113 | store.set('bounds', mainWindowBounds) 114 | }) 115 | 116 | bWindow.on('moved', () => { 117 | const mainWindowBounds = bWindow?.getBounds() || DEFAULT_BOUNDS 118 | store.set('bounds', mainWindowBounds) 119 | }) 120 | } 121 | 122 | // System events 123 | systemPreferences.subscribeNotification( 124 | 'com.spotify.client.PlaybackStateChanged', 125 | (_, { 'Player State': playerState }) => { 126 | if (typeof playerState === 'string') { 127 | bWindow?.webContents.send( 128 | 'PlaybackStateChanged', 129 | playerState.toLowerCase(), 130 | ) 131 | } 132 | }, 133 | ) 134 | 135 | // This method will be called when Electron has finished 136 | // initialization and is ready to create browser windows. 137 | // Some APIs can only be used after this event occurs. 138 | app.on('ready', createMainWindow) 139 | -------------------------------------------------------------------------------- /src/main/static/icon/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/src/main/static/icon/icon.icns -------------------------------------------------------------------------------- /src/main/static/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/src/main/static/icon/icon.png -------------------------------------------------------------------------------- /src/main/static/icon/png2icns.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # chmod +x png2icns.sh 4 | # ./png2icns.sh icon.png 5 | 6 | # Exec Paths 7 | SIPS='/usr/bin/sips' 8 | ICONUTIL='/usr/bin/iconutil' 9 | if [ ! -x "${SIPS}" ]; then 10 | echo "Cannot find required SIPS executable at: ${SIPS}" >&2 11 | exit 1; 12 | fi 13 | if [ ! -x "${ICONUTIL}" ]; then 14 | echo "Cannot find required ICONUTIL executable at: ${ICONUTIL}" >&2 15 | exit 1; 16 | fi 17 | 18 | # Parameters 19 | SOURCE=$1 20 | 21 | # Get source image 22 | if [ -z "${SOURCE}" ]; then 23 | echo "No source image specified, searching in current directory...\c" 24 | SOURCE=$( ls *.png | head -n1 ) 25 | if [ -z "${SOURCE}" ]; then 26 | echo "No source image specified and none found." 27 | exit 1; 28 | else 29 | echo "FOUND"; 30 | fi 31 | fi 32 | 33 | 34 | # File Infrastructure 35 | NAME=$(basename "${SOURCE}") 36 | EXT="${NAME##*.}" 37 | BASE="${NAME%.*}" 38 | ICONSET="${BASE}.iconset" 39 | 40 | # Debug Info 41 | echo "SOURCE: ${SOURCE}" 42 | echo "NAME: $NAME" 43 | echo "BASE: $BASE" 44 | echo "EXT: $EXT" 45 | echo "ICONSET: $ICONSET" 46 | 47 | # Get source image info 48 | SRCWIDTH=$( $SIPS -g pixelWidth "${SOURCE}" | tail -n1 | awk '{print $2}') 49 | SRCHEIGHT=$( $SIPS -g pixelHeight "${SOURCE}" | tail -n1 | awk '{print $2}' ) 50 | SRCFORMAT=$( $SIPS -g format "${SOURCE}" | tail -n1 | awk '{print $2}' ) 51 | 52 | # Debug Info 53 | echo "SRCWIDTH: $SRCWIDTH" 54 | echo "SRCHEIGHT: $SRCHEIGHT" 55 | echo "SRCFORMAT: $SRCFORMAT" 56 | 57 | # Check The Source Image 58 | if [ "x${SRCWIDTH}" != "x1024" ] || [ "x${SRCHEIGHT}" != "x1024" ]; then 59 | echo "ERR: Source image should be 1024 x 1024 pixels." >&2 60 | exit 1; 61 | fi 62 | if [ "x${SRCFORMAT}" != "xpng" ]; then 63 | echo "ERR: Source image format should be png." >&2 64 | exit 1; 65 | fi 66 | 67 | # Resample image into iconset 68 | mkdir "${ICONSET}" 69 | $SIPS -s format png --resampleWidth 1024 "${SOURCE}" --out "${ICONSET}/icon_512x512@2x.png" > /dev/null 2>&1 70 | $SIPS -s format png --resampleWidth 512 "${SOURCE}" --out "${ICONSET}/icon_512x512.png" > /dev/null 2>&1 71 | cp "${ICONSET}/icon_512x512.png" "${ICONSET}/icon_256x256@2x.png" 72 | $SIPS -s format png --resampleWidth 256 "${SOURCE}" --out "${ICONSET}/icon_256x256.png" > /dev/null 2>&1 73 | cp "${ICONSET}/icon_256x256.png" "${ICONSET}/icon_128x128@2x.png" 74 | $SIPS -s format png --resampleWidth 128 "${SOURCE}" --out "${ICONSET}/icon_128x128.png" > /dev/null 2>&1 75 | $SIPS -s format png --resampleWidth 64 "${SOURCE}" --out "${ICONSET}/icon_32x32@2x.png" > /dev/null 2>&1 76 | $SIPS -s format png --resampleWidth 32 "${SOURCE}" --out "${ICONSET}/icon_32x32.png" > /dev/null 2>&1 77 | cp "${ICONSET}/icon_32x32.png" "${ICONSET}/icon_16x16@2x.png" 78 | $SIPS -s format png --resampleWidth 16 "${SOURCE}" --out "${ICONSET}/icon_16x16.png" > /dev/null 2>&1 79 | 80 | # Create an icns file from the iconset 81 | $ICONUTIL -c icns "${ICONSET}" 82 | 83 | # Clean up the iconset 84 | rm -rf "${ICONSET}" 85 | -------------------------------------------------------------------------------- /src/main/static/icon/tray_iconHighlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/src/main/static/icon/tray_iconHighlight.png -------------------------------------------------------------------------------- /src/main/static/icon/tray_iconHighlight@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/src/main/static/icon/tray_iconHighlight@2x.png -------------------------------------------------------------------------------- /src/main/static/icon/tray_iconTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/src/main/static/icon/tray_iconTemplate.png -------------------------------------------------------------------------------- /src/main/static/icon/tray_iconTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/SpotSpot/be9176298f8b7a22ac61eeb273e0a03f3b87a264/src/main/static/icon/tray_iconTemplate@2x.png -------------------------------------------------------------------------------- /src/renderer/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faPause, 3 | faPlay, 4 | faStepBackward, 5 | faStepForward, 6 | } from '@fortawesome/free-solid-svg-icons' 7 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 8 | import { spawn } from 'child_process' 9 | import { ipcRenderer } from 'electron' 10 | import React, { useCallback, useEffect, useState } from 'react' 11 | import { animated, useSpring, useTransition } from 'react-spring' 12 | 13 | import { 14 | getIsRunning, 15 | getPlayerState, 16 | getTrackInfo, 17 | next, 18 | playPause, 19 | previous, 20 | SpotifyPlayingState, 21 | TrackInfo, 22 | } from '../utils/spotify' 23 | 24 | let detailsTimeout: NodeJS.Timer 25 | 26 | const handleDoubleClick = () => spawn('open', ['-a', 'spotify']) 27 | 28 | const stopPropagation = (event: React.MouseEvent) => 29 | event.stopPropagation() 30 | 31 | const AppContainer: React.FC = () => { 32 | const [isControlsTimingOut, setIsControlsTimingOut] = useState(false) 33 | const [isLoaded, setIsLoaded] = useState(false) 34 | const [isMouseOver, setIsMouseOver] = useState(false) 35 | const [playerState, setPlayerState] = useState('stopped') 36 | const [track, setTrack] = useState() 37 | 38 | const showControls = useCallback(() => { 39 | clearTimeout(detailsTimeout) 40 | setIsControlsTimingOut(true) 41 | }, [setIsControlsTimingOut]) 42 | 43 | const hideControls = useCallback(() => { 44 | detailsTimeout = global.setTimeout(() => { 45 | setIsControlsTimingOut(false) 46 | }, 7000) 47 | }, [setIsControlsTimingOut]) 48 | 49 | // Init 50 | useEffect(() => { 51 | const init = async () => { 52 | const isRunning = await getIsRunning() 53 | if (isRunning) { 54 | const [pState, trackInfo] = await Promise.all([ 55 | getPlayerState(), 56 | getTrackInfo(), 57 | ]) 58 | setPlayerState(pState) 59 | setTrack(trackInfo) 60 | } 61 | 62 | setTimeout(() => { 63 | setIsLoaded(true) 64 | }, 3500) 65 | } 66 | 67 | init() 68 | 69 | ipcRenderer.on( 70 | 'PlaybackStateChanged', 71 | async (_: unknown, pState: SpotifyPlayingState) => { 72 | if (pState !== 'stopped') { 73 | setTrack(await getTrackInfo()) 74 | } 75 | 76 | setPlayerState(pState) 77 | showControls() 78 | hideControls() 79 | }, 80 | ) 81 | }, [setPlayerState, setTrack, setIsLoaded, showControls, hideControls]) 82 | 83 | const handleMouseEnter = useCallback(() => { 84 | setIsMouseOver(true) 85 | showControls() 86 | }, [setIsMouseOver, showControls]) 87 | 88 | const handleMouseLeave = useCallback(() => { 89 | setIsMouseOver(false) 90 | hideControls() 91 | }, [setIsMouseOver, hideControls]) 92 | 93 | const isLogoShown = !isLoaded || playerState === 'stopped' 94 | const isOverlayShown = 95 | isControlsTimingOut || playerState === 'paused' || isMouseOver 96 | const isDisplayingPaused = playerState === 'paused' && !isMouseOver 97 | 98 | const trackDetailsStyles = useSpring({ 99 | transform: `translateY(${isOverlayShown ? '0%' : '-100%'})`, 100 | }) 101 | 102 | const controlsStyles = useSpring({ 103 | transform: `translateY(${isOverlayShown ? '0%' : '100%'})`, 104 | }) 105 | 106 | const logoAlbumArtTransitions = useTransition( 107 | !track || isLogoShown, 108 | // eslint-disable-next-line unicorn/no-null 109 | null, 110 | { 111 | from: { opacity: 0 }, 112 | enter: { opacity: 1 }, 113 | leave: { opacity: 0 }, 114 | }, 115 | ) 116 | 117 | return ( 118 |
124 | {logoAlbumArtTransitions.map(({ item, key, props }) => 125 | item ? ( 126 | 127 |
128 |
129 |
130 |
131 | 132 | 133 | 134 | 139 | 144 | 145 | 146 | 147 | 148 | 149 | ) : ( 150 | 158 | ), 159 | )} 160 | 161 | {track && !isLogoShown && ( 162 | <> 163 | 164 |
{track.name}
165 |
{track.artist}
166 |
167 | 168 | 169 | {isDisplayingPaused ? ( 170 | 'PAUSED' 171 | ) : ( 172 | <> 173 | 181 | 193 | 201 | 202 | )} 203 | 204 | 205 | )} 206 |
207 | ) 208 | } 209 | 210 | export default AppContainer 211 | -------------------------------------------------------------------------------- /src/renderer/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --black: #191917; 3 | --green: #2fd566; 4 | } 5 | 6 | * { 7 | box-sizing: border-box; 8 | } 9 | 10 | html, 11 | body { 12 | background-color: transparent; /* prevents white flash */ 13 | height: 100%; 14 | } 15 | 16 | body { 17 | -webkit-app-region: drag; 18 | background-color: var(--black); 19 | color: white; 20 | font-family: sans-serif; 21 | font-size: calc(12px + (5 + 12) * (100vw - 100px) / (400 - 100)); 22 | font-weight: 300; 23 | margin: 0; 24 | overflow: hidden; 25 | position: relative; 26 | text-align: center; 27 | user-select: none; 28 | } 29 | 30 | #root { 31 | position: absolute; 32 | top: 0; 33 | right: 0; 34 | bottom: 0; 35 | left: 0; 36 | } 37 | 38 | .app { 39 | height: 100%; 40 | width: 100%; 41 | } 42 | 43 | @keyframes blob1Anim { 44 | 0% { 45 | transform: scale(calc(1 / 3)) translate(-150%, 150%); 46 | } 47 | 50% { 48 | transform: scale(1.5) translate(0, 0); 49 | } 50 | 100% { 51 | transform: scale(calc(1 / 3)) translate(-150%, 150%); 52 | } 53 | } 54 | 55 | @keyframes blob2Anim { 56 | 0% { 57 | transform: scale(1) translate(50%, -50%); 58 | } 59 | 50% { 60 | transform: scale(1.5) translate(0, 0); 61 | } 62 | 100% { 63 | transform: scale(1) translate(50%, -50%); 64 | } 65 | } 66 | 67 | .logo { 68 | height: 100%; 69 | width: 100%; 70 | position: relative; 71 | } 72 | 73 | .logo__goo { 74 | filter: url('#goo'); 75 | position: absolute; 76 | top: 0; 77 | left: 0; 78 | bottom: 0; 79 | right: 0; 80 | } 81 | 82 | .logo__blob1 { 83 | position: absolute; 84 | background: var(--green); 85 | left: 50%; 86 | top: 50%; 87 | width: 30%; 88 | height: 30%; 89 | border-radius: 100%; 90 | margin-top: -15%; 91 | margin-left: -15%; 92 | animation: blob1Anim cubic-bezier(0.77, 0, 0.175, 1) 3s forwards; 93 | } 94 | 95 | .logo__blob2 { 96 | position: absolute; 97 | background: var(--green); 98 | left: 50%; 99 | top: 50%; 100 | width: 30%; 101 | height: 30%; 102 | border-radius: 100%; 103 | margin-top: -15%; 104 | margin-left: -15%; 105 | animation: blob2Anim cubic-bezier(0.77, 0, 0.175, 1) 3s forwards; 106 | } 107 | 108 | .albumArt { 109 | position: absolute; 110 | top: 0; 111 | right: 0; 112 | bottom: 0; 113 | left: 0; 114 | background-size: cover; 115 | } 116 | 117 | .trackDetails { 118 | width: 100%; 119 | height: 70%; 120 | background-color: var(--black); 121 | position: absolute; 122 | top: 0; 123 | left: 0; 124 | display: flex; 125 | flex-direction: column; 126 | justify-content: center; 127 | align-items: center; 128 | padding: 5%; 129 | } 130 | 131 | .trackDetails__artist { 132 | color: var(--green); 133 | } 134 | 135 | .trackDetails__name, 136 | .trackDetails__artist { 137 | padding: 2%; 138 | 139 | display: -webkit-box; 140 | overflow: hidden; 141 | cursor: default; 142 | text-overflow: ellipsis; 143 | 144 | -webkit-box-orient: vertical; 145 | -webkit-line-clamp: 2; 146 | } 147 | 148 | .controls { 149 | width: 100%; 150 | height: 30%; 151 | background-color: var(--black); 152 | position: absolute; 153 | bottom: 0; 154 | left: 0; 155 | display: flex; 156 | flex-shrink: 0; 157 | align-items: center; 158 | justify-content: center; 159 | color: rgba(255, 255, 255, 0.5); 160 | padding: 0 10%; 161 | } 162 | 163 | .controls__button { 164 | flex-grow: 1; 165 | width: calc(100% / 3); 166 | transition: opacity 100ms linear; 167 | opacity: 0.5; 168 | color: white; 169 | border: 0; 170 | outline: none; 171 | background-color: transparent; 172 | font-size: calc(14px + (24 + 14) * (100vw - 100px) / (400 - 100)); 173 | cursor: pointer; 174 | } 175 | 176 | .controls__button:hover { 177 | opacity: 1; 178 | } 179 | 180 | .controls__button:active { 181 | opacity: 0.5; 182 | } 183 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SpotSpot 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.css' 2 | 3 | import * as React from 'react' 4 | import * as ReactDOM from 'react-dom' 5 | 6 | import App from './App' 7 | 8 | ReactDOM.render(, document.querySelector('#root')) 9 | -------------------------------------------------------------------------------- /src/utils/spotify.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa' 2 | 3 | export interface TrackInfo { 4 | artist: string 5 | artworkUrl: string 6 | name: string 7 | } 8 | 9 | async function getArtist(): Promise { 10 | const { stdout: artist } = await execa('osascript', [ 11 | '-e', 12 | 'tell application "Spotify" to return current track\'s artist', 13 | ]) 14 | return artist 15 | } 16 | 17 | async function getName(): Promise { 18 | const { stdout: name } = await execa('osascript', [ 19 | '-e', 20 | 'tell application "Spotify" to return current track\'s name', 21 | ]) 22 | return name 23 | } 24 | 25 | async function getArtworkUrl(): Promise { 26 | const { stdout: artworkUrl } = await execa('osascript', [ 27 | '-e', 28 | 'tell application "Spotify" to return current track\'s artwork url', 29 | ]) 30 | return artworkUrl 31 | } 32 | 33 | export async function getTrackInfo(): Promise { 34 | const [artist, name, artworkUrl] = await Promise.all([ 35 | getArtist(), 36 | getName(), 37 | getArtworkUrl(), 38 | ]) 39 | return { artist, name, artworkUrl } 40 | } 41 | 42 | export type SpotifyPlayingState = 'playing' | 'paused' | 'stopped' 43 | 44 | export async function getPlayerState(): Promise { 45 | const { stdout } = await execa('osascript', [ 46 | '-e', 47 | 'tell application "Spotify" to return player state', 48 | ]) 49 | if (String(stdout) === 'playing') return 'playing' 50 | if (String(stdout) === 'paused') return 'paused' 51 | return 'stopped' 52 | } 53 | 54 | export async function getIsRunning(): Promise { 55 | const { stdout } = await execa('osascript', [ 56 | '-e', 57 | 'tell application "System Events" to (name of processes) contains "Spotify"', 58 | ]) 59 | return stdout === 'true' 60 | } 61 | 62 | export async function playPause(): Promise { 63 | await execa('osascript', ['-e', 'tell application "Spotify" to playpause']) 64 | } 65 | 66 | export async function next(): Promise { 67 | await execa('osascript', ['-e', 'tell application "Spotify" to next track']) 68 | } 69 | 70 | export async function previous(): Promise { 71 | await execa('osascript', [ 72 | '-e', 73 | 'tell application "Spotify" to previous track', 74 | ]) 75 | } 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "skipLibCheck": true, 7 | "noImplicitAny": true, 8 | "sourceMap": true, 9 | "strict": true, 10 | "baseUrl": ".", 11 | "outDir": "dist", 12 | "paths": { 13 | "*": ["node_modules/*"] 14 | }, 15 | "resolveJsonModule": true, 16 | "esModuleInterop": true 17 | }, 18 | "include": ["src/**/*", "@types/*"] 19 | } 20 | -------------------------------------------------------------------------------- /webpack.main.config.js: -------------------------------------------------------------------------------- 1 | const CopyPlugin = require('copy-webpack-plugin') 2 | const rules = require('./webpack.rules') 3 | 4 | module.exports = { 5 | /** 6 | * This is the main entry point for your application, it's the first file 7 | * that runs in the main process. 8 | */ 9 | entry: './src/main/main.ts', 10 | // Put your normal webpack config below here 11 | module: { 12 | rules, 13 | }, 14 | resolve: { 15 | extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], 16 | }, 17 | plugins: [new CopyPlugin([{ from: 'src/main/static', to: 'static' }])], 18 | } 19 | -------------------------------------------------------------------------------- /webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | const rules = require('./webpack.rules') 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 3 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin') 4 | 5 | module.exports = { 6 | module: { 7 | rules, 8 | }, 9 | plugins: [ 10 | new ForkTsCheckerWebpackPlugin(), 11 | new MiniCssExtractPlugin({ 12 | filename: 'main_window/style.css', 13 | }), 14 | ], 15 | resolve: { 16 | extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /webpack.rules.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 2 | 3 | module.exports = [ 4 | { 5 | test: /\.node$/u, 6 | use: 'node-loader', 7 | }, 8 | { 9 | test: /\.(m?js|node)$/u, 10 | parser: { amd: false }, 11 | use: { 12 | loader: '@marshallofsound/webpack-asset-relocator-loader', 13 | options: { 14 | outputAssetBase: 'native_modules', 15 | }, 16 | }, 17 | }, 18 | { 19 | test: /\.(png|jpg|gif|svg)$/iu, 20 | use: 'url-loader', 21 | }, 22 | { 23 | test: /\.tsx?$/u, 24 | exclude: /(node_modules|\.webpack)/u, 25 | use: { 26 | loader: 'ts-loader', 27 | options: { 28 | transpileOnly: true, 29 | }, 30 | }, 31 | }, 32 | { 33 | test: /\.css$/u, 34 | use: ['style-loader', MiniCssExtractPlugin.loader, 'css-loader'], 35 | }, 36 | ] 37 | --------------------------------------------------------------------------------