├── .eslintignore ├── .gitignore ├── src ├── css │ ├── _colors.scss │ ├── _mixins.scss │ ├── _fonts.scss │ ├── fonts │ │ └── fontawesome-webfont.woff2 │ ├── _resets.scss │ ├── modules │ │ ├── _VerticalGroup.scss │ │ ├── _TitleBlock.scss │ │ ├── _ButtonGroup.scss │ │ └── _Button.scss │ └── popup.scss ├── images │ ├── icon.xcf │ ├── icon128.png │ ├── icon16.png │ ├── icon19.png │ ├── icon32.png │ ├── icon38.png │ └── icon48.png ├── js │ ├── util │ │ ├── Logger.js │ │ ├── function.js │ │ ├── api.js │ │ ├── templates.js │ │ ├── object.js │ │ ├── gradient.js │ │ └── dom.js │ ├── shared │ │ ├── constants.js │ │ └── domain.js │ ├── api │ │ ├── commands.js │ │ ├── storage.js │ │ └── messages.js │ ├── bandcamp.entry.js │ ├── youtube.entry.js │ ├── soundcloud.entry.js │ ├── spotify.entry.js │ ├── youtubemusic.entry.js │ ├── background.entry.js │ ├── popup.entry.js │ └── data │ │ └── tabs.js ├── html │ └── popup.html └── manifest.json ├── .editorconfig ├── README.md ├── deploy.js ├── .github └── workflows │ └── ci.yml ├── package.json ├── webpack.config.js ├── .sass-lint.yml ├── .eslintrc.json └── LICENSE /.eslintignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | dist 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bower_components 2 | /node_modules 3 | /dist 4 | /.idea 5 | *.iml 6 | -------------------------------------------------------------------------------- /src/css/_colors.scss: -------------------------------------------------------------------------------- 1 | $bgColor: #333; 2 | $textColor: #fff; 3 | $highlightColor: #2db69a; 4 | -------------------------------------------------------------------------------- /src/images/icon.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/global-mediakeys/master/src/images/icon.xcf -------------------------------------------------------------------------------- /src/css/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin size($width, $height: $width) { 2 | width: $width; 3 | height: $height; 4 | } 5 | -------------------------------------------------------------------------------- /src/images/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/global-mediakeys/master/src/images/icon128.png -------------------------------------------------------------------------------- /src/images/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/global-mediakeys/master/src/images/icon16.png -------------------------------------------------------------------------------- /src/images/icon19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/global-mediakeys/master/src/images/icon19.png -------------------------------------------------------------------------------- /src/images/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/global-mediakeys/master/src/images/icon32.png -------------------------------------------------------------------------------- /src/images/icon38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/global-mediakeys/master/src/images/icon38.png -------------------------------------------------------------------------------- /src/images/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/global-mediakeys/master/src/images/icon48.png -------------------------------------------------------------------------------- /src/css/_fonts.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'fa'; 3 | src: url('fonts/fontawesome-webfont.woff2') format('woff2'); 4 | } 5 | -------------------------------------------------------------------------------- /src/css/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/global-mediakeys/master/src/css/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | end_of_line = lf 4 | charset = utf-8 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | -------------------------------------------------------------------------------- /src/css/_resets.scss: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, 6 | *::before, 7 | *::after { 8 | box-sizing: inherit; 9 | } 10 | -------------------------------------------------------------------------------- /src/css/modules/_VerticalGroup.scss: -------------------------------------------------------------------------------- 1 | .VerticalGroup { 2 | display: flex; 3 | flex-direction: column; 4 | flex-wrap: nowrap; 5 | height: 100%; 6 | 7 | &-content { 8 | flex-shrink: 0; 9 | } 10 | 11 | &-spacer { 12 | flex-grow: 1; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/css/modules/_TitleBlock.scss: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .TitleBlock { 4 | width: 100%; 5 | background-color: $highlightColor; 6 | color: $textColor; 7 | text-align: center; 8 | text-shadow: $bgColor 0 0 1px; 9 | 10 | &.isClickable { 11 | cursor: pointer; 12 | user-select: none; 13 | } 14 | 15 | &-title { 16 | padding: 0 5px; 17 | font-size: 15px; 18 | 19 | &--sub { 20 | font-size: 10px; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # global-mediakeys 2 | 3 | Use global keyboard shortcuts to control Soundcloud, YouTube Music, and Bandcamp. 4 | 5 | Includes a browser action popup to view song info and album art, or to "like" the current song. 6 | 7 | ![Soundcloud](https://cloud.githubusercontent.com/assets/7673145/9709071/ecd35f8e-54f2-11e5-90a7-217283abb7ae.png) 8 | ![Google Play Music](https://cloud.githubusercontent.com/assets/7673145/9709183/c18e33e2-54f4-11e5-8e7b-180bb07c0d62.png) 9 | -------------------------------------------------------------------------------- /src/css/popup.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'mixins'; 3 | @import 'fonts'; 4 | @import 'resets'; 5 | @import 'modules/VerticalGroup'; 6 | @import 'modules/TitleBlock'; 7 | @import 'modules/ButtonGroup'; 8 | @import 'modules/Button'; 9 | 10 | $Body-fadeDuration: 0.2s; 11 | 12 | body { 13 | @include size(250px); 14 | margin: 0; 15 | 16 | transition: opacity $Body-fadeDuration; 17 | background: $bgColor 50% no-repeat; 18 | background-size: cover; 19 | opacity: 0.5; 20 | 21 | &:hover { 22 | opacity: 1; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/css/modules/_ButtonGroup.scss: -------------------------------------------------------------------------------- 1 | @import '../mixins'; 2 | 3 | .ButtonGroup { 4 | display: flex; 5 | flex-wrap: nowrap; 6 | align-items: center; 7 | justify-content: space-around; 8 | width: 100%; 9 | 10 | &--pullRight { 11 | justify-content: flex-end; 12 | } 13 | 14 | &-button { 15 | @include size(80px); 16 | padding: 15px; 17 | font-size: 20px; 18 | line-height: 50px; 19 | } 20 | 21 | &-smallButton { 22 | @include size(50px); 23 | padding: 10px; 24 | font-size: 10px; 25 | line-height: 30px; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /deploy.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | 'use strict'; 4 | 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | const deploy = require('chrome-extension-deploy'); 8 | 9 | deploy({ 10 | clientId: process.env.CHROME_CLIENT_ID, 11 | clientSecret: process.env.CHROME_CLIENT_SECRET, 12 | refreshToken: process.env.CHROME_REFRESH_TOKEN, 13 | id: 'hhingnpbfhkjnmfkghlihmghgnddojeb', 14 | zip: fs.readFileSync(path.join(__dirname, 'dist/GMK.zip')) 15 | }).then(function() { 16 | console.log('Deploy complete!'); 17 | }, function(err) { 18 | console.error(err); 19 | process.exit(1); 20 | }); 21 | -------------------------------------------------------------------------------- /src/js/util/Logger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file A basic logging class, similar to Android's `Log`. 3 | * @module util/Logger 4 | */ 5 | 6 | /* eslint-disable no-console */ 7 | 8 | export default class Logger { 9 | constructor(tag) { 10 | this._tag = `[${tag}]`; 11 | } 12 | 13 | e(...info) { 14 | console.error(this._tag, ...info); 15 | } 16 | 17 | w(...info) { 18 | console.warn(this._tag, ...info); 19 | } 20 | 21 | i(...info) { 22 | console.info(this._tag, ...info); 23 | } 24 | 25 | d(...info) { 26 | console.log(this._tag, ...info); 27 | } 28 | 29 | v(...info) { 30 | console.debug(this._tag, ...info); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/js/shared/constants.js: -------------------------------------------------------------------------------- 1 | export const MSG = { 2 | REGISTER: 'tab-register', 3 | UNREGISTER: 'tab-unregister', 4 | PLAY_STATE: 'tab-playstate', 5 | 6 | INFO: 'tab-info', 7 | ACTIONS: 'tab-actions', 8 | DO_ACTION: 'tab-do-action', 9 | FOCUS_TAB: 'tab-focus', 10 | 11 | PLAY_PAUSE: 'tab-play-pause', 12 | NEXT: 'tab-next', 13 | PREV: 'tab-prev', 14 | 15 | ECHO: 'echo', 16 | }; 17 | 18 | export const CMD = { 19 | PLAY_PAUSE: 'media-play-pause', 20 | NEXT: 'media-next', 21 | PREV: 'media-prev', 22 | STOP: 'media-stop', 23 | }; 24 | 25 | export const STORAGE = { 26 | TABS: 'registered-tabs', 27 | }; 28 | 29 | export const DEBOUNCE = { 30 | MSG: 50, // ms 31 | }; 32 | -------------------------------------------------------------------------------- /src/js/util/function.js: -------------------------------------------------------------------------------- 1 | // Leading and trailing edge debounce. 2 | // 3 | // For example: 4 | // Inputs: |-----|-|-|--|--|---- 5 | // Outputs: |-----|-------------| 6 | // ^~~~^ ^~~~^ 7 | // delay delay 8 | export function debounce(callback, delay) { 9 | let timeout = 0; 10 | return (...args) => { 11 | if (timeout === 0) { 12 | // not currently bouncing, set timer... 13 | timeout = setTimeout(() => { 14 | timeout = 0; 15 | }, delay); 16 | // ...and fire immediately (leading edge) 17 | callback(...args); 18 | } else { 19 | // currently bouncing, reset timer... 20 | clearTimeout(timeout); 21 | // ...and fire when it expires (trailing edge) 22 | timeout = setTimeout(() => { 23 | timeout = 0; 24 | callback(...args); 25 | }, delay); 26 | } 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/js/api/commands.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file A simple wrapper around `chrome.commands`. 3 | * @module api/commands 4 | */ 5 | 6 | const listeners = new Map(); 7 | 8 | /** 9 | * Register a listener to be invoked whenever `commandName` is fired. 10 | * @param {string} commandName 11 | * @param {function(): void} callback 12 | * @throws {Error} If a listener for `commandName` already exists. 13 | * @returns {void} 14 | */ 15 | export function addListener(commandName, callback) { 16 | if (listeners.has(commandName)) { 17 | throw new Error(`Listener for command: ${commandName} already exists.`); 18 | } 19 | listeners.set(commandName, callback); 20 | } 21 | 22 | chrome.commands.onCommand.addListener(commandName => { 23 | if (!listeners.has(commandName)) { 24 | throw new Error(`Unrecognised command: ${commandName}`); 25 | } 26 | listeners.get(commandName)(); 27 | }); 28 | -------------------------------------------------------------------------------- /src/js/util/api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Utility functions related to the Chrome extension API. 3 | * @module util/api 4 | */ 5 | 6 | /** 7 | * Wraps an asynchronous API call in a promise. 8 | * @param {function(...*, function(...*): void): void} func 9 | * @returns {function(...*): Promise} `func`, in a wrapper that will append a callback to the argument list and return a promise. 10 | * The promise will reject if `chrome.runtime.lastError` is set, 11 | * resolving with the result passed to the callback or an array of results otherwise. 12 | */ 13 | export function apiToPromise(func) { 14 | return (...args) => 15 | new Promise((resolve, reject) => { 16 | func(...args, (...results) => { 17 | if (chrome.runtime.lastError) { 18 | reject(new Error(chrome.runtime.lastError.message)); 19 | } else { 20 | resolve(results.length > 1 ? results : results[0]); 21 | } 22 | }); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/css/modules/_Button.scss: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | @import '../mixins'; 3 | 4 | $Button-popDuration: 0.1s; 5 | 6 | .Button { 7 | @include size(100%); 8 | transition: transform $Button-popDuration; 9 | border-radius: 50%; 10 | background-color: $highlightColor; 11 | color: $textColor; 12 | font-family: 'fa', sans-serif; 13 | text-align: center; 14 | text-shadow: $textColor 0 0 5px; 15 | cursor: pointer; 16 | 17 | &:hover:not(:active) { 18 | transform: scale(1.1); 19 | } 20 | 21 | &.isInactive { 22 | color: $bgColor; 23 | text-shadow: none; 24 | } 25 | 26 | &-prev::after { 27 | content: '\f048'; 28 | } 29 | 30 | &-next::after { 31 | content: '\f051'; 32 | } 33 | 34 | &-playPause::after { 35 | padding-left: 5%; 36 | content: '\f04b'; 37 | 38 | .isPlaying & { 39 | padding-left: 0; 40 | content: '\f04c'; 41 | } 42 | } 43 | 44 | &-custom::after { 45 | content: attr(data-icon); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - v*.*.* 9 | pull_request: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: '12.x' 19 | registry-url: 'https://registry.npmjs.org' 20 | - run: yarn install 21 | 22 | - run: yarn lint 23 | - run: yarn build 24 | 25 | - uses: softprops/action-gh-release@v1 26 | if: startsWith(github.ref, 'refs/tags/') 27 | with: 28 | files: dist/GMK.zip 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | - run: node deploy.js 32 | if: startsWith(github.ref, 'refs/tags/') 33 | env: 34 | CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} 35 | CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} 36 | CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} 37 | -------------------------------------------------------------------------------- /src/js/util/templates.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file A primitive utility for templating HTML. 3 | * @module util/templates 4 | */ 5 | 6 | /** 7 | * Populate a template with values from `map`. 8 | * Template keys may be formatted as follows: `{{key}}`. 9 | * @param {string} templateId The id of the HTML `